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

@ -69,7 +69,7 @@ import argparse
import json
import os
import sys
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
@ -98,7 +98,7 @@ class ApproachVerdict:
approach_id: str
label: str
status: str # "validated" | "rejected" | "not_evaluated"
status: str # "validated" | "rejected" | "unsupported" | "not_evaluated"
#: (a) - grounded by an OPENED path, or by a citation under a NARROWED list. See the module
#: docstring: a whole-base citation list is stamped before any model call and grounds nothing.
grounded: bool
@ -197,6 +197,10 @@ class ContextSetVerdict:
approach_rows_seen: int
concepts_in_base: int
ferdig: bool
#: Row 6 - declarations with no ``approach_id``: written before declarations carried one. A run
#: with any cannot be measured against the rule that a validated proposal needs its own
#: approach's declaration, and the v1 gate says so instead of counting.
unaddressed_declarations: int = 0
def to_payload(self) -> dict[str, Any]:
"""Byte-stable plain data: the ONE rendering, shared by the CLI's stdout and its file."""
@ -265,21 +269,29 @@ def _inside(base: Path, raw: str) -> Path | None:
return resolved if resolved == root or root in resolved.parents else None
def _attributable(approach: Any, declared: Sequence[str]) -> tuple[tuple[str, ...], str]:
def _attributable(
approach: Any, declared: Sequence[Mapping[str, Any]]
) -> tuple[tuple[str, ...], str]:
"""Which declared requirement paths this approach may be judged on, and where they came from.
The approach's OWN requirement wins when it has one: the mandate carries it per approach, so
it is unambiguous by construction. Otherwise the RUN's declarations are attributable - the
debate declares once for the whole run, so the row says ``run`` rather than pretending the
declaration was made about it. ``absent`` is the third value and is not the same as "declared
nothing that matched": a run that declared nothing is a different finding from one that
declared the wrong document."""
it is unambiguous by construction. Next, a declaration filed under THIS approach's id (row 6)
is the approach's own and says ``approach`` too. A declaration with no ``approach_id`` at all
was written before declarations carried one; it can only be attributed to the whole run, and
the row says ``run`` rather than pretending it was made about this approach. A declaration
filed under ANOTHER approach's id is not this one's, so a run whose declarations all name other
approaches reads ``absent`` here the same value as a run that declared nothing.
"""
own = getattr(approach, "requirement", None)
path = "" if own is None else str(getattr(own, "path", "") or "")
if path:
return (path,), "approach"
if declared:
return tuple(declared), "run"
addressed = tuple(str(r["path"]) for r in declared if r.get("approach_id") == approach.id)
if addressed:
return addressed, "approach"
legacy = tuple(str(r["path"]) for r in declared if "approach_id" not in r)
if legacy:
return legacy, "run"
return (), "absent"
@ -359,14 +371,13 @@ def score_context_set(
# what every stress round has been) writes no ``{run_id}-exploration.json`` at all - the
# hypothesiser never runs - so the debate artefact is the only one that can carry them there.
# Both are read, because an ``--explore`` run carries them in the other.
declared_paths: list[str] = []
declared_records: list[dict[str, Any]] = []
for artefact in (debate, outbox / f"{run_id}-exploration.json"):
if artefact.is_file():
declared_paths += [
str(r.get("path", ""))
for r in _read_json(artefact).get("requirements", [])
if r.get("path")
declared_records += [
r for r in _read_json(artefact).get("requirements", []) if r.get("path")
]
declared_paths = [str(r["path"]) for r in declared_records]
hallucinated_reads: list[str] = []
for call in tool_calls:
raw = str(call.get("path", ""))
@ -417,9 +428,11 @@ def score_context_set(
named_in_measure=False,
named_in_snippet=False,
hallucinations=(),
requirement_declared=_attributable(approach, declared_paths)[0],
requirement_source=_attributable(approach, declared_paths)[1],
requirement_hit=bool(set(_attributable(approach, declared_paths)[0]) & wanted),
requirement_declared=_attributable(approach, declared_records)[0],
requirement_source=_attributable(approach, declared_records)[1],
requirement_hit=bool(
set(_attributable(approach, declared_records)[0]) & wanted
),
prose_codes=(),
priced=False,
not_evaluated_reason=stop_reason or "absent",
@ -472,7 +485,7 @@ def score_context_set(
forms = payload.get("provenance", {}).get("code_forms") or classify_codes(codes)
prose_codes = tuple(sorted(c for c in codes if forms.get(c) == "prose"))
attributable, requirement_source = _attributable(approach, declared_paths)
attributable, requirement_source = _attributable(approach, declared_records)
requirement_hit = bool(set(attributable) & wanted)
halluc = [f"citation:{f}" for f in sorted(cited_files - concept_names)]
@ -563,6 +576,7 @@ def score_context_set(
must_refuse=tuple(refusals),
hallucinated_reads=tuple(hallucinated_reads),
requirements_declared=tuple(declared_paths),
unaddressed_declarations=sum(1 for r in declared_records if "approach_id" not in r),
token_usage=token_usage,
stop_reason=stop_reason if coverage_seen else "absent",
anchored=anchored,