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:
parent
c660bd2a82
commit
368367e1c5
4 changed files with 408 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue