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:
Kjell Tore Guttormsen 2026-09-03 22:27:24 +02:00
commit a79e2f8965
3 changed files with 693 additions and 1 deletions

View file

@ -36,6 +36,8 @@ from typing import Literal
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
#: may not claim it, because the two rows would collapse onto one key.
OWN_PROPOSAL_ID = "own-proposal"
@ -51,6 +53,28 @@ class Approach(BaseModel):
id: str = Field(min_length=1)
label: str = Field(min_length=1)
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
#: ``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
@ -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], ...]:
"""Partition one commission into one sub-mandate PER knowledge base (§ C.7).