S7b-forberedelse, fase 2. Bruksscenarioet "dokumenter + konkret oppgave ->
forslag" hadde ingen vei: eneste kandidat-kilde var generate_via_llm.
DEN TILFOEYDE VEIEN. Eksperten sier HVA (label -> measure, verbatim), HVILKE
linjer (affected_codes) og HVOR MYE (claimed_saving_nok); dokumentet sier
MENGDE og PRIS (derive_cost_baseline, MAJOR-4). Ingenting her oppfinner et
tall. Tre nekter ved navn - manglende anslag, ingen koder, ukjent kode - og
hver av dem er ekspertens aa skrive, aldri vaar aa defaulte
(write_concept_file-regelen).
ANSLAGET BOR PAA APPROACH, IKKE PAA MANDATE, og det foelger av settle sin egen
regel: tilnaerminger er ALTERNATIVER og summeres aldri, saa ett tall paa
mandatnivaa ville vaert tvetydig over N. Det er heller ikke kjoeringens MAAL -
contracts.GoalContract eier nettopp ett saant, og skillet staar skrevet der
feltet innfoeres, ellers leses det som den driften modulen forbyr.
NULL MODELLKALL ER STRUKTURELT: evaluate_mandate_candidates er SYNC, saa den kan
ikke aware et chat-kall - ingen mutasjon av kroppen kan stille innfoere ett.
CLI-armen asserterer det likevel ATFERDSMESSIG (_default_factory patchet til aa
raise), fordi rc 0 alene ogsaa er utfallet til en doer som gjorde ingenting.
allow_own_proposals faar en not_evaluated-RAD, ikke en nekt: raden kan ikke
fylles uten en modell, men aa utelate den gjoer den uskillbar fra en
tilnaerming ingen bestilte (ApproachOutcome sin egen regel), og aa nekte hele
kjoeringen ville vaert feil andre veien - feltet defaulter til True.
Fire CLI-nekter, alle ved navn: krever --mandate, krever
--derive-cost-baseline, nektet i --portfolio (ved NAVN, ikke ved gjennomfall)
og i report_forbidden (der en utelatelse er et stille DROPP, ikke en nekt -
F4-gapet).
LOAD-BEARING MAALT: 15 mutasjoner, 14 ROEDE mot HELE suiten + groenn kontroll
1274/5 (fra 1257, supersett, 0 fjernet) og golden demo-transcript.stdout
BYTE-UENDRET (shasum -a 1 = ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
TO MUTASJONER FALSIFISERTE TESTEN FOERST (repoets vakuoes-gate-klasse):
* M5 (bygg lazily) sto GROENN - armen asserterte kun pytest.raises, og BEGGE
implementasjoner reiser; ingen rad naar kalleren uansett, saa de er
uskillbare utenfra. Oekt 57s regel ("en nekt etter forbruket ser identisk ut
ved exit-koden") anvendt paa CBC-solves: testen TELLER naa solves, med en
kontroll som beviser at telleren faktisk beveger seg.
* M14 (rut paa mount-navnet) sto GROENN - armen brukte f"not-{declared}", som
matcher verken mount eller erklaering. Fixturene erklaerer ingen bundle_id
(S7a-3 maalte null ^bundle_id-treff under tests/), saa de to SAMMENFALLER
der. Ny arm bygger en base som erklaerer en id ulik katalognavnet og
asserterer BEGGE halvdeler: erklaert ruter, mount nektes.
EN MUTASJON FORBLIR GROENN, OG DET ER EN AERLIGHETS-GRENSE - IKKE EN GATE:
M7 (doem UTEN baselinen) er strukturelt uobserverbar, fordi kandidaten er
BYGGET fra baselinen og stage 0 derfor avstemmer med 0 % avvik ved
konstruksjon. baseline= staar som en DEFENSIV, uvitnet soem
(budget_stop-presedensen), ikke som noe en test holder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
428 lines
19 KiB
Python
428 lines
19 KiB
Python
"""A candidate proposal that ARISES from the commission + the bundle's own priced schedule, with
|
||
no model in the loop (S7b-forberedelse, ordre ``20260903T014107Z-72282048``).
|
||
|
||
**The order's premise was felled before anything was built on it, and the measurement is what
|
||
made this two seams rather than one** (``docs/2026-09-03-forslag-fra-mandat.md``). The order says
|
||
the proposal is *read from a hand-written* ``validator-input.json``. The symptom is real — a base
|
||
without that file refuses with ``FileNotFoundError`` before the first model call — but the
|
||
diagnosis is not: ``SavingsProposal`` has ALWAYS been built by ``generate._parse_ir`` from the
|
||
MODEL's reply. The file is a fixture BESIDE the run path, required only as the project's identity
|
||
(``run._project_from_bundle``, plus two other readers). So what was missing was never a way to stop
|
||
reading a file; it was a DETERMINISTIC CANDIDATE SOURCE beside ``generate_via_llm``. This module
|
||
gates that source. Making the IR projection optional is the OTHER seam, measured in the document
|
||
and deliberately not built here.
|
||
|
||
**The fixture is used UNTOUCHED, and that is the arm's whole point.**
|
||
``test_cost_baseline_derivation_loadbearing`` has to call ``_runnable()`` — copy the fixture and
|
||
write a ``validator-input.json`` into the copy — before ``run_project`` will look at it. Here the
|
||
same fixture runs as it stands. A test that had to author that file first would have proved the
|
||
opposite of what it claims.
|
||
|
||
**Null model calls is STRUCTURAL, not merely measured.** ``run.evaluate_mandate_candidates`` is a
|
||
SYNC function: it cannot await a chat call, so no mutation of its body can quietly introduce one.
|
||
The CLI arm still asserts the property behaviourally — ``run._default_factory`` is patched to raise,
|
||
which is the seam ``test_run_cli_loadbearing`` uses, and it is the only way to prove the door
|
||
reaches the offline path rather than merely returning 0 by some other route.
|
||
|
||
**The discriminating arm is (a2), not (a).** Copying ``affected_items`` out of the derived baseline
|
||
makes stage 0 reconcile at 0 % deviation by construction, and with no assumption bands the Monte
|
||
Carlo is degenerate (P10 == P50 == P90). "The validator ran" is therefore nearly green by
|
||
construction — this repo's vacuous-gate class. The live constraint is ``MAX_SAVING_FRACTION``: a
|
||
claim above 30 % of the affected total must be REJECTED by the ordinary gate, which is what proves
|
||
the candidate goes THROUGH ``validate_proposal`` rather than around it.
|
||
|
||
Refusals assert the NAMED class and the distinguishing token, never bare ``ValueError``: pydantic's
|
||
``ValidationError`` subclasses ``ValueError`` and ``SavingsProposal`` carries two model validators,
|
||
so a source that fabricated a figure would raise ``ValueError`` too and a loose arm would stay green
|
||
against the very mutation it exists to catch (MAJOR-4's own M17 lesson).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from portfolio_optimiser import okf, run
|
||
from portfolio_optimiser.mandate import (
|
||
OWN_PROPOSAL_ID,
|
||
Approach,
|
||
Mandate,
|
||
MandateCandidateError,
|
||
candidate_from_approach,
|
||
)
|
||
|
||
_FIXTURES = Path(__file__).parent / "fixtures"
|
||
#: MAJOR-4's priced fixture, used EXACTLY as it sits on disk — no ``validator-input.json`` added.
|
||
_PRICED = str(_FIXTURES / "k2-prisskjema-SYNTETISK")
|
||
_UNPRICED = str(_FIXTURES / "k2-prisskjema-uprisert-SYNTETISK")
|
||
|
||
_PROJECT = "K2"
|
||
#: Transcribed from the fixture's table, not from the deriver: ``21.1`` is 1250 × 850.
|
||
_LINE_211_TOTAL = 1250.0 * 850.0
|
||
#: ``validator.MAX_SAVING_FRACTION`` is 0.30, so this is the boundary the ordinary gate enforces.
|
||
_FEASIBLE_211 = _LINE_211_TOTAL * 0.30
|
||
|
||
|
||
def _baseline() -> Any:
|
||
return okf.derive_cost_baseline(okf.navigate_bundle(_PRICED), project_id=_PROJECT)
|
||
|
||
|
||
def _approach(**overrides: Any) -> Approach:
|
||
fields: dict[str, Any] = {
|
||
"id": "a1",
|
||
"label": "Redusert sprengningsvolum i sone A",
|
||
"description": "Eksperten mener massetaket kan flyttes.",
|
||
"affected_codes": ("21.1",),
|
||
"claimed_saving_nok": 200_000.0,
|
||
}
|
||
fields.update(overrides)
|
||
return Approach(**fields)
|
||
|
||
|
||
def _mandate(*approaches: Approach, allow_own: bool = False) -> Mandate:
|
||
return Mandate(
|
||
objective="Finn kostnadsbesparelser i K2",
|
||
approaches=tuple(approaches),
|
||
allow_own_proposals=allow_own,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------------------------
|
||
# (a) the candidate is built from the commission + the derived baseline
|
||
# --------------------------------------------------------------------------------------------
|
||
|
||
|
||
def test_candidate_carries_the_experts_own_words_and_the_bundles_own_numbers() -> None:
|
||
"""``measure`` is the expert's label VERBATIM; the quantities are the BASELINE's, never the
|
||
approach's — the expert says WHAT and HOW MUCH, the document says at what price."""
|
||
candidate = candidate_from_approach(_approach(), baseline=_baseline(), project_id=_PROJECT)
|
||
|
||
assert candidate.project_id == _PROJECT
|
||
assert candidate.measure == "Redusert sprengningsvolum i sone A"
|
||
assert candidate.claimed_saving_nok == 200_000.0
|
||
assert [(i.code, i.quantity, i.unit_cost) for i in candidate.affected_items] == [
|
||
("21.1", 1250.0, 850.0)
|
||
]
|
||
# Stated as a measured honesty limit rather than left implicit: no band can be derived from a
|
||
# single price, so the Monte Carlo stage is degenerate on this path.
|
||
assert candidate.assumptions == {}
|
||
|
||
|
||
def test_named_codes_select_their_lines_in_the_order_the_expert_named_them() -> None:
|
||
candidate = candidate_from_approach(
|
||
_approach(affected_codes=("36.1", "21.1"), claimed_saving_nok=100_000.0),
|
||
baseline=_baseline(),
|
||
project_id=_PROJECT,
|
||
)
|
||
assert [i.code for i in candidate.affected_items] == ["36.1", "21.1"]
|
||
|
||
|
||
def test_the_ordinary_gate_validates_a_claim_inside_the_feasible_bound() -> None:
|
||
rows = run.evaluate_mandate_candidates(
|
||
_mandate(_approach()), bundle_dir=_PRICED, project_id=_PROJECT
|
||
)
|
||
assert [(r.id, r.status) for r in rows] == [("a1", "validated")]
|
||
assert rows[0].saving_nok == 200_000.0
|
||
|
||
|
||
def test_the_ordinary_gate_rejects_a_claim_above_the_feasible_bound() -> None:
|
||
"""THE DISCRIMINATOR. Everything else about a baseline-copied candidate reconciles at 0 %
|
||
deviation by construction, so this is the one arm that can tell a candidate that goes THROUGH
|
||
``validate_proposal`` from one that goes around it. The figure is above 30 % of the affected
|
||
total and below the total itself, so pydantic's own ``claimed <= total`` validator does NOT
|
||
fire — the rejection has to come from the validator's stages."""
|
||
over = _FEASIBLE_211 + 50_000.0
|
||
assert over < _LINE_211_TOTAL # pydantic would otherwise refuse at construction
|
||
|
||
rows = run.evaluate_mandate_candidates(
|
||
_mandate(_approach(claimed_saving_nok=over)), bundle_dir=_PRICED, project_id=_PROJECT
|
||
)
|
||
assert [(r.id, r.status) for r in rows] == [("a1", "rejected")]
|
||
assert "feasible" in rows[0].detail
|
||
|
||
|
||
# --------------------------------------------------------------------------------------------
|
||
# (b) what the expert did not say is REFUSED BY NAME, never invented
|
||
# --------------------------------------------------------------------------------------------
|
||
|
||
|
||
def test_an_approach_without_an_estimate_is_refused_by_name() -> None:
|
||
with pytest.raises(MandateCandidateError) as excinfo:
|
||
candidate_from_approach(
|
||
_approach(claimed_saving_nok=None), baseline=_baseline(), project_id=_PROJECT
|
||
)
|
||
message = str(excinfo.value)
|
||
assert "a1" in message
|
||
assert "claimed_saving_nok" in message
|
||
|
||
|
||
def test_an_approach_without_cost_codes_is_refused_by_name() -> None:
|
||
with pytest.raises(MandateCandidateError) as excinfo:
|
||
candidate_from_approach(
|
||
_approach(affected_codes=()), baseline=_baseline(), project_id=_PROJECT
|
||
)
|
||
message = str(excinfo.value)
|
||
assert "a1" in message
|
||
assert "affected_codes" in message
|
||
|
||
|
||
def test_a_code_the_baseline_does_not_carry_is_refused_by_name() -> None:
|
||
"""``route_by_bundle``'s rule, one level down: a key that names something not configured is
|
||
refused BY NAME rather than resolved by position. The known codes are listed, because the
|
||
operator's next move is to correct the mandate against the document."""
|
||
with pytest.raises(MandateCandidateError) as excinfo:
|
||
candidate_from_approach(
|
||
_approach(affected_codes=("99.9",)), baseline=_baseline(), project_id=_PROJECT
|
||
)
|
||
message = str(excinfo.value)
|
||
assert "99.9" in message
|
||
assert "21.1" in message # the known codes are named
|
||
|
||
|
||
def test_the_refusal_fires_before_any_approach_is_evaluated(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Fail-fast on a commission that cannot be executed AS WRITTEN (``load_mandate``'s rule): one
|
||
unfillable approach refuses the whole run rather than doing work for the approaches ahead of it.
|
||
The good approach is FIRST, so a source that built lazily would have solved it before raising.
|
||
|
||
**The assert is on the WORK, not on the exception**, and that is measured rather than stylistic:
|
||
an earlier version of this arm asserted only ``pytest.raises`` and stayed GREEN against the lazy
|
||
mutation, because both implementations raise and no row ever reaches the caller either way —
|
||
from the outside the two are indistinguishable. This is økt 57's rule ("a refusal after the
|
||
spend looks identical at the exit code") applied to CBC solves instead of model calls: count the
|
||
solves."""
|
||
solves = 0
|
||
real = run.validate_proposal
|
||
|
||
def _counting(*args: Any, **kwargs: Any) -> Any:
|
||
nonlocal solves
|
||
solves += 1
|
||
return real(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(run, "validate_proposal", _counting)
|
||
|
||
with pytest.raises(MandateCandidateError):
|
||
run.evaluate_mandate_candidates(
|
||
_mandate(_approach(), _approach(id="a2", claimed_saving_nok=None)),
|
||
bundle_dir=_PRICED,
|
||
project_id=_PROJECT,
|
||
)
|
||
assert solves == 0
|
||
|
||
# The control: with the same wiring and a fillable commission the counter DOES move, so a
|
||
# zero above is a property of the refusal rather than of a patch that never took effect.
|
||
run.evaluate_mandate_candidates(_mandate(_approach()), bundle_dir=_PRICED, project_id=_PROJECT)
|
||
assert solves == 1
|
||
|
||
|
||
def test_an_unpriced_schedule_still_refuses_in_full() -> None:
|
||
"""MAJOR-4's refusal is not routed around: the candidate source has nothing to quantify with,
|
||
so the derivation's own named refusal propagates rather than degrading to an un-anchored run."""
|
||
with pytest.raises(okf.CostBaselineDerivationError):
|
||
run.evaluate_mandate_candidates(
|
||
_mandate(_approach()), bundle_dir=_UNPRICED, project_id=_PROJECT
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------------------------
|
||
# (c) the coverage report stays honest about what this path CANNOT do
|
||
# --------------------------------------------------------------------------------------------
|
||
|
||
|
||
def test_own_proposals_are_reported_as_not_evaluated_rather_than_omitted() -> None:
|
||
"""A run's OWN proposal needs a model, and this path has none. The row is REPORTED with a named
|
||
reason rather than dropped: ``ApproachOutcome``'s own rule is that an omitted row is
|
||
indistinguishable from an approach nobody commissioned. Refusing the whole run would be wrong
|
||
the other way — ``allow_own_proposals`` defaults to True, so every mandate written so far
|
||
carries it."""
|
||
rows = run.evaluate_mandate_candidates(
|
||
_mandate(_approach(), allow_own=True), bundle_dir=_PRICED, project_id=_PROJECT
|
||
)
|
||
assert [(r.id, r.status) for r in rows] == [
|
||
("a1", "validated"),
|
||
(OWN_PROPOSAL_ID, "not_evaluated"),
|
||
]
|
||
assert "no model" in rows[1].detail
|
||
|
||
|
||
def _declaring_base(tmp_path: Path, declared: str) -> str:
|
||
"""A copy of the priced fixture that DECLARES a bundle id differing from its mount name.
|
||
|
||
Needed because the shipped fixtures declare none — S7a-3 measured zero ``^bundle_id`` matches
|
||
anywhere under ``tests/`` — so on them ``reconcile_bundle_id`` falls through to the mount's
|
||
basename and the declared id and the mount COINCIDE. A routing arm written against such a base
|
||
cannot tell the two apart, which is exactly what let the mount-name mutation stay green.
|
||
"""
|
||
import shutil
|
||
|
||
root = tmp_path / "mounted-under-another-name"
|
||
shutil.copytree(_PRICED, root)
|
||
for path in root.glob("*.md"):
|
||
text = path.read_text(encoding="utf-8")
|
||
path.write_text(
|
||
text.replace("---\ntype:", f"---\nbundle_id: {declared}\ntype:", 1), "utf-8"
|
||
)
|
||
return str(root)
|
||
|
||
|
||
def test_routing_follows_the_declared_id_and_refuses_the_mount_name(tmp_path: Path) -> None:
|
||
"""Routing is not re-implemented here: ``route_by_bundle`` is called with the base's DECLARED id
|
||
(S7a-3 pkt. 1), so a base delivered under a directory name of its own routes as ITSELF.
|
||
|
||
Both halves are asserted, because a mutation that routed on the mount name differs from the
|
||
shipped code in BOTH directions — and an arm that only refused an id matching neither (an
|
||
earlier version used ``f"not-{declared}"``) is green against it, since both implementations
|
||
refuse a name that is nobody's."""
|
||
declared = "k2-trinn1-20260903"
|
||
base = _declaring_base(tmp_path, declared)
|
||
assert okf.reconcile_bundle_id(base).id == declared
|
||
assert Path(base).name != declared # the two are genuinely distinct here
|
||
|
||
rows = run.evaluate_mandate_candidates(
|
||
_mandate(_approach(bundle_id=declared)), bundle_dir=base, project_id=_PROJECT
|
||
)
|
||
assert [(r.id, r.status) for r in rows] == [("a1", "validated")]
|
||
|
||
with pytest.raises(run.MandateRoutingError):
|
||
run.evaluate_mandate_candidates(
|
||
_mandate(_approach(bundle_id=Path(base).name)),
|
||
bundle_dir=base,
|
||
project_id=_PROJECT,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------------------------
|
||
# (d) the CLI door
|
||
# --------------------------------------------------------------------------------------------
|
||
|
||
|
||
def _argv(*extra: str) -> list[str]:
|
||
return [
|
||
_PROJECT,
|
||
"--docs-dir",
|
||
_PRICED,
|
||
"--bundle-dir",
|
||
_PRICED,
|
||
"--derive-cost-baseline",
|
||
"--proposals-from-mandate",
|
||
*extra,
|
||
]
|
||
|
||
|
||
def _mandate_file(tmp_path: Path, mandate: Mandate) -> str:
|
||
path = tmp_path / "mandate.json"
|
||
path.write_text(mandate.model_dump_json(), encoding="utf-8")
|
||
return str(path)
|
||
|
||
|
||
def test_cli_runs_the_whole_path_without_building_a_single_model_client(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""The behavioural half of "null model calls". ``_default_factory`` is the one injection point
|
||
the CLI leaves (``test_run_cli_loadbearing``'s seam), so a door that fell through to the debate
|
||
would raise here instead of returning 0. Asserting rc 0 alone would pass against a door that
|
||
quietly did nothing."""
|
||
|
||
def _refuse(_profile: Any) -> Any:
|
||
raise AssertionError("a model client was built on a path that must make no model calls")
|
||
|
||
monkeypatch.setattr(run, "_default_factory", _refuse)
|
||
|
||
rc = run.main(_argv("--mandate", _mandate_file(tmp_path, _mandate(_approach()))))
|
||
assert rc == 0
|
||
out = capsys.readouterr().out
|
||
assert "VALIDATED" in out
|
||
assert "a1" in out
|
||
|
||
|
||
def test_cli_refuses_the_flag_without_a_mandate(capsys: pytest.CaptureFixture[str]) -> None:
|
||
rc = run.main(_argv())
|
||
assert rc == 1
|
||
assert "--mandate" in capsys.readouterr().err
|
||
|
||
|
||
def test_cli_refuses_the_flag_without_a_derived_baseline(
|
||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""The derived baseline is the candidate's only source of quantities, so without it there is
|
||
nothing to build an ``affected_items`` from. Refused by NAME rather than surfacing later as a
|
||
missing-baseline failure that names neither flag."""
|
||
rc = run.main(
|
||
[
|
||
_PROJECT,
|
||
"--docs-dir",
|
||
_PRICED,
|
||
"--bundle-dir",
|
||
_PRICED,
|
||
"--proposals-from-mandate",
|
||
"--mandate",
|
||
_mandate_file(tmp_path, _mandate(_approach())),
|
||
]
|
||
)
|
||
assert rc == 1
|
||
assert "--derive-cost-baseline" in capsys.readouterr().err
|
||
|
||
|
||
def test_cli_refuses_the_flag_in_portfolio_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||
rc = run.main(["--portfolio", "--proposals-from-mandate"])
|
||
assert rc == 1
|
||
err = capsys.readouterr().err
|
||
# Named, not fallen through to "requires --mandate": an operator who wrote --portfolio has to
|
||
# hear which of the two is wrong (the --explore precedent).
|
||
assert "--portfolio" in err
|
||
assert "--proposals-from-mandate" in err
|
||
|
||
|
||
def test_cli_refuses_the_flag_in_report_mode(
|
||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""Report mode returns BEFORE the run dispatch, so a flag missing from ``report_forbidden`` is
|
||
a SILENT DROP rather than a refusal (the gap F4 measured). The argv is one report mode would
|
||
otherwise ACCEPT, so rc 1 is the mutant's opposite outcome."""
|
||
ledger = tmp_path / "ledger.json"
|
||
ledger.write_text(json.dumps([]), encoding="utf-8")
|
||
assert run.main(["--report", "--ledger", str(ledger)]) == 0
|
||
capsys.readouterr()
|
||
rc = run.main(["--report", "--ledger", str(ledger), "--proposals-from-mandate"])
|
||
assert rc == 1
|
||
assert "mode-exclusive" in capsys.readouterr().err
|
||
|
||
|
||
# --------------------------------------------------------------------------------------------
|
||
# (e) the control: the hand-written projection is still preferred, and untouched
|
||
# --------------------------------------------------------------------------------------------
|
||
|
||
|
||
async def test_a_bundle_carrying_the_ir_projection_runs_exactly_as_before(tmp_path: Path) -> None:
|
||
"""The control the order asks for. This path is ADDITIVE: a base with a hand-written
|
||
``validator-input.json`` reaches the unchanged bundle arm, anchoring included. If this arm ever
|
||
goes red, the new door has started competing with the old one instead of standing beside it."""
|
||
import shutil
|
||
|
||
root = tmp_path / "runnable"
|
||
shutil.copytree(_PRICED, root)
|
||
(root / "validator-input.json").write_text(
|
||
json.dumps(
|
||
{
|
||
"project_id": _PROJECT,
|
||
"measure": "energy_efficiency",
|
||
"affected_items": [{"code": "21.1", "quantity": 1250.0, "unit_cost": 850.0}],
|
||
"claimed_saving_nok": 100_000.0,
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
report = await run.run_project(
|
||
_PROJECT,
|
||
"local",
|
||
docs_dir=str(root),
|
||
bundle_dir=str(root),
|
||
derive_cost_baseline=True,
|
||
live_dry_run=True,
|
||
)
|
||
assert isinstance(report, run.DryRunReport)
|
||
assert report.cost_baseline_anchored is True
|