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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-17 16:40:54 +02:00
commit 938a1ca30e
23 changed files with 718 additions and 115 deletions

View file

@ -123,8 +123,10 @@ 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 (
UNSUPPORTED_REASON,
Grounding,
Rejection,
Unsupported,
ValidatedProposal,
baseline_from_project,
classify_codes,
@ -410,7 +412,10 @@ def _coverage_row(
) -> ApproachOutcome:
"""One coverage row from one evaluated approach. A rejection carries the validator's reason
verbatim a bare status would tell the expert their approach failed without telling them why,
which is the part they can actually act on."""
which is the part they can actually act on. An ``Unsupported`` outcome (row 6) is checked
first: it subclasses ``Rejection``, and its own status is the whole point of the class."""
if isinstance(outcome, Unsupported):
return ApproachOutcome(id=row_id, label=label, status="unsupported", detail=outcome.reason)
if isinstance(outcome, ValidatedProposal):
return ApproachOutcome(
id=row_id,
@ -490,6 +495,15 @@ def evaluate_mandate_candidates(
return tuple(rows)
def _declarable_ids(mandate: Mandate | None) -> list[str]:
"""The approach ids a declaration may be filed under (row 6): the mandate's own, plus the run's
own proposal whenever the run makes one always without a mandate."""
ids = [a.id for a in mandate.approaches] if mandate is not None else []
if mandate is None or mandate.allow_own_proposals:
ids.append(OWN_PROPOSAL_ID)
return ids
def _select_outcome(
produced: list[tuple[int, ValidatedProposal | Rejection]],
) -> ValidatedProposal | Rejection:
@ -511,6 +525,7 @@ async def _evaluate_mandate(
mandate: Mandate,
evaluate: Callable[[Approach | None], Awaitable[ValidatedProposal | Rejection]],
budget_stops: list[str] | None = None,
declared: Sequence[DeclaredRequirement] | None = None,
) -> tuple[
ValidatedProposal | Rejection,
tuple[ApproachOutcome, ...],
@ -532,6 +547,14 @@ async def _evaluate_mandate(
become ``not_evaluated`` rows. But if the very first approach exhausts the budget there is
nothing honest to return, so ``BudgetExceeded`` propagates exactly as it did before a run
that produced nothing must still fail loudly rather than hand back an empty report.
**Row 6: a validated outcome must rest on ITS approach's own declaration.** When ``declared`` is
given (the rule is active see ``run_project``), a ``ValidatedProposal`` whose approach carries
no ``requirement`` of its own and has no declaration filed under its id becomes
``Unsupported``: the numbers held, the ground was never named. A declaration made for another
approach does not stand in, and nothing about the declaration's QUALITY is judged — a
requirement the model declared is accepted whatever it says (P22 § 4: an overlap gate would
refuse legitimate proposals). ``declared`` is read at evaluation time, after the debate.
"""
plan: list[tuple[str, str, Approach | None]] = [(a.id, a.label, a) for a in mandate.approaches]
if mandate.allow_own_proposals:
@ -563,6 +586,15 @@ async def _evaluate_mandate(
for rid, lbl, _ in plan[index:]
)
break
if (
declared is not None
and isinstance(outcome, ValidatedProposal)
and not (approach is not None and approach.requirement is not None)
and row_id not in {d.approach_id for d in declared}
):
outcome = Unsupported(
proposal=outcome.proposal, reason=UNSUPPORTED_REASON, validated=outcome
)
produced.append((index, outcome))
evaluated.append((row_id, outcome))
rows.append(_coverage_row(row_id, label, outcome))
@ -622,8 +654,10 @@ def _bundle_pointer(bundle: okf.Bundle, bundle_id: str, *, dimension: str | None
"requirement number or path contains that word, and reports 'total_matches'.\n"
"Before you settle on a measure, name the ONE requirement of this base that BINDS it: "
"find it with a filter, read it with read_file, then call "
f"declare_requirement({bundle_id!r}, path, ref) with the requirement's own number. A "
"declaration naming a document this run never opened is refused; reading it is the fix."
f"declare_requirement({bundle_id!r}, path, ref, approach_id) with the requirement's own "
"number and the id of the approach it binds (the run's own proposal is 'own-proposal'). "
"Declare once per approach: a proposal whose approach declared nothing is not validated. "
"A declaration naming a document this run never opened is refused; reading it is the fix."
)
@ -1184,6 +1218,12 @@ async def run_project(
#: documents above, so the gate's vocabulary and the gate's text describe one reading of one
#: base. Empty on the road path, which is what keeps the rule unable to fire there.
bundle_references: tuple[str, ...] = ()
#: Row 6: whether the declaration rung was offered to the debate. The rule that a validated
#: proposal needs its approach's own declaration is active exactly when it was: a run that
#: could declare and did not is the silence the rule exists for. It is NOT relaxed for a base
#: without requirement numbers — any document the run read can be declared, and a base that
#: holds nothing worth declaring is a finding about the base, not a reason to validate.
requirement_rung = False
# S2c: a CALLER-OWNED sink for what the debate opens (the ``parse_failures``/``ExplorationTrace``
# shape). A returned value would be lost on exactly the run that most needs the evidence — a
# budget stop mid-debate raises out of ``debate.run`` and constructs no ``RunResult`` at all.
@ -1300,8 +1340,11 @@ async def run_project(
# names every direction the run carries rather than picking one it cannot
# attribute. Without a mandate this is empty and the reply is unchanged.
labels=[a.label for a in mandate.approaches] if mandate else (),
# Row 6: the ids a declaration may be filed under.
approach_ids=_declarable_ids(mandate),
)
)
requirement_rung = True
# What the navigation could NOT reach, taken from the run's ONE walk. The road path below
# navigates no bundle at all, so its empty tuple is literally true rather than a stand-in.
skipped_links: tuple[okf.SkippedLink, ...] = bundle.skipped
@ -1588,7 +1631,10 @@ async def run_project(
validator_outcome = await _evaluate(None)
else:
validator_outcome, coverage, evaluated = await _evaluate_mandate(
mandate, _evaluate, budget_stops
mandate,
_evaluate,
budget_stops,
declared=debate_requirements if requirement_rung else None,
)
except BaseException as stop:
# Recorded and re-raised UNTOUCHED. This arm decides nothing about the exception itself —
@ -1679,7 +1725,9 @@ async def run_project(
model=model,
role="proposer",
validator_decision=(
"validated" if isinstance(validator_outcome, ValidatedProposal) else "rejected"
"validated"
if isinstance(validator_outcome, (ValidatedProposal, Unsupported))
else "rejected"
),
token_usage=meter.tokens,
# Whether stage 0 of the deterministic gate had a baseline to reconcile against. Read off
@ -1799,7 +1847,7 @@ async def run_project(
update={
"validator_decision": (
"validated"
if isinstance(approach_outcome, ValidatedProposal)
if isinstance(approach_outcome, (ValidatedProposal, Unsupported))
else "rejected"
),
# P20: ``code_forms`` must follow ITS OWN approach too, for