feat(mandate): forslaget OPPSTAAR fra kommisjonen og basens eget prisskjema - null modellkall
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>
This commit is contained in:
parent
19badcd0fe
commit
a79e2f8965
3 changed files with 693 additions and 1 deletions
|
|
@ -36,6 +36,8 @@ from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from portfolio_optimiser.ir import AffectedItem, CostBaseline, SavingsProposal
|
||||||
|
|
||||||
#: Coverage-report id for the run's OWN (non-commissioned) proposal. Reserved: an expert approach
|
#: Coverage-report id for the run's OWN (non-commissioned) proposal. Reserved: an expert approach
|
||||||
#: may not claim it, because the two rows would collapse onto one key.
|
#: may not claim it, because the two rows would collapse onto one key.
|
||||||
OWN_PROPOSAL_ID = "own-proposal"
|
OWN_PROPOSAL_ID = "own-proposal"
|
||||||
|
|
@ -51,6 +53,28 @@ class Approach(BaseModel):
|
||||||
id: str = Field(min_length=1)
|
id: str = Field(min_length=1)
|
||||||
label: str = Field(min_length=1)
|
label: str = Field(min_length=1)
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
#: The cost codes this approach claims to save against, named by the expert (S7b).
|
||||||
|
#:
|
||||||
|
#: Which lines of a priced schedule an approach touches is JUDGEMENT, and it is the expert's
|
||||||
|
#: to exercise: a deterministic source that took "all of them" would make ``claimed_saving_nok
|
||||||
|
#: <= affected total`` slack enough to admit almost anything, and one that guessed a subset
|
||||||
|
#: would put its own guess behind the expert's name. Empty means the approach is not
|
||||||
|
#: quantifiable deterministically — ``candidate_from_approach`` refuses by name rather than
|
||||||
|
#: choosing.
|
||||||
|
affected_codes: tuple[str, ...] = ()
|
||||||
|
#: The expert's OWN estimate of what this approach saves, in NOK (S7b). ``None`` = not stated.
|
||||||
|
#:
|
||||||
|
#: **Per approach, never per mandate**, and that follows from ``settle``'s own rule: commissioned
|
||||||
|
#: approaches are ALTERNATIVES and are never summed, so one figure at mandate level would be
|
||||||
|
#: ambiguous across N of them.
|
||||||
|
#:
|
||||||
|
#: **This is not the run's TARGET, and the distinction is load-bearing.** The module docstring
|
||||||
|
#: refuses to carry a numeric goal because ``contracts.GoalContract`` / ``--goals`` owns exactly
|
||||||
|
#: one — and two copies of one number drift apart (the ``(p)`` precedent). A target says what the
|
||||||
|
#: RUN must achieve and is compared against the ledger; this says what ONE candidate claims and
|
||||||
|
#: is what the deterministic validator falsifies. They are different facts with different
|
||||||
|
#: owners, so this is a new number rather than a second copy of that one.
|
||||||
|
claimed_saving_nok: float | None = None
|
||||||
#: Which knowledge base this approach belongs to (§ C.7), by the base's id — exactly as
|
#: Which knowledge base this approach belongs to (§ C.7), by the base's id — exactly as
|
||||||
#: ``okf.reconcile_bundle_id`` resolves it (the base's own declaration where it makes one, the
|
#: ``okf.reconcile_bundle_id`` resolves it (the base's own declaration where it makes one, the
|
||||||
#: mount's basename otherwise). DEFAULTS to empty, meaning "no base
|
#: mount's basename otherwise). DEFAULTS to empty, meaning "no base
|
||||||
|
|
@ -111,6 +135,90 @@ class MandateRoutingError(ValueError):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MandateCandidateError(ValueError):
|
||||||
|
"""A commissioned approach that cannot be quantified as written (S7b).
|
||||||
|
|
||||||
|
A ``ValueError`` for ``MandateRoutingError``'s measured reason: a caller's configuration mistake
|
||||||
|
must land on ``run.main``'s refusal tuple and ``hosting``'s 400 arm, never the crash channel.
|
||||||
|
|
||||||
|
Raised, never worked around. The two fields this needs — the estimate and the cost codes — are
|
||||||
|
the expert's to write, and the alternatives are both worse than a refusal: inventing a figure
|
||||||
|
puts a number nobody stands behind through a gate whose whole purpose is to falsify numbers,
|
||||||
|
and defaulting the codes to the whole schedule quietly widens the only bound
|
||||||
|
(``claimed <= affected total``) that a baseline-copied candidate does not satisfy by
|
||||||
|
construction. Validation, NEVER repair — ``write_concept_file``'s rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_from_approach(
|
||||||
|
approach: Approach, *, baseline: CostBaseline, project_id: str
|
||||||
|
) -> SavingsProposal:
|
||||||
|
"""Build ONE candidate ``SavingsProposal`` from a commissioned approach and the project's own
|
||||||
|
priced schedule — the deterministic candidate source beside ``generate_via_llm`` (S7b).
|
||||||
|
|
||||||
|
**The split of authorship is the whole design.** The expert says WHAT (``label`` -> ``measure``,
|
||||||
|
verbatim), WHICH lines (``affected_codes``) and HOW MUCH (``claimed_saving_nok``); the bundle's
|
||||||
|
own baseline says at what QUANTITY and PRICE. Nothing here originates a number: every quantity
|
||||||
|
and unit cost is copied from ``baseline``, which on the S7b path is itself derived from a table
|
||||||
|
the producer rendered into the bundle (``okf.derive_cost_baseline``). ``project_id`` comes from
|
||||||
|
the run, which is the one party that already knows it.
|
||||||
|
|
||||||
|
**No judgement, and every gap is a refusal by name** — the ``derive_cost_baseline`` contract one
|
||||||
|
level up: a missing estimate, no named codes, or a code the baseline does not carry. The known
|
||||||
|
codes are listed in that last message because the operator's next move is to correct the mandate
|
||||||
|
against the document.
|
||||||
|
|
||||||
|
**Order follows the expert's own naming**, not the baseline's dict order: the mandate is what a
|
||||||
|
person wrote and reads back, and a reordered list would make a settlement harder to check against
|
||||||
|
the commission it settles.
|
||||||
|
|
||||||
|
**Honesty limits, stated.** ``assumptions`` is left EMPTY: an uncertainty band cannot be derived
|
||||||
|
from a single price, and inventing one would move the Monte Carlo's samples away from the
|
||||||
|
document's own figure. The consequence is measured rather than hidden — ``validator._monte_carlo``
|
||||||
|
falls back to each item's ``unit_cost``, so P10 == P50 == P90 and stage 3 reports percentiles that
|
||||||
|
carry no uncertainty. What still binds is stage 0 (reconciliation), stage 2/4b (the CBC solve
|
||||||
|
against ``MAX_SAVING_FRACTION``) and pydantic's ``claimed <= total``. And ``measure`` is not only
|
||||||
|
prose: ``validate_proposal`` stage 5 looks ``METHOD_CAPS`` up by it, so an expert's label reaches
|
||||||
|
the method cap only if it IS a registered method name — naming the method is a separate decision
|
||||||
|
this function cannot make for them.
|
||||||
|
|
||||||
|
:raises MandateCandidateError: the approach does not carry what a candidate needs.
|
||||||
|
:raises pydantic.ValidationError: the expert's figure exceeds the named lines' own total (a
|
||||||
|
``ValueError`` too, so it lands on the same refusal surfaces).
|
||||||
|
"""
|
||||||
|
if approach.claimed_saving_nok is None:
|
||||||
|
raise MandateCandidateError(
|
||||||
|
f"approach {approach.id!r} states no claimed_saving_nok, so there is no saving to "
|
||||||
|
"validate; the estimate is the expert's to write and is never invented here"
|
||||||
|
)
|
||||||
|
if not approach.affected_codes:
|
||||||
|
raise MandateCandidateError(
|
||||||
|
f"approach {approach.id!r} names no affected_codes, so which cost lines it saves "
|
||||||
|
"against is unknown; defaulting to the whole schedule would widen the only bound a "
|
||||||
|
"derived candidate does not already satisfy"
|
||||||
|
)
|
||||||
|
unknown = [code for code in approach.affected_codes if code not in baseline.items]
|
||||||
|
if unknown:
|
||||||
|
raise MandateCandidateError(
|
||||||
|
f"approach {approach.id!r} names cost code(s) {', '.join(sorted(unknown))}, which the "
|
||||||
|
f"project's cost baseline does not carry; it carries: {', '.join(sorted(baseline.items))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SavingsProposal(
|
||||||
|
project_id=project_id,
|
||||||
|
measure=approach.label,
|
||||||
|
affected_items=[
|
||||||
|
AffectedItem(
|
||||||
|
code=code,
|
||||||
|
quantity=baseline.items[code].quantity,
|
||||||
|
unit_cost=baseline.items[code].unit_cost,
|
||||||
|
)
|
||||||
|
for code in approach.affected_codes
|
||||||
|
],
|
||||||
|
claimed_saving_nok=approach.claimed_saving_nok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def route_by_bundle(mandate: Mandate, bundle_ids: Sequence[str]) -> tuple[tuple[str, Mandate], ...]:
|
def route_by_bundle(mandate: Mandate, bundle_ids: Sequence[str]) -> tuple[tuple[str, Mandate], ...]:
|
||||||
"""Partition one commission into one sub-mandate PER knowledge base (§ C.7).
|
"""Partition one commission into one sub-mandate PER knowledge base (§ C.7).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,10 @@ from portfolio_optimiser.mandate import (
|
||||||
Approach,
|
Approach,
|
||||||
ApproachOutcome,
|
ApproachOutcome,
|
||||||
Mandate,
|
Mandate,
|
||||||
|
MandateCandidateError,
|
||||||
MandateRoutingError,
|
MandateRoutingError,
|
||||||
announce,
|
announce,
|
||||||
|
candidate_from_approach,
|
||||||
load_mandate,
|
load_mandate,
|
||||||
route_by_bundle,
|
route_by_bundle,
|
||||||
settle,
|
settle,
|
||||||
|
|
@ -98,7 +100,12 @@ from portfolio_optimiser.mcp_tools import (
|
||||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||||
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
||||||
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
|
from portfolio_optimiser.tracing import TracingConfigError, configure_tracing, tracing_notice
|
||||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal, baseline_from_project
|
from portfolio_optimiser.validator import (
|
||||||
|
Rejection,
|
||||||
|
ValidatedProposal,
|
||||||
|
baseline_from_project,
|
||||||
|
validate_proposal,
|
||||||
|
)
|
||||||
from portfolio_optimiser import hitl, okf, outbox
|
from portfolio_optimiser import hitl, okf, outbox
|
||||||
from portfolio_optimiser.semretrieval import (
|
from portfolio_optimiser.semretrieval import (
|
||||||
SEMANTIC_WEIGHT_DEFAULT,
|
SEMANTIC_WEIGHT_DEFAULT,
|
||||||
|
|
@ -319,6 +326,75 @@ def _coverage_row(
|
||||||
return ApproachOutcome(id=row_id, label=label, status="rejected", detail=outcome.reason)
|
return ApproachOutcome(id=row_id, label=label, status="rejected", detail=outcome.reason)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_mandate_candidates(
|
||||||
|
mandate: Mandate, *, bundle_dir: str, project_id: str
|
||||||
|
) -> tuple[ApproachOutcome, ...]:
|
||||||
|
"""Judge every commissioned approach as a DETERMINISTIC candidate, built from the commission and
|
||||||
|
the knowledge base's own priced schedule — no model anywhere (S7b).
|
||||||
|
|
||||||
|
The use case this serves is "documents + a concrete task -> a judged proposal". Until now the
|
||||||
|
only candidate source was ``generate_via_llm``, and the bundle arm additionally required a
|
||||||
|
hand-written ``validator-input.json`` for the project's identity — so an ingested tender corpus
|
||||||
|
could be navigated and never run (measured: ``docs/2026-09-03-forslag-fra-mandat.md``). This is
|
||||||
|
the candidate source; making that projection optional is the OTHER seam and is not built here.
|
||||||
|
|
||||||
|
**SYNC, and that is the design rather than an omission.** A sync function cannot await a chat
|
||||||
|
call, so "this path makes no model calls" is a property of its type instead of a promise its body
|
||||||
|
has to keep. No mutation of the body can quietly reintroduce one.
|
||||||
|
|
||||||
|
**Nothing here is re-implemented.** Routing is ``route_by_bundle`` against the base's DECLARED id
|
||||||
|
(S7a-3, so a base delivered under a directory name of its own routes as itself); the baseline is
|
||||||
|
``okf.derive_cost_baseline``; the judgement is ``validate_proposal`` with that same baseline, so a
|
||||||
|
commissioned candidate gets **no discount at the deterministic gate** — exactly the rule the LLM
|
||||||
|
path states for ``approach``. The coverage rows are ``_coverage_row``'s.
|
||||||
|
|
||||||
|
**Every candidate is built BEFORE any is judged.** A commission that cannot be executed as
|
||||||
|
written is refused whole rather than settled in part (``load_mandate``'s rule): a partial
|
||||||
|
settlement would describe work nobody ordered. It also keeps the refusal ahead of the work, which
|
||||||
|
is the økt-57 hoist applied to CBC solves rather than to model calls.
|
||||||
|
|
||||||
|
**``allow_own_proposals`` gets a ``not_evaluated`` row, not a refusal.** A run's own proposal
|
||||||
|
needs a model and this path has none, so the row cannot be filled — but omitting it would make it
|
||||||
|
indistinguishable from an approach nobody commissioned, which is the silence ``ApproachOutcome``
|
||||||
|
exists to remove. Refusing the whole run would be wrong the other way: the field defaults to
|
||||||
|
``True``, so every mandate written before today carries it.
|
||||||
|
|
||||||
|
:raises MandateRoutingError: the commission names a base this run was not given.
|
||||||
|
:raises MandateCandidateError: an approach carries no estimate, no codes, or an unknown code.
|
||||||
|
:raises okf.CostBaselineDerivationError: the bundle's schedule cannot be derived from (an
|
||||||
|
unpriced schedule refuses in full — MAJOR-4's rule, propagated rather than routed around).
|
||||||
|
"""
|
||||||
|
bundle = okf.navigate_bundle(bundle_dir)
|
||||||
|
okf.assert_declared_ids_agree(bundle)
|
||||||
|
declared = okf.reconcile_bundle_id(bundle_dir).id
|
||||||
|
routed = route_by_bundle(mandate, [declared])
|
||||||
|
_, scoped = routed[0]
|
||||||
|
|
||||||
|
baseline = okf.derive_cost_baseline(bundle, project_id=project_id)
|
||||||
|
candidates = [
|
||||||
|
(approach, candidate_from_approach(approach, baseline=baseline, project_id=project_id))
|
||||||
|
for approach in scoped.approaches
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_coverage_row(approach.id, approach.label, validate_proposal(candidate, baseline=baseline))
|
||||||
|
for approach, candidate in candidates
|
||||||
|
]
|
||||||
|
if scoped.allow_own_proposals:
|
||||||
|
rows.append(
|
||||||
|
ApproachOutcome(
|
||||||
|
id=OWN_PROPOSAL_ID,
|
||||||
|
label="the system's own proposal",
|
||||||
|
status="not_evaluated",
|
||||||
|
detail=(
|
||||||
|
"this path builds candidates from the commission alone, so there is no model "
|
||||||
|
"to originate one"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
def _select_outcome(
|
def _select_outcome(
|
||||||
produced: list[tuple[int, ValidatedProposal | Rejection]],
|
produced: list[tuple[int, ValidatedProposal | Rejection]],
|
||||||
) -> ValidatedProposal | Rejection:
|
) -> ValidatedProposal | Rejection:
|
||||||
|
|
@ -2023,6 +2099,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"guesses: an unpriced or ambiguous schedule stops the run"
|
"guesses: an unpriced or ambiguous schedule stops the run"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--proposals-from-mandate",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"build each candidate DETERMINISTICALLY from --mandate and the schedule "
|
||||||
|
"--derive-cost-baseline reads, and judge it with the ordinary validator — ZERO model "
|
||||||
|
"calls (S7b). The expert supplies the measure, the cost codes and the estimate; the "
|
||||||
|
"document supplies the quantities and prices. Requires --mandate and "
|
||||||
|
"--derive-cost-baseline. Refuses rather than invents: an approach with no estimate or "
|
||||||
|
"no affected_codes stops the run by name"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--scripted-replies",
|
"--scripted-replies",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2103,6 +2191,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
# Report mode returns before the run dispatch, so an omission here is a SILENT DROP,
|
# Report mode returns before the run dispatch, so an omission here is a SILENT DROP,
|
||||||
# not a refusal — the gap F4 measured on --plan-review.
|
# not a refusal — the gap F4 measured on --plan-review.
|
||||||
"--derive-cost-baseline": args.derive_cost_baseline,
|
"--derive-cost-baseline": args.derive_cost_baseline,
|
||||||
|
# Same reason, one flag later: report mode returns above the S7b dispatch too.
|
||||||
|
"--proposals-from-mandate": args.proposals_from_mandate,
|
||||||
"PROJECT_ID": args.project_id is not None,
|
"PROJECT_ID": args.project_id is not None,
|
||||||
"--goals": args.goals is not None,
|
"--goals": args.goals is not None,
|
||||||
"--docs-dir": args.docs_dir is not None,
|
"--docs-dir": args.docs_dir is not None,
|
||||||
|
|
@ -2176,6 +2266,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
# who wrote --portfolio --derive-cost-baseline to add the one flag this mode also
|
# who wrote --portfolio --derive-cost-baseline to add the one flag this mode also
|
||||||
# refuses. Same reason --explore is listed here rather than left to fall through.
|
# refuses. Same reason --explore is listed here rather than left to fall through.
|
||||||
"--derive-cost-baseline": args.derive_cost_baseline,
|
"--derive-cost-baseline": args.derive_cost_baseline,
|
||||||
|
# It reads ONE base's schedule and settles ONE commission against it, so it sits on the
|
||||||
|
# same side of the partition as the flag it requires. BY NAME rather than falling
|
||||||
|
# through to "requires --derive-cost-baseline": an operator who wrote --portfolio
|
||||||
|
# --proposals-from-mandate must not be told to add a flag this mode also refuses.
|
||||||
|
"--proposals-from-mandate": args.proposals_from_mandate,
|
||||||
# One exploration shapes ONE mandate against ONE knowledge base, and --bundle-dir (its
|
# One exploration shapes ONE mandate against ONE knowledge base, and --bundle-dir (its
|
||||||
# only source of bases here) is already single-project-only. Refusing it by NAME beats
|
# only source of bases here) is already single-project-only. Refusing it by NAME beats
|
||||||
# letting it fall through to the --bundle-dir requirement below: an operator who wrote
|
# letting it fall through to the --bundle-dir requirement below: an operator who wrote
|
||||||
|
|
@ -2237,6 +2332,30 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# S7b: the deterministic candidate source needs BOTH halves of its input, and each missing half
|
||||||
|
# is refused by its own name. Neither is inferable — a commission is what a person wrote, and
|
||||||
|
# the derived schedule is the only thing that can supply a quantity and a price — so a run that
|
||||||
|
# proceeded without one would have either nothing to quantify or nothing to quantify WITH.
|
||||||
|
# Placed AFTER the --bundle-dir requirement above, so a base-less argv is still answered by the
|
||||||
|
# message naming --bundle-dir rather than by one of these.
|
||||||
|
if not args.portfolio and args.proposals_from_mandate:
|
||||||
|
if args.mandate is None:
|
||||||
|
print(
|
||||||
|
"run refused: --proposals-from-mandate requires --mandate (the commission IS the "
|
||||||
|
"candidate source here — the measure, the cost codes and the estimate all come "
|
||||||
|
"from the approaches, and none of them is ours to invent)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if not args.derive_cost_baseline:
|
||||||
|
print(
|
||||||
|
"run refused: --proposals-from-mandate requires --derive-cost-baseline (the "
|
||||||
|
"derived schedule is where each candidate's quantities and unit costs come from; "
|
||||||
|
"without it there is nothing to build affected_items out of)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
# --semantic-retrieval is refused, never silently ignored (the repo's flag contract). In
|
# --semantic-retrieval is refused, never silently ignored (the repo's flag contract). In
|
||||||
# single-project mode it can only do observable work with BOTH of these: the Step-1 fold is
|
# single-project mode it can only do observable work with BOTH of these: the Step-1 fold is
|
||||||
# gated on ``bundle_dir``, and ``--verdict-dir`` is the only route by which ``main()`` can hand
|
# gated on ``bundle_dir``, and ``--verdict-dir`` is the only route by which ``main()`` can hand
|
||||||
|
|
@ -2740,6 +2859,43 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
# nothing printed at all — silent egress, which this repo forbids outright.
|
# nothing printed at all — silent egress, which this repo forbids outright.
|
||||||
print("Contacts: " + ", ".join(service_labels(mcp_servers)))
|
print("Contacts: " + ", ".join(service_labels(mcp_servers)))
|
||||||
|
|
||||||
|
# S7b: the deterministic door. Placed AFTER the announcement — the commission is declared before
|
||||||
|
# the work it commissions, exactly as the announcement's contract requires, and here that
|
||||||
|
# contract is trivially kept because there IS no un-announced spend: this path makes no model
|
||||||
|
# calls at all. Placed BEFORE the portfolio dispatch and every run dispatch below, because it is
|
||||||
|
# a terminal mode rather than a modifier: it settles the commission and returns.
|
||||||
|
#
|
||||||
|
# ``mandate`` is narrowed by the refusal above, which is why this reads it without a guard. The
|
||||||
|
# refusals of the underlying seam surface through the SAME structured contract as every other
|
||||||
|
# single-project loader failure — stderr + rc 1, never a traceback — which is what
|
||||||
|
# ``MandateCandidateError``/``MandateRoutingError``/``CostBaselineDerivationError`` all
|
||||||
|
# subclassing ``ValueError`` buys.
|
||||||
|
if args.proposals_from_mandate:
|
||||||
|
assert mandate is not None # narrowed by the --mandate refusal above
|
||||||
|
assert args.bundle_dir is not None # narrowed by --derive-cost-baseline's requirement
|
||||||
|
try:
|
||||||
|
coverage = evaluate_mandate_candidates(
|
||||||
|
mandate,
|
||||||
|
bundle_dir=args.bundle_dir,
|
||||||
|
project_id=args.project_id or "",
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
MandateCandidateError,
|
||||||
|
MandateRoutingError,
|
||||||
|
okf.BundleIdMismatch,
|
||||||
|
okf.CostBaselineDerivationError,
|
||||||
|
FileNotFoundError,
|
||||||
|
) as exc:
|
||||||
|
# NARROWED to the classes this seam owns, never a blanket ``except ValueError``: the
|
||||||
|
# five named here are all caller-configuration mistakes, and swallowing anything else
|
||||||
|
# would turn a programming error into a polite refusal (the ``_unwrap_ingest_error``
|
||||||
|
# ownership rule). ``FileNotFoundError`` is ``navigate_bundle``'s, for a --bundle-dir
|
||||||
|
# with no readable index.
|
||||||
|
print(f"run refused: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(settle(coverage))
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.portfolio:
|
if args.portfolio:
|
||||||
# Portfolio mode (Step 3): dispatch to the EXISTING run_portfolio via the fail-fast loaders
|
# Portfolio mode (Step 3): dispatch to the EXISTING run_portfolio via the fail-fast loaders
|
||||||
# (run_portfolio itself is unchanged). Loader/ValueError failures surface through the same
|
# (run_portfolio itself is unchanged). Loader/ValueError failures surface through the same
|
||||||
|
|
|
||||||
428
tests/test_proposal_from_mandate_loadbearing.py
Normal file
428
tests/test_proposal_from_mandate_loadbearing.py
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
"""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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue