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 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue