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

@ -81,8 +81,10 @@ from portfolio_optimiser.mandate import (
Approach,
ApproachOutcome,
Mandate,
MandateCandidateError,
MandateRoutingError,
announce,
candidate_from_approach,
load_mandate,
route_by_bundle,
settle,
@ -98,7 +100,12 @@ from portfolio_optimiser.mcp_tools import (
from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
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.semretrieval import (
SEMANTIC_WEIGHT_DEFAULT,
@ -319,6 +326,75 @@ def _coverage_row(
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(
produced: list[tuple[int, 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"
),
)
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(
"--scripted-replies",
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,
# not a refusal — the gap F4 measured on --plan-review.
"--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,
"--goals": args.goals 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
# refuses. Same reason --explore is listed here rather than left to fall through.
"--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
# 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
@ -2237,6 +2332,30 @@ def main(argv: list[str] | None = None) -> int:
)
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
# 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
@ -2740,6 +2859,43 @@ def main(argv: list[str] | None = None) -> int:
# nothing printed at all — silent egress, which this repo forbids outright.
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:
# 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