feat(p19): a cost code must have a FORM where the input offers forms

P18's round 2 ended with two VALIDATED proposals whose affected_item codes
were ordinary words from a road standard's prose -- impulsventilator (4 of
270 N500 documents) and bituminoest baerelag (4 of 1133 N200). Both are
grounded in P7's sense and neither is inert in P18/B1's sense; they are simply
not identifiers of a cost line, and the gate had no stage that could say so.
Known positive MEASURED, not asserted: replayed offline against the bases
those runs were given, both come back Rejection naming the denominator.

IDENTIFIER_FORMS moved from generate.py to validator.py: they now drive both
P8's report and this gate, and two copies of "what an identifier looks like"
would let the two disagree about one run's own input.

B1 -- two new forms, transcribed from measurement. R761's requirement numbers
are bare dotted numbers and all six refs in kontrakt-sorasen's fasit are of
that shape, which neither pre-P19 form matched: r761's whole offer was 3
identifiers over 6.5 MB, and is now 2332. The FIRST form was widened in the
same pass because B2 made these forms decide prose vs identifier, and this
repo's own ENERGI-TOTAL-EL matched none of them -- a gate may only be wrong in
the direction that admits too much.

Three things keep the gate from being a rule about shapes: the generality
guard (it fires only where the input offers forms), the baseline exemption
(stage 0 has already ruled that code real), and full-matching.

Honesty limit, measured and given its OWN arm: a decimal and an R761 process
number are typographically identical, so the form counts both. P8's existing
"bare numbers" arm is narrowed to bare INTEGERS accordingly.

Measured over all nine round-1+2 outboxes: 26 of 36 codes are prose.

Load-bearing measured (22 arms), four mutations all red against the whole
suite (16 / 10 / 1 / 2), green control 1734/5 and the golden byte-unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-15 02:11:29 +02:00
commit d74f32dc1c
15 changed files with 446 additions and 36 deletions

View file

@ -22,7 +22,6 @@ Two entry points, because the LLM call is async while ``validator.self_repair``
from __future__ import annotations
import json
import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field, replace
from typing import Any
@ -477,28 +476,6 @@ def _grounding_text(
)
#: The identifier forms the DELIVERED corpora actually carry, TRANSCRIBED from the measurement
#: (``docs/2026-09-09-p8-forankringstilbudet.md`` § 2) rather than chosen: over K2's two delivered
#: bundles the first form finds 50 distinct tokens and the second none, while over the three
#: N payloads the second finds 269-981 distinct and the first at most 3.
#:
#: **Why a pattern is admissible HERE and not in ``_ground_against_input``.** That is a GATE, and a
#: pattern there would be a rule about shapes that can wrongly REFUSE a real code. This is a
#: REPORT: a form it does not know is a token it fails to count, so it errs toward saying the input
#: offers LESS than it does — an under-count is a quiet report, never a false rejection.
#:
#: Bare numbers are deliberately EXCLUDED, with the number: K2 carries 46 394 occurrences over
#: 2 117 distinct values (P7 § 2), so counting them would make every report positive and the
#: measurement inert — the repo's cardinal class, a gate that can only come out green.
_IDENTIFIER_FORMS = (
# ``SHA-01``, ``RIM-02``, ``B-20-00-00``, ``FOR-2011-12-06-1357`` (K2's 50).
re.compile(r"\b[A-ZÆØÅ]{1,8}[-_]\d{1,4}(?:[-_]\d{1,4})*\b"),
# ``Krav 3.3.1—13`` (the N corpora's dominant form). EM-DASH U+2014 AND the hyphen, because the
# binding known positive is the em-dash spelling and only the em-dash spelling scores 6 of 6.
re.compile(r"Krav\s+\d+(?:\.\d+)*\s*[\u2014-]\s*\d+"),
)
@dataclass(frozen=True)
class GroundingOffer:
"""P8: what the DELIVERED input of one run can lawfully ground an ``affected_item`` code in.
@ -539,13 +516,12 @@ def grounding_offer(
Deterministic and free: no model call, no network, and no second walk of the bundle.
"""
text = _grounding_text(project, baseline, delivered).text
found: set[str] = set()
for form in _IDENTIFIER_FORMS:
found |= set(form.findall(text))
grounding = _grounding_text(project, baseline, delivered)
return GroundingOffer(
chars=len(text),
identifiers=len(found),
chars=len(grounding.text),
# ONE reader, shared with P19/B3's gate (kø-(p)): a report and a gate that counted
# identifiers differently would disagree about one run's own input.
identifiers=len(grounding.identifiers),
cost_lines=0 if baseline is None else len(baseline.items),
)

View file

@ -86,6 +86,19 @@ class ProvenanceStamp(BaseModel):
#: artefact that was judged — the same class as ``cost_baseline_anchored``, which also resolves
#: once per run and is stamped per proposal.
bundle_id_source: ResolvedBundleId | None
#: P19/B2 — each ``affected_item`` code as ``"identifier"`` or ``"prose"``, by whether it is
#: SHAPED like an identifier of the delivered corpora at all (``validator.classify_codes``).
#:
#: A REPORT, never the gate. The gate is P19/B3 inside ``validate_proposal``, and it fires only
#: where the input demonstrably offers identifier forms; this says what the run classified
#: whatever the gate then did, which is what makes an un-anchored or form-less run readable
#: rather than silent.
#:
#: REQUIRED WITHOUT DEFAULT, for ``cost_baseline_anchored``'s reason, sharpened by the shape:
#: an empty map would read as "this proposal has no codes", and ``affected_items`` cannot be
#: empty (``min_length=1``), so the empty default could never be true. The only honest
#: alternative to a value here is a constructor that says what it saw.
code_forms: dict[str, str]
#: External service calls the run made (B4). EMPTY is a positive statement — "nothing outside
#: this process was contacted" — not an absent field, which is why it is always serialized.
external_calls: list[ExternalCall] = Field(default_factory=list)

View file

@ -126,6 +126,7 @@ from portfolio_optimiser.validator import (
Rejection,
ValidatedProposal,
baseline_from_project,
classify_codes,
validate_proposal,
)
from portfolio_optimiser import hitl, okf, outbox, prepass
@ -1560,6 +1561,9 @@ async def run_project(
# anchoring than the one that ran. The road path is anchored by construction (the reference
# project's own cost_items ARE the baseline); a bundle is anchored iff it ships the file.
cost_baseline_anchored=baseline is not None,
# P19/B2: what the run made of each code it was handed. Derived from the SAME classifier
# the gate uses (kø-(p)), off the proposal being stamped — never re-read from anywhere.
code_forms=classify_codes([item.code for item in proposal.affected_items]),
# WHICH corpus was judged, and whether the base named itself or the mount named it for it.
# Read off the SAME resolution the run opened the base with (kø-(p)); ``None`` on the road
# path, where no knowledge base exists to name.

View file

@ -76,6 +76,7 @@ from typing import Any
from portfolio_optimiser import okf
from portfolio_optimiser.mandate import Mandate, load_mandate
from portfolio_optimiser.validator import classify_codes
#: Where the vegnormal bases are mounted, unless ``--bundle-root`` says otherwise. Read at CALL
#: time (the ``shared_root()`` idiom) so a test or an operator can move the mount without a reimport.
@ -119,6 +120,11 @@ class ApproachVerdict:
requirement_declared: tuple[str, ...]
requirement_source: str # "approach" | "run" | "absent"
requirement_hit: bool
#: P19/B2 - this row's ``affected_item`` codes that are not shaped like an identifier of the
#: delivered corpora at all. Read off the artefact's own ``provenance.code_forms`` when the run
#: wrote one, and RE-DERIVED with the same classifier when it did not, so rounds 1 and 2 -
#: written before the field existed - can be re-judged with the same instrument.
prose_codes: tuple[str, ...]
ferdig: bool
@ -292,6 +298,7 @@ def score_context_set(
requirement_declared=_attributable(approach, declared_paths)[0],
requirement_source=_attributable(approach, declared_paths)[1],
requirement_hit=bool(set(_attributable(approach, declared_paths)[0]) & wanted),
prose_codes=(),
ferdig=False,
)
)
@ -335,6 +342,10 @@ def score_context_set(
# snippets and gave this row ``named`` without the model having said anything.
named_in_snippet = scope == "narrowed" and any(m in snippets for m in marks)
codes = [str(i.get("code", "")) for i in proposal.get("affected_items", [])]
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)
requirement_hit = bool(set(attributable) & wanted)
@ -363,6 +374,7 @@ def score_context_set(
requirement_declared=attributable,
requirement_source=requirement_source,
requirement_hit=requirement_hit,
prose_codes=prose_codes,
ferdig=(
grounded
and (named_in_measure or named_in_snippet)
@ -383,8 +395,8 @@ def score_context_set(
for row in fasit.get("must_refuse", []):
rid = row["approach_id"]
commissioned = next((a for a in mandate.approaches if a.id == rid), None)
codes = set(commissioned.affected_codes) if commissioned is not None else set()
leaked = sorted(codes & validated_codes)
refuse_codes = set(commissioned.affected_codes) if commissioned is not None else set()
leaked = sorted(refuse_codes & validated_codes)
if rid in validated_ids:
refusals.append(
RefusalVerdict(

View file

@ -22,10 +22,12 @@ layered on by the Step 10 generate loop, keeping THIS module pure.
from __future__ import annotations
import random
import re
import statistics
import warnings
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from functools import cached_property
from dataclasses import dataclass
from typing import Final
@ -239,6 +241,66 @@ _GROUNDING_MAX_DOCUMENT_SHARE: Final = 0.05
_GROUNDING_MIN_INERT_DOCUMENTS: Final = 10
#: The identifier forms the DELIVERED corpora actually carry, TRANSCRIBED from the measurements
#: (``docs/2026-09-09-p8-forankringstilbudet.md`` § 2 for the first two, P19 DEL B for the third
#: and fourth) rather than chosen. They live HERE, in the validator, because P19/B3 made them a
#: GATE as well as P8's report, and ``generate`` imports them from here: two copies of "what an
#: identifier looks like" would let the report and the gate disagree about one run (kø-(p)).
#:
#: Bare numbers are deliberately EXCLUDED, with the number: K2 carries 46 394 occurrences over
#: 2 117 distinct values (P7 § 2), so counting them would make every report positive and the
#: measurement inert — the repo's cardinal class, a gate that can only come out green.
IDENTIFIER_FORMS: Final = (
# ``SHA-01``, ``RIM-02``, ``B-20-00-00``, ``FOR-2011-12-06-1357`` (K2's 50) — and, since P19/B2
# made the same forms decide ``prose`` vs ``identifier``, an UPPERCASE separated token with no
# digits at all. MEASURED: this repo's own ``ENERGI-TOTAL-EL`` matched neither of the pre-P19
# forms, so the classifier called a real cost code prose; a gate is only allowed to be wrong in
# the direction that admits too much.
re.compile(r"\b[A-ZÆØÅ][A-ZÆØÅ0-9]*(?:[-_][A-ZÆØÅ0-9]+)+\b"),
# ``Krav 3.3.1—13`` (the N corpora's dominant form). EM-DASH U+2014 AND the hyphen, because the
# binding known positive is the em-dash spelling and only the em-dash spelling scores 6 of 6.
# The trailing ``(?:_\d+)?`` is MEASURED, not defensive: one of the 26 fasit references is
# ``Krav 3.3.2—1_1``, and without it the classifier called that real reference prose.
re.compile(r"Krav\s+\d+(?:\.\d+)*\s*[\u2014-]\s*\d+(?:_\d+)?"),
# R761's process numbers, ``12.1`` / ``52.11`` (P19 B1). MEASURED: all six ``ref`` values in
# ``contexts/kontrakt-sorasen-2027/fasit.json`` are of this shape and NEITHER of the first two
# forms matches one of them, so r761's whole offer was 3 identifiers over 6.5 MB. The trailing
# ``(?!\.\d)`` is what keeps a Norwegian date out: ``15.09.2026`` would otherwise contribute
# its ``15.09`` prefix, and a date is not a requirement.
re.compile(r"(?<![\d.])[1-9]\d{0,2}(?:\.\d{1,3}){1,4}\b(?!\.\d)"),
# ``65 ASFALTDEKKER`` — a process number and its heading, the form a price schedule's section
# rows carry (P18 § 2 measured it at 29 of 2 756 documents).
re.compile(r"(?<!\d )(?<![\d.])[1-9]\d{0,2} [A-ZÆØÅ]{5,}\b"),
)
def identifier_tokens(text: str) -> set[str]:
"""Every DISTINCT token of any ``IDENTIFIER_FORMS`` shape in ``text``. One reader, two callers.
A form it does not know is a token it fails to count, so it errs toward saying the input offers
LESS than it does. That direction is deliberate and is what makes the same function admissible
on both sides of P19/B3: an under-count keeps the gate OFF, never turns it on wrongly.
"""
found: set[str] = set()
for form in IDENTIFIER_FORMS:
found |= set(form.findall(text))
return found
def has_identifier_form(code: str) -> bool:
"""Whether ``code`` is shaped like an identifier at all — the ``prose`` / ``identifier`` split.
FULL-MATCH, never a search: ``impulsventilator 12.1`` containing a process number does not make
the word a cost code, and a substring rule here would let any prose code carry one along.
"""
return any(form.fullmatch(code) for form in IDENTIFIER_FORMS)
def classify_codes(codes: Sequence[str]) -> dict[str, str]:
"""``{code: "identifier" | "prose"}`` — P19/B2's report, in ONE place for both consumers."""
return {code: "identifier" if has_identifier_form(code) else "prose" for code in codes}
@dataclass(frozen=True)
class Grounding:
"""The run's non-model-authored input, carried as the DOCUMENTS it is made of.
@ -273,6 +335,16 @@ class Grounding:
"""How many of the documents contain ``token`` — the numerator, in the unit of the rule."""
return sum(1 for document in self.documents if token in document)
@cached_property
def identifiers(self) -> frozenset[str]:
"""Every distinct identifier-shaped token this input OFFERS (P8's count, P19/B3's guard).
Cached because the gate asks for it once per attempt and the composed text is corpus-sized
(K2: 2 005 561 chars). ``cached_property`` writes through the instance ``__dict__``, which a
frozen dataclass still has, so the object stays immutable to every caller.
"""
return frozenset(identifier_tokens(self.text))
def _inert_in(grounding: Grounding, code: str) -> str | None:
"""Why ``code`` identifies nothing in this input, or ``None`` when it identifies something.
@ -297,7 +369,43 @@ def _inert_in(grounding: Grounding, code: str) -> str | None:
return None
def _ground_against_input(proposal: SavingsProposal, grounding: Grounding) -> Rejection | None:
def _form_refusal(grounding: Grounding, code: str, anchored_codes: frozenset[str]) -> str | None:
"""Why ``code`` cannot be a cost code of THIS input, or ``None`` (P19/B3).
**The guard is what makes this a rule and not a preference.** MEASURED over P18's round 2: two
ordinary words from a standard's prose — ``impulsventilator`` (4 of 270 N500 documents) and
``bituminøst bærelag`` (4 of 1 133 N200 documents) passed the whole gate to ``validated`` as
``affected_item`` codes. Both are GROUNDED: they appear verbatim in the input, which is all P7
asks. What they are not is an identifier of a cost line.
The rule fires ONLY when the input demonstrably offers identifier forms. A base that carries
none cannot be answered in a form it does not use, and refusing there would be a rule about
shapes rather than about the corpus the generality guard, stated rather than assumed.
A code the BASELINE carries is exempt, and that is not leniency: stage 0 has already ruled it a
real line of this project, and the weaker stage must never overrule the stronger falsifier
(the same sentence ``_grounding_text`` carries about its third source). A derived schedule whose
codes are bare section numbers would otherwise be refused wholesale by the gate meant to protect
it.
"""
if code in anchored_codes or has_identifier_form(code):
return None
offered = grounding.identifiers
if not offered:
return None
sample = ", ".join(sorted(offered)[:3])
return (
f"has no identifier form; the input this run was given offers {len(offered)} identifiers "
f"of its own (for example {sample}) — name a cost line by its identifier, not by a word "
"from the prose"
)
def _ground_against_input(
proposal: SavingsProposal,
grounding: Grounding,
anchored_codes: frozenset[str] = frozenset(),
) -> Rejection | None:
"""P7: every identifier the proposal builds on must appear VERBATIM in the input it was built
from, or the verdict falls.
@ -346,6 +454,11 @@ def _ground_against_input(proposal: SavingsProposal, grounding: Grounding) -> Re
inert = _inert_in(grounding, item.code)
if inert is not None:
violations.append(f"ungrounded identifier {item.code!r}: it {inert}")
continue
# P19/B3: present, and not everywhere — but still not an identifier at all.
shapeless = _form_refusal(grounding, item.code, anchored_codes)
if shapeless is not None:
violations.append(f"ungrounded identifier {item.code!r}: it {shapeless}")
if not violations:
return None
return Rejection(proposal=proposal, reason="; ".join(violations))
@ -387,7 +500,11 @@ def validate_proposal(
# to before: where both would fire, the baseline's sentence is the more actionable one (it
# names the project and how many codes it knows), and Step 5 feeds that sentence back.
if grounding is not None:
adrift = _ground_against_input(proposal, grounding)
adrift = _ground_against_input(
proposal,
grounding,
frozenset() if baseline is None else frozenset(baseline.items),
)
if adrift is not None:
return adrift
# Stage 1 (Pydantic) already ran at construction. Stage 2: real CBC solve.