portfolio-optimiser/src/portfolio_optimiser/mandate.py
Kjell Tore Guttormsen 938a1ca30e feat(row6): a proposal whose approach declared no requirement is unsupported
Stress round 6 validated three falsification arms, and every validated
approach rested only on run-level declarations nobody can attribute to one
approach. declare_requirement now takes a required approach_id (a mandate
id or own-proposal; an unknown id is refused naming the valid ones), and a
ValidatedProposal whose approach has neither a mandate requirement nor a
declaration under its own id becomes validator.Unsupported - a Rejection
subclass carrying the validator's own ruling, reported as `unsupported` in
coverage, the outcome artefact, the settlement and the judge, and never
counted or summed. The rule is active whenever the debate held the
declaration tool, the micro base included; the road and pre-pass paths are
untouched. Declaration quality is not judged, so the rule can be satisfied
by declaring any document the run read.

The v1 gate's row 6 probes pass; its artefact half reads IKKE MÅLT because
stress round 6 predates approach-addressed declarations, and IKKE MÅLT is
never green - it fails the exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:40:54 +02:00

492 lines
25 KiB
Python

"""Typed IR for a run MANDATE — what a domain expert commissions a run to evaluate, and what
that run is meant to achieve (Trekk A1).
Pure module — imports **only** ``pydantic`` + stdlib. No ``agent_framework``, and deliberately
no ``contracts`` (which pulls MAF in transitively via ``backends``), so this module stays
D7-portable and is guarded by ``tests/test_okf.py::test_okf_is_maf_free`` alongside ``okf.py``
and ``dimension.py``.
**A mandate is not a dimension.** ``dimension.admits`` FILTERS what may pass the scoping gate;
a mandate DIRECTS what the run shall actually spend its attempts on. The two compose: a run may
be commissioned to evaluate three approaches *and* be scoped to one cost axis.
**A mandate is not a goal, either.** The numeric target has exactly one home already
(``contracts.GoalContract`` / ``--goals``); duplicating it here would put the same number in two
places, and two copies of one number drift apart (the ``(p)`` precedent — one quantisation order,
one source). The mandate carries the run's *intent* in plain language; the figure is read from
the goal config and merely RESTATED in the run announcement.
Two refusals are load-bearing, both at construction time:
* an **empty commission** (no approaches and no own proposals) — a run with nothing to do is a
caller error, not a result (mirrors ``budget.BudgetRefused``'s startup refusal: a pass that can
afford zero projects is refused before anything loads);
* a **duplicate approach id**, including one claiming the reserved ``OWN_PROPOSAL_ID`` — ``id`` is
the key each row of the coverage report is written under, so two rows sharing one key would
silently collapse into one. That is the S3.2 key-collision class, and silence is exactly what
the coverage report exists to prevent.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
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"
class BindingRequirement(BaseModel):
"""The ONE document in the knowledge base that BINDS a direction (P19 DEL A).
Measured over two paid stress rounds (P16 § 3, P18 § 1): **0 of 26** fasit concepts were ever
opened, in both rounds, while every run still produced proposals — so a direction could be
committed to, quantified and validated without a single requirement of the corpus having been
read. P18 closed the navigation side of that (a window, a named refusal for an invented path)
and the number did not move, which is what made it a ROLE question rather than a ladder one:
nothing in the loop ever asked the model to name what binds it.
``path`` is bundle-relative exactly as a listing gave it — the same string ``read_file`` takes,
so it can be checked against what the run actually opened without a second normalisation.
``ref`` is the requirement's OWN number as the document's frontmatter states it, never a
paraphrase: it is what a reader searches the corpus with, and what the judge matches the fasit
on.
Both are required with ``min_length=1``. A half-declared requirement would be worse than none:
it reads as a citation and points at nothing, which is the fabricated-provenance class
``write_concept_file`` refuses and ``RunFailure`` names in its own docstring.
"""
path: str = Field(min_length=1)
ref: str = Field(min_length=1)
class Approach(BaseModel):
"""One approach a domain expert wants evaluated for a project.
``description`` is the expert's own prose reason for wanting it tried; it is fed to the
proposer VERBATIM, because the reason is the part the model cannot infer from the cost data.
"""
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
#: named": a legitimate statement when the run has only one base to name, and what keeps every
#: mandate written before multi-base existed valid and dispatchable unchanged.
#:
#: The field is a ROUTING key, never a claim about content. It says which pipeline the approach
#: must be evaluated in, and ``route_by_bundle`` is the one place that reads it.
bundle_id: str = ""
#: The ONE requirement of the knowledge base that binds this direction (P19 DEL A).
#:
#: DEFAULTS to ``None``, which keeps every mandate written before today valid and dispatchable
#: unchanged — the ``bundle_id`` precedent, and the honest reading of a commission whose author
#: named no requirement. ``None`` is therefore "none stated", never "none exists": the loop
#: that MINTS an approach must say which of the two it means (``why_none`` on the hypothesis
#: line), because a direction the base has no requirement for is a finding, while one nobody
#: looked for is a silence.
requirement: BindingRequirement | None = None
class Mandate(BaseModel):
"""The commission for one run: what to evaluate, and what the run is for.
``allow_own_proposals`` defaults to ``True`` so that naming approaches never silently forbids
the system from adding its own — the operator's requirement is "these **and/or** your own",
and the permissive half is the one that matches today's behaviour.
"""
objective: str = Field(min_length=1)
approaches: tuple[Approach, ...] = ()
allow_own_proposals: bool = True
success_criteria: str = ""
@model_validator(mode="after")
def _commission_is_not_empty(self) -> Mandate:
if not self.approaches and not self.allow_own_proposals:
raise ValueError(
"empty commission: a mandate with no approaches and allow_own_proposals=false "
"gives the run nothing to evaluate"
)
return self
@model_validator(mode="after")
def _approach_ids_are_unique_and_unreserved(self) -> Mandate:
seen: set[str] = set()
for approach in self.approaches:
if approach.id == OWN_PROPOSAL_ID:
raise ValueError(
f"approach id {OWN_PROPOSAL_ID!r} is reserved for the run's own proposal"
)
if approach.id in seen:
raise ValueError(f"duplicate approach id: {approach.id!r}")
seen.add(approach.id)
return self
class MandateRoutingError(ValueError):
"""A commission that cannot be executed against the bases it was given (§ C.7).
**A ``ValueError`` by construction, and that is a measurement rather than a taxonomy note.**
økt 57 paid for the opposite: ``ExplorationError`` is a ``RuntimeError`` and therefore fell
outside ``run.main``'s ``(ValueError, FileNotFoundError, ValidationError)`` refusal tuple and
outside ``hosting``'s 400 arm, so a caller's configuration mistake would have left as a
traceback on one surface and a 500 — the crash channel — on the other. A routing refusal is
exactly that class of caller mistake, so it is born inside both nets instead of being
retrofitted into them later.
"""
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).
This is the whole of "multi-base", and it is deliberately a partition rather than a widening.
``run_project`` derives four single-valued things from THE bundle it is given — the project
(``_project_from_bundle`` fail-fasts when the bundle's own ``project_id`` is not the requested
one), the validator's stage-0 cost baseline, the agents' read context and the ExpeL query key —
and returns ONE ``RunResult`` with ONE ``ProvenanceStamp``. A second ``bundle_dir`` on that
signature would force a silent pick-one for all four. § C.7 says the same thing in its own
words: *pipelinen kjøres per bundle som i dag* — N calls, not one call taking N.
Order is taken from ``bundle_ids``, never from first appearance among the approaches, so the
dispatch's spend order is a property of how the run was configured rather than of how a model
happened to sequence its hypotheses.
**Fail-fast on a commission that cannot be executed as written**, mirroring ``load_mandate``'s
contract for exactly its reason: a run must never proceed on a *silently degraded* commission,
because the coverage report would then describe work nobody ordered. Two ways that happens, and
both refuse by name rather than resolving by position (the S3.2 key-collision class):
* an approach naming a base that was not configured;
* an approach naming NO base while more than one is configured — with a single base there is no
other value the field could take, so resolving it there is the only answer rather than a
guess, and it is what keeps every pre-multi-base mandate dispatchable unchanged.
A base that no approach names is NOT run: a run costs money and the commission ordered nothing
for it. The single-base case keeps its own rule above, so an own-proposals-only mandate still
reaches the one base it could possibly mean.
:raises MandateRoutingError: no bases configured, or an approach that cannot be routed.
"""
if not bundle_ids:
raise MandateRoutingError(
"a mandate cannot be routed against zero knowledge bases: an empty plan reads as "
"'there was nothing to do', which is indistinguishable from a commission that was "
"fully evaluated against nothing"
)
known = tuple(bundle_ids)
sole = known[0] if len(known) == 1 else None
grouped: dict[str, list[Approach]] = {bundle_id: [] for bundle_id in known}
for approach in mandate.approaches:
target = approach.bundle_id or sole
if target is None:
raise MandateRoutingError(
f"approach {approach.id!r} names no knowledge base and {len(known)} are "
f"configured ({', '.join(known)}); which one it belongs to is not something this "
"layer may decide on the expert's behalf"
)
if target not in grouped:
raise MandateRoutingError(
f"approach {approach.id!r} names knowledge base {target!r}, which is not "
f"configured for this run; configured: {', '.join(known)}"
)
grouped[target].append(approach)
return tuple(
(
bundle_id,
mandate.model_copy(update={"approaches": tuple(grouped[bundle_id])}),
)
for bundle_id in known
if grouped[bundle_id] or sole is not None
)
@dataclass(frozen=True)
class ApproachOutcome:
"""What became of ONE commissioned approach — one row of the run's coverage report.
``not_evaluated`` is the row that earns this type its keep: an approach the run never got to
(budget exhausted, pass stopped) must be reported as *not evaluated*, never omitted. An omitted
row is indistinguishable from an approach nobody ordered, which is exactly the silence krav 1
is asking us to remove.
``detail`` carries the validator's reason on a rejection, or why an approach went unevaluated;
``saving_nok`` is set only for a validated row (the claimed figure the validator admitted).
"""
id: str
label: str
#: ``unsupported`` (row 6): the numbers held but the approach declared no binding requirement,
#: so it is neither a success nor a numeric rejection. Never counted as validated.
status: Literal["validated", "rejected", "unsupported", "not_evaluated"]
detail: str = ""
saving_nok: float | None = None
def load_mandate(path: str | Path) -> Mandate:
"""Fail-fast standalone loader for a run mandate (mirrors ``dimension.load_dimension``).
A mandate is *authoritative startup input*, so loading is fail-fast: a missing file raises
``FileNotFoundError`` and malformed/invalid content raises ``pydantic.ValidationError``. This
is the deliberate contrast to the tolerant verdict-inbox RAW layer
(``verdicts.load_verdicts_from_dir``), which skips bad files rather than raising — a run must
never proceed on a *silently degraded* commission, because the coverage report would then
describe work nobody ordered.
:raises FileNotFoundError: ``path`` does not point at an existing file.
:raises pydantic.ValidationError: the content is not JSON, or violates the ``Mandate`` schema.
"""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"mandate not found: {str(path)!r}")
return Mandate.model_validate_json(p.read_text(encoding="utf-8"))
def announce(
mandate: Mandate,
*,
project_id: str,
max_rounds: int,
max_tokens: int,
dimension_label: str | None = None,
goal_nok: float | None = None,
external_services: tuple[str, ...] = (),
) -> str:
"""Render the run announcement: what this run will do, BEFORE the first (paid) model call.
Deterministic and byte-stable — no wall clock, and nothing ordered by set iteration — so it
can be golden-tested. English, like every other line this CLI prints; the Norwegian
explanation of what the block means belongs in ``docs/bestille-en-kjoring.md``, next to the
domain expert who reads it.
Optional lines are OMITTED rather than rendered empty: an announcement that printed
``Scoped to: -`` would imply a scope decision nobody made.
``external_services`` is the egress declaration. It is empty until a run is given MCP servers
(Trekk B); when it is non-empty, every server named here may be contacted — and a run never
reaches a service it did not announce. The "no external services" wording is deliberate: an
omitted line reads the same as an unchecked one.
"""
lines = [
f"Run mandate for {project_id}",
f" Objective: {mandate.objective}",
]
if mandate.approaches:
suffix = " + the system's own proposals" if mandate.allow_own_proposals else " (only these)"
lines.append(
f" Evaluates: {len(mandate.approaches)} expert-proposed approach(es){suffix}"
)
lines.extend(
f" {n}. {a.id}{a.label}"
for n, a in enumerate(mandate.approaches, start=1)
)
else:
lines.append(" Evaluates: the system's own proposals (none were commissioned)")
if dimension_label is not None:
lines.append(f" Scoped to: {dimension_label}")
if goal_nok is not None:
lines.append(f" Target: >= {goal_nok:.0f} NOK (restated from the goal config)")
lines.append(f" Stops at: {max_rounds} rounds / {max_tokens} tokens")
lines.append(
" Contacts: "
+ (", ".join(external_services) if external_services else "no external services")
)
if mandate.success_criteria:
lines.append(f" Success: {mandate.success_criteria}")
return "\n".join(lines)
def criteria_block(success_criteria: str) -> str:
"""The ONE rendering of a commission's success criteria INTO a prompt, or ``""``.
MEASURED (P19 F2): ``success_criteria`` reached ``announce`` and nothing else, so the operator's
own statement of what a good answer looks like was printed for a human and withheld from the
only reader who could act on it. The approach's ``description`` has always reached the
generation prompt (``generate._build_messages``); this is its run-level sibling.
ONE composer, for kø-(p): the debate task and any later prompt that carries the criteria must
say the same thing about them, and two renderings of one commission are free to disagree about
what the operator asked for.
Empty in, empty out — omission rather than an empty heading, the ``announce`` rule. That is
what keeps every prompt of every un-commissioned run, the demo's included, byte-identical.
"""
if not success_criteria:
return ""
return (
"\nWhat the commissioner counts as success (restated verbatim from the commission):\n"
f"{success_criteria}\n"
)
def settle(
coverage: tuple[ApproachOutcome, ...],
*,
goal_nok: float | None = None,
goal_reached: bool | None = None,
) -> str:
"""Render the settlement: what the run actually did about each commissioned approach.
Empty coverage renders an EMPTY string — a run without a mandate has nothing to settle, and a
header over zero rows would imply a commission that never existed.
**Commissioned approaches are ALTERNATIVES and are never summed.** Several of them usually
attack the same cost line, so a total would report money the project cannot realise — measured
on a real run, three approaches against one line each validated at 30000 NOK and an earlier
version of this function claimed a 90000 total. What is honest is how many held and which one
the run carries: a selection, not an arithmetic claim.
The goal verdict is **passed in**, never computed here: ``ledger.to_ore`` is the framework's one
NOK->øre conversion and the goal comparison already runs on quantised integers, but this cannot
import it without pulling ``verdicts`` — and with it ``agent_framework`` — into a deliberately
framework-neutral file, while a private copy of a money conversion is precisely the ``(p)``
defect. So the caller decides and this renders. Both ``goal_nok`` and ``goal_reached`` must be
given for the target line to appear; a figure without a decided verdict makes no claim.
"""
if not coverage:
return ""
lines = ["Mandate outcome"]
for row in coverage:
if row.status == "validated":
amount = f"{row.saving_nok:.0f} NOK" if row.saving_nok is not None else "-"
lines.append(f" {row.id:<20} VALIDATED {amount:>14} {row.label}")
elif row.status == "rejected":
lines.append(f" {row.id:<20} REJECTED {row.detail}")
elif row.status == "unsupported":
lines.append(f" {row.id:<20} UNSUPPORTED {row.detail}")
else:
lines.append(f" {row.id:<20} NOT EVALUATED {row.detail}")
held = [r for r in coverage if r.status == "validated"]
best = max((r.saving_nok or 0.0 for r in held), default=None)
tally = f" Validated: {len(held)} of {len(coverage)} approaches"
if best is not None:
tally += f" — best {best:.0f} NOK (the outcome this run carries)"
lines.append(tally)
if goal_nok is not None and goal_reached is not None:
lines.append(
f" Target: >= {goal_nok:.0f} NOK — "
f"{'reached' if goal_reached else 'not reached'}"
)
return "\n".join(lines)