feat(toolbox): the judgement through the same door -- validate-proposal, verdict-key, capture-verdict

B's premise applied to the three steps that DECIDE a proposal: a proposal authored outside po --
by a human, or by an agent that is not po -- now meets the blocking deterministic gate, mints the
learning key, and is captured as a Verdict, all without a chat client on the way.

Three thin adapters, no second implementation. The reason is the one the first four doors were
built on, but it bites harder here: the refusal SENTENCE is fed back verbatim into the next
attempt by step 5, so a door that reworded it would break the repair loop while still looking
correct. The probes assert the sentence, not a substring two stages share.

One measurement decided a design detail. The IR writes whole magnitudes as JSON integers
(30000), the run path carries the pydantic float, and verdicts._mint_id hashes the raw value --
so minting from the undeclared JSON would hand out a DIFFERENT verdict id than the debate does
for the same proposal. The door therefore reads the proposal through SavingsProposal and feeds
model_dump() to the public features_from_ir; the probe pins both forms and asserts they differ,
so the shortcut cannot come back silently.

A blocked proposal exits 3, carrying the verdict rather than an exception envelope. "You asked
right and the answer is no" is the same fact whether a file was missing or a claim was
infeasible, and a caller that only reads the exit code must not see a blocked proposal as a
cleared one.

Fasit outside the door in every arm: the base's own checked-in golden suite (written before the
toolbox existed, so it cannot have been fitted to it), a cost baseline authored in the test, a
method cap computed by hand from the fixture, and the public minting rule. Each refusal arm has
an rc-0 control on an argv that would otherwise be accepted.

STATED LIMIT: the input-grounding stage (P7, stage 0b) has no flag here. It falsifies a proposal
against the rendered prompt the model received, and an outside caller has no such prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-20 10:08:37 +02:00
commit 368367e1c5
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 408 additions and 14 deletions

View file

@ -2,15 +2,18 @@
This is B's premise made callable. The framework's own CLI (``portfolio_optimiser.run``) drives a
MAF debate and therefore constructs a chat client on every path through it; an outside caller
a human at a terminal, or an agent that is NOT po cannot reach ``navigate_bundle`` or
``retrieve_chunks`` through that door without paying for a model. The steps themselves need no
a human at a terminal, or an agent that is NOT po cannot reach ``navigate_bundle``,
``retrieve_chunks`` or ``validate_proposal`` through that door without paying for a model. The steps themselves need no
model at all. This module exposes exactly those steps, and nothing else.
**One CLI, four subcommands, one core call each.** Each subcommand parses arguments, calls the
**One CLI, one core call per subcommand.** Each subcommand parses arguments, calls the
SAME function the run path calls, writes the result as JSON on stdout, and returns an exit code
that says what happened: ``0`` the step ran, ``2`` the call was malformed (argparse), ``3`` the
step refused and the refusal is named in the JSON. There is no fourth code and no silent zero
the exit code is the only signal a calling agent has before it reads a byte.
the exit code is the only signal a calling agent has before it reads a byte. A proposal the
deterministic gate BLOCKS is a ``3`` as well, and carries the verdict rather than an exception:
"you asked right and the answer is no" is the same fact whether a file was missing or a claim
was infeasible, and a caller that only reads rc must not see a blocked proposal as a cleared one.
**No re-implementation, and that is the load-bearing part.** Every handler below is a thin
adapter: it converts strings to the types the core function already takes and converts what came
@ -30,10 +33,26 @@ import argparse
import json
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from portfolio_optimiser import okf, prepass
from portfolio_optimiser.contracts import FeedbackContract
from portfolio_optimiser.datasource import retrieve_chunks
from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser.validator import (
ValidatedProposal,
rejection_stage,
validate_proposal,
)
from portfolio_optimiser.verdicts import (
ProposalFeatures,
capture_verdict,
features_from_ir,
verdict_key,
verdict_to_dict,
)
__all__ = ["main"]
@ -48,6 +67,19 @@ REFUSED = 3
_REFUSALS = (ValueError, FileNotFoundError, OSError)
@dataclass(frozen=True)
class Refused:
"""A step that RAN, whose answer is NO — carried out with the full result, not an error.
``validate_proposal`` returning a ``Rejection`` is the deterministic gate doing its job, so
the payload is the step's own verdict (decision, the verbatim reason, the stage that wrote
it) and not an exception envelope. The exit code is still ``REFUSED``, because a calling
agent reads the code before it reads a byte, and a blocked proposal that answered ``0``
would be indistinguishable from a validated one to everything that only checks rc."""
payload: Mapping[str, Any]
def navigate_bundle_command(args: argparse.Namespace) -> Mapping[str, Any]:
"""``navigate-bundle`` — open a knowledge base and report what navigation reached.
@ -111,8 +143,120 @@ def prepass_admit_command(args: argparse.Namespace) -> Mapping[str, Any]:
}
def _proposal_from(path: str) -> SavingsProposal:
"""The proposal IR, read the ONE way the repository reads it: Pydantic over the same
``SavingsProposal`` the run path constructs.
Deliberately not ``json.loads`` straight into the feature mapping. The IR writes whole
magnitudes as JSON integers (``30000``), the run path carries the pydantic FLOAT, and
``verdicts._mint_id`` hashes the raw value so a door that minted from the undeclared JSON
would hand out a DIFFERENT verdict id than the debate does for the same proposal, which is
the one thing the learning key may not do (A5)."""
return SavingsProposal.model_validate_json(Path(path).read_text(encoding="utf-8"))
def _method_caps(path: str) -> Mapping[str, float]:
"""The method-cap registry as DATA (F8): measure type -> the method-scoped max fraction.
Read as the registry the core function already takes, never merged with the built-in one
``validate_proposal`` treats a supplied registry as the whole registry, and a door that
quietly unioned them would apply a cap the caller did not ask for."""
caps = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(caps, dict):
raise ValueError(f"method caps must be an object of measure -> fraction, got {type(caps)}")
return {str(measure): float(fraction) for measure, fraction in caps.items()}
def _features_of(proposal: SavingsProposal) -> ProposalFeatures:
"""The run path's own IR -> features mapping, reached through its PUBLIC name.
``verdicts.features_from_ir`` is public for exactly this reason (A5, one minting rule), and
it is fed ``model_dump()`` rather than the file's own dict so the magnitudes are the
validated floats the debate mints from."""
return features_from_ir(proposal.model_dump())
def validate_proposal_command(args: argparse.Namespace) -> Mapping[str, Any] | Refused:
"""``validate-proposal`` — the blocking deterministic gate, method cap included.
The whole of B rests on this one: a proposal authored OUTSIDE po by a human, or by an
agent that is not po must meet the same stages, in the same order, with the same verbatim
refusal sentence, as one a MAF agent produced. The sentence matters as much as the verdict:
Step 5 feeds it back into the next attempt, so a door that reworded it would break the
repair loop while still looking correct.
``cost_baseline_anchored`` is carried out rather than implied. Stage 0 only runs when a
baseline was given, and an UNANCHORED "validated" means something much weaker than an
anchored one four paid rounds were measured reasoning about magnitudes nobody priced.
STATED LIMIT: the input-grounding stage (P7, stage 0b) has no flag here. It falsifies a
proposal against the rendered prompt the model received, and an outside caller has no such
prompt; a file pretending to be one would be a different measurement wearing its name."""
proposal = _proposal_from(args.proposal)
baseline = (
None if args.cost_baseline is None else okf.load_cost_baseline_file(args.cost_baseline)
)
caps = None if args.method_caps is None else _method_caps(args.method_caps)
result = validate_proposal(proposal, baseline=baseline, method_caps=caps)
common = {
"project_id": proposal.project_id,
"measure": proposal.measure,
"claimed_saving_nok": proposal.claimed_saving_nok,
"cost_baseline_anchored": baseline is not None,
}
if isinstance(result, ValidatedProposal):
return {
"decision": "validated",
**common,
"nominal_feasible": result.nominal_feasible,
"p10": result.p10,
"p50": result.p50,
"p90": result.p90,
}
return Refused(
{
"decision": "rejected",
**common,
"reason": result.reason,
# Which falsifier wrote the sentence, named by the module that owns the wordings —
# never re-derived here, or the door and the validator could disagree about one run.
"stage": rejection_stage(result.reason),
}
)
def verdict_key_command(args: argparse.Namespace) -> Mapping[str, Any]:
"""``verdict-key`` — the id an expert verdict on THIS proposal will arrive under.
Available without capturing a decision nobody has made yet: a caller can stamp its own
artefact with the key, hand the proposal to a human, and find the verdict again when it
comes back."""
features = _features_of(_proposal_from(args.proposal))
return {
"verdict_id": verdict_key(features),
"affected_codes": sorted(features.affected_codes),
"measure_type": features.measure_type,
"claimed_saving_nok": features.claimed_saving_nok,
}
def capture_verdict_command(args: argparse.Namespace) -> Mapping[str, Any]:
"""``capture-verdict`` — an expert's judgement, minted into the Verdict the store holds.
The decision vocabulary is ``FeedbackContract``'s, which is what step 1 of the run path
already checks a supplied verdict against: a half-given or invented judgement is refused BY
FIELD NAME rather than completed on the expert's behalf. The emitted JSON is
``verdict_to_dict`` the same form ``write_verdict`` puts on disk so a caller that
redirects stdout into the inbox folder has authored a verdict the next run will read."""
given = FeedbackContract(decision=args.decision, rationale=args.rationale)
verdict = capture_verdict(
_features_of(_proposal_from(args.proposal)), given.decision, given.rationale
)
return verdict_to_dict(verdict)
def build_parser() -> argparse.ArgumentParser:
"""The four doors, each registered by name.
"""The doors, each registered by name.
``required=True`` on the subparsers: a toolbox invoked with no command must be a usage error,
never a zero. Measured as a class in this repository an exit 0 for a call that did nothing
@ -140,11 +284,30 @@ def build_parser() -> argparse.ArgumentParser:
slipp.add_argument("--payload", required=True, help="the producer's payload JSON")
slipp.add_argument("--bundle-dir", required=True, help="the base the cut claims to be of")
slipp.add_argument("--dimension", default=None, help="restrict admission to one dimension")
doem = sub.add_parser("validate-proposal", help="run the blocking deterministic gate")
doem.add_argument("--proposal", required=True, help="the proposal IR as JSON")
doem.add_argument(
"--cost-baseline",
default=None,
help="the project's own priced lines; without it stage 0 never runs",
)
doem.add_argument(
"--method-caps", default=None, help="method-cap registry JSON (measure -> fraction)"
)
noekkel = sub.add_parser("verdict-key", help="the learning key a verdict will arrive under")
noekkel.add_argument("--proposal", required=True, help="the proposal IR as JSON")
fang = sub.add_parser("capture-verdict", help="mint an expert judgement into a Verdict")
fang.add_argument("--proposal", required=True, help="the proposal IR as JSON")
fang.add_argument("--decision", required=True, help="the expert's decision")
fang.add_argument("--rationale", required=True, help="why — carried into the store verbatim")
return parser
def dispatch(args: argparse.Namespace) -> Any:
"""Name the four handlers, one branch each — deliberately not a ``set_defaults(handler=…)``.
"""Name each handler, one branch each — deliberately not a ``set_defaults(handler=…)``.
The dispatch table argparse offers is one line shorter and hides the only thing a reader of
this module wants to see: which command reaches which run-path step. B-gate row 1 asks the
@ -158,6 +321,12 @@ def dispatch(args: argparse.Namespace) -> Any:
return retrieve_chunks_command(args)
if args.command == "prepass-admit":
return prepass_admit_command(args)
if args.command == "validate-proposal":
return validate_proposal_command(args)
if args.command == "verdict-key":
return verdict_key_command(args)
if args.command == "capture-verdict":
return capture_verdict_command(args)
raise RuntimeError(f"unregistered command {args.command!r}") # pragma: no cover - argparse
@ -181,9 +350,12 @@ def main(argv: Sequence[str] | None = None) -> int:
)
print()
return REFUSED
code = 0
if isinstance(result, Refused):
result, code = result.payload, REFUSED
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
return code
if __name__ == "__main__": # pragma: no cover - dekket av subprosess-probene