feat(p20): the requirement that is RIGHT, and a clause number that is not a price

Three seams, one commit: A, B and C touch the same four modules (run.py carries
the debate task, the grounding composition and the announcement; okf.py carries
one reference-number vocabulary read by both A and B), so splitting them into
three commits would have meant hunk-level staging of entangled files. Stated
rather than silently restructured.

A — the declaration answers with the DOCUMENT's own words. Measured: 13
declarations over round 3 and P17b, not one naming a fasit concept, while the
tool answered {"declared": true, ...} by echoing the caller's own arguments. It
now returns the document's title and req_number, read off Bundle.context_files
(so the type: verdict layer can never be named back), plus the sentence saying
what the declaration binds. A path the base carries as no concept answers with
empty strings rather than refusing. The commission's success_criteria now reach
the DEBATE task through mandate.criteria_block, the one renderer, empty when
there are none — which is what keeps every un-commissioned prompt, and the
golden, byte-identical.

B — a clause number is not a price. THE ORDER'S OWN RULE WAS FELLED BY
MEASUREMENT: it asks to refuse a code that IS declared req_number/prosessnr,
and neither of its two known positives is. n500 declares seksjon 10.4.1..10.4.4
but never the bare 10.4; r761 declares 2727 prosessnr and 2753 seksjon, none of
them 1.10.4, which occurs once, as prose ("iht. vegnormal N200 kap. 1.10.4").
The COMPLEMENT fires on both and closes the hole _ground_against_input already
admits in writing -- "it fails OPEN on a coincidental match". Unanchored run +
requirement-shaped code + the base declares a vocabulary + the code is not in
it -> refused, naming the denominator. All five of kontrakt-sorasen's real
process codes ARE declared and pass, which is what keeps the one context set
built on real codes measurable. Replayed over all 24 codes of round 3 + P17b:
exactly the two known positives flip validated -> rejected, 22 unchanged.

C — a parse failure no longer burns the round ledger blind. _fetch_parsed takes
a BUILDER instead of a finished message list, so the retry carries the parse
reason; measured, kontrakt-sorasen-04 spent 11 of 12 rounds re-asking the same
question. And announced_subject names the routed bases instead of saying "the
portfolio" for a two-base commission.

Suite 1807/5 (from 1781, +26, 0 removed), golden demo-transcript.stdout
BYTE-UNCHANGED (shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f),
ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-15 06:02:46 +02:00
commit c8f0c8f7c4
12 changed files with 1098 additions and 37 deletions

View file

@ -211,9 +211,13 @@ _INSTRUCTIONS: Final = {
HYPOTHESISER_ROLE: (
"You shape ONE candidate cost-saving direction at a time from what the navigator found. "
"BEFORE you commit to a direction, name the ONE requirement in the knowledge base that "
"BINDS it: have the navigator find it with read_dir(filter=...) and read it with "
"read_file, then call declare_requirement with the base id, that path and the "
"requirement's own number. A direction with no requirement behind it is a guess. "
"BINDS it: pass read_dir a 'filter' word taken from the approach's own label — "
"filter='rundkjoring' finds the level's requirements about roundabouts, and one of them is "
"the 'Krav 4.1.2-1' you are looking for — read it with read_file, then call "
"declare_requirement with the base id, that path and the requirement's own number. The "
"reply gives back the document's own title and number: if they are not about your measure, "
"you declared the wrong requirement and should filter again. A direction with no "
"requirement behind it is a guess. "
"You may call quick_validate to sanity-check a candidate's numbers; its verdict is "
"ADVISORY and is not the project's decision. When you commit to a direction, end your "
f'turn with a line of the form: {HYPOTHESIS_MARKER} {{"label": "<short name>", '
@ -1001,6 +1005,26 @@ def _resolve_bundle(index: Mapping[str, str], bundle_id: str) -> str:
return index[bundle_id]
def _declared_document(index: Mapping[str, str], bundle_id: str, path: str) -> tuple[str, str]:
"""``(title, reference_number)`` of the document at ``path``, or ``("", "")`` when the base has
no navigated concept under that name.
Read off the SAME ``Bundle.context_files`` every listing rung is built from (MAJOR-3/S7a-3), so
a declaration can never be answered with the title of a ``type: verdict`` document the one
layer no listing names and ``read_file`` refuses outright.
A path the base does not carry as a concept ``index.md`` is the reachable case answers with
two empty strings rather than raising: the declaration itself has already been accepted by the
read-trace check above, and turning "I cannot restate your title" into a refusal would fail a
declaration the run's own trace proves was read.
"""
bundle = okf.navigate_bundle(_resolve_bundle(index, bundle_id))
for file in bundle.context_files:
if file.name == path:
return okf.unquote_scalar(file.frontmatter.get("title", "")), okf.reference_number(file)
return "", ""
#: Characters of the root index body one catalogue entry may carry. The catalogue's job is to let a
#: manager pick a base, not to read one, so the excerpt is a fixed-size window rather than a share
#: of the base: cost then scales with how many bases are configured, which the operator chose, and
@ -1364,7 +1388,11 @@ def navigator_tools(
"requirement's own number as its frontmatter states it. You must have READ the "
"document with read_file first: a declaration naming a path this run never opened is "
"refused, and reading it is the correction. Use read_dir with a 'filter' word to find "
"it, read_file to read it, then declare it."
"it (a word from the approach's own label works: filter='rundkjoring' -> "
"'Krav 4.1.2-1'), read_file to read it, then declare it. The reply gives back the "
"document's own "
"title and number, so you can see whether you declared the requirement you meant: a "
"declaration of a requirement that is not about the measure is worth nothing."
),
)
def declare_requirement(bundle_id: str, path: str, ref: str) -> dict[str, Any]:
@ -1386,7 +1414,26 @@ def navigator_tools(
"direction"
)
requirements.append(DeclaredRequirement(bundle_id=bundle_id, path=path, ref=ref))
return {"declared": True, "bundle_id": bundle_id, "path": path, "ref": ref}
# P20/A1: give back the DOCUMENT's own title and number, read off the base rather than
# echoed from the arguments. MEASURED (P19 round 3, P17b): 13 declarations over 5 runs and
# NOT ONE named a fasit concept — the tool answered ``{"declared": true, ...}`` to every
# declaration, so a model that had declared the wrong requirement was told it had succeeded.
# ``okf.reference_number`` is the ONE reader of "which requirement is this" (kø-(p)), and
# ``binds`` says out loud what the declaration is for: without it the reply is data with no
# instruction, and the instruction is the whole correction.
declared = _declared_document(index, bundle_id, path)
return {
"declared": True,
"bundle_id": bundle_id,
"path": path,
"ref": ref,
"title": declared[0],
"req_number": declared[1],
"binds": (
f"This declaration says {ref} is the requirement the proposal rests on; a "
"declaration of a requirement that is not about the measure is worth nothing."
),
}
tools = [list_bundles, read_bundle, read_dir, read_file]
if requirements is not None:

View file

@ -286,6 +286,7 @@ def _build_messages(
*,
approach: Approach | None = None,
prior_feedback: str | None = None,
parse_error: str | None = None,
) -> list[Message]:
"""Build the hypothesis prompt. When ``prior_rejection`` is set (Step 5, målbilde §5/§7),
append a revision block carrying ONLY the falsification *reason* verbatim never the prior
@ -308,8 +309,17 @@ def _build_messages(
"your numbers were refuted"; conflating the two would tell the model the machine objected
when a person did. ``None`` -> the byte-identical base prompt, like the other two.
All three are composable, and the ORDER is fixed: base -> approach head -> rejection ->
feedback. A prompt can legitimately carry a rejection AND a feedback at once that is the
When ``parse_error`` is set (P20/C1) a FOURTH block carries the reason the PREVIOUS reply
could not be parsed the same "only the reason, never the JSON" rule the other two follow.
It is a different instruction from both: a rejection means the numbers were refuted and a
feedback means a person objected, while this one means nothing was ever read. MEASURED (P19
F4): ``_fetch_parsed`` retried with the byte-identical prompt, and one round-3 run
(``kontrakt-sorasen-04``) spent ELEVEN of its twelve rounds on replies that all failed the
same way ``claimed_saving_nok: 0`` because nothing ever told the model what was wrong.
``None`` -> the byte-identical base prompt, like the other three.
All four are composable, and the ORDER is fixed: base -> approach head -> rejection ->
feedback -> parse error. A prompt can legitimately carry a rejection AND a feedback at once that is the
attempt after a revise whose bought attempt the validator then rejected: the human's
instruction STANDS until the human next answers, while the machine's reason is per-attempt
(only the most recent, as today).
@ -362,6 +372,14 @@ def _build_messages(
f"Expert feedback: {prior_feedback}\n"
"Produce a REVISED SavingsProposal that follows this feedback."
)
if parse_error is not None:
prompt += (
"\n\nYour previous reply could not be PARSED as a SavingsProposal, so it was "
"discarded before any validator saw it.\n"
f"Reason: {parse_error}\n"
"Reply with a SavingsProposal whose claimed_saving_nok is greater than 0 and whose "
"affected_items each carry code, quantity and unit_cost."
)
return [Message(role="user", contents=[prompt])]
@ -472,7 +490,13 @@ def _grounding_text(
*delivered.documents,
*(item.code for item in project.cost_items),
*(() if baseline is None else baseline.items),
)
),
# P20/B: the base's own vocabulary of clause numbers travels WITH the text it was read
# off. Carried through rather than recomposed: ``run_project`` walks the base once and
# composes both halves there, and a second derivation here would be free to disagree with
# the documents it is supposed to describe (kø-(p)). The two later sources are cost CODES,
# which declare nothing, so they contribute none.
declared_references=delivered.declared_references,
)
@ -615,10 +639,19 @@ async def generate_via_llm(
from discarding what it already knew. Never a malformed proposal; raises ``BudgetExceeded``
when the meter cap is crossed."""
async def _fetch_parsed(messages: list[Message]) -> SavingsProposal:
async def _fetch_parsed(build: Callable[[str | None], list[Message]]) -> SavingsProposal:
# Parse-robust: a malformed/text-leaked reply is retried; the meter caps total work.
#
# P20/C1: the retry is no longer BLIND. It takes a BUILDER rather than a finished message
# list, because the whole defect was that the same bytes were re-sent: measured, one
# round-3 run burned 11 of its 12 rounds on replies that all failed identically. The
# builder is the caller's own ``_build_messages`` binding, so this loop cannot compose a
# prompt the outer loop would not have composed (kø-(p)); the reason is per-RETRY, like
# ``prior_rejection`` is per-attempt, and starts empty so attempt 1 is byte-identical.
parse_error: str | None = None
while True:
meter.tick_round() # between-attempt bound (BudgetExceeded over cap)
messages = build(parse_error)
# Fase 1b, funn 1b: hand the model a GRAMMAR, not a prose request. The prompt's
# "Respond with ONLY a JSON object" line stays — a provider that ignores
# ``response_format`` (or a local model that does not implement it) must still be told
@ -633,10 +666,10 @@ async def generate_via_llm(
# Capture BEFORE the retry: this reply was paid for, and once ``continue`` runs the
# only record of what the model actually said is gone (Fase 1b, funn 1). Verbatim —
# the operator is diagnosing a format failure, so any shortening removes evidence.
reason = f"{type(exc).__name__}: {exc}"
if parse_failures is not None:
parse_failures.append(
ParseFailure(text=reply.text, error=f"{type(exc).__name__}: {exc}")
)
parse_failures.append(ParseFailure(text=reply.text, error=reason))
parse_error = reason
continue
last: Rejection | None = None
@ -672,14 +705,16 @@ async def generate_via_llm(
# accumulated history (bounded prompt growth).
if last is not None:
fed_back.append(last)
messages = _build_messages(
project,
context,
prior_rejection=last,
approach=approach,
prior_feedback=feedback,
candidate = await _fetch_parsed(
lambda parse_error: _build_messages(
project,
context,
prior_rejection=last,
approach=approach,
prior_feedback=feedback,
parse_error=parse_error,
)
)
candidate = await _fetch_parsed(messages)
if pending_revise is not None and reviews is not None:
reviews[pending_revise] = replace(reviews[pending_revise], honoured=True)
pending_revise = None

View file

@ -414,6 +414,29 @@ def announce(
return "\n".join(lines)
def criteria_block(success_criteria: str) -> str:
"""The ONE rendering of a commission's success criteria INTO a prompt, or ``""``.
MEASURED (P19 F2): ``success_criteria`` reached ``announce`` and nothing else, so the operator's
own statement of what a good answer looks like was printed for a human and withheld from the
only reader who could act on it. The approach's ``description`` has always reached the
generation prompt (``generate._build_messages``); this is its run-level sibling.
ONE composer, for -(p): the debate task and any later prompt that carries the criteria must
say the same thing about them, and two renderings of one commission are free to disagree about
what the operator asked for.
Empty in, empty out omission rather than an empty heading, the ``announce`` rule. That is
what keeps every prompt of every un-commissioned run, the demo's included, byte-identical.
"""
if not success_criteria:
return ""
return (
"\nWhat the commissioner counts as success (restated verbatim from the commission):\n"
f"{success_criteria}\n"
)
def settle(
coverage: tuple[ApproachOutcome, ...],
*,

View file

@ -1224,6 +1224,53 @@ _DIRECTORY_PAGE_MAX: Final = 50
#: hence ``unquote_scalar``, this repo's ONE de-quoting rule).
_FILTER_FIELDS: Final = ("req_number", "prosessnr")
#: Which frontmatter keys DECLARE a reference number — the base's own vocabulary of requirement and
#: process numbers. Derived from ``_FILTER_FIELDS`` rather than restating it (two copies of the two
#: measured keys is the kø-(p) drift), plus ``seksjon``, which is the field that carries the BARE
#: form-3 number on the N corpora and on R761 (``seksjon: '10.4.3'``, ``seksjon: '11.11'``) while
#: ``req_number`` carries the composed ``Krav 10.4.3—1``. MEASURED 15.09 over the four delivered
#: bases: n100 553 distinct declared numbers, n200 1 440, n500 365, r761 2 765.
#:
#: NOT added to ``_FILTER_FIELDS`` itself, and that is deliberate: what a ``filter`` word searches
#: is measured and gated (P18), and widening it would change ``total_matches`` for every navigator
#: call. These two constants answer different questions — "what does a filter look in" and "what
#: does this document declare as its number" — over ONE list of the measured reference keys.
REFERENCE_NUMBER_FIELDS: Final = (*_FILTER_FIELDS, "seksjon")
def reference_number(file: BundleFile) -> str:
"""The document's OWN reference number as its top-level frontmatter states it, or ``""``.
First non-empty of ``_FILTER_FIELDS``, in that order: the N corpora declare ``req_number``
("Krav 4.1.2—1") and R761 declares ``prosessnr`` ("'11.11'", quoted hence ``unquote_scalar``),
and MEASURED no delivered document declares both. ``seksjon`` is deliberately NOT read here:
this answers "which requirement IS this", and a section number names the chapter a requirement
sits in, not the requirement.
Read off ``BundleFile.frontmatter``, so P15's top-level-wins rule applies and a nested
``sources:`` entry can never answer for the concept.
"""
for key in _FILTER_FIELDS:
value = unquote_scalar(file.frontmatter.get(key, ""))
if value:
return value
return ""
def declared_reference_numbers(file: BundleFile) -> tuple[str, ...]:
"""Every reference number this ONE document declares (``REFERENCE_NUMBER_FIELDS``), de-quoted.
The unit of the reference VOCABULARY the validator's stage 0b checks a requirement-shaped code
against (P20/B). Per DOCUMENT rather than per base, because the caller composing the grounding
already walks the base once and the boundaries it composes are the ones the gate must read
(P18/B1's rule: one document is one unit, never a blob).
"""
return tuple(
value
for key in REFERENCE_NUMBER_FIELDS
if (value := unquote_scalar(file.frontmatter.get(key, "")))
)
def _matches_filter(file: BundleFile, needle: str) -> bool:
"""Case-insensitive SUBSTRING over the document's title and its reference number.

View file

@ -98,6 +98,7 @@ from portfolio_optimiser.mandate import (
MandateRoutingError,
announce,
candidate_from_approach,
criteria_block,
load_mandate,
route_by_bundle,
settle,
@ -1131,6 +1132,10 @@ async def run_project(
# gate's share rule needs, and composing them here — where the base is already walked — is what
# keeps them from being a second, drifting reconstruction (kø-(p)).
bundle_grounding: tuple[str, ...] = ()
#: P20/B: the reference numbers the base's documents DECLARE, composed in the SAME walk as the
#: 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, ...] = ()
# 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.
@ -1151,6 +1156,9 @@ async def run_project(
bundle_grounding = tuple(
"\n".join([f.name, *f.frontmatter.values(), f.body]) for f in bundle.context_files
)
bundle_references = tuple(
ref for f in bundle.context_files for ref in okf.declared_reference_numbers(f)
)
# ONE bundle-id rule (Step 10, slackened S7a-3 pkt. 1): the DECLARED id is the identity and
# the mount is carried alongside, so a base delivered under a directory name of its own is
# opened rather than refused. What is still refused, before a single model call: a base
@ -1272,7 +1280,9 @@ async def run_project(
# ``_fetch_parsed`` has returned, so a report from there could only ever speak once an attempt
# had been paid for. ONE binding feeding both the report and the gate: two compositions of one
# text are free to disagree, which is exactly what a report must not be able to do (kø-(p)).
delivered = Grounding(documents=(context, *bundle_grounding))
delivered = Grounding(
documents=(context, *bundle_grounding), declared_references=bundle_references
)
offer = grounding_offer(project, baseline, delivered)
# Trekk B2 (krav 3): configured MCP servers become tools the AGENTS can call during the debate.
@ -1359,8 +1369,16 @@ async def run_project(
async with AsyncExitStack() as mcp_stack:
for live_tool in live_mcp_tools:
await mcp_stack.enter_async_context(live_tool)
# P20/A2: the commission's success criteria reach the DEBATE — the prompt where
# ``declare_requirement`` is available — and not only the announcement. Composed through
# ``mandate.criteria_block``, the ONE renderer (kø-(p)); empty without a commission, so
# every un-commissioned run's task message is byte-identical, which is what keeps the
# golden transcript unchanged. MEASURED: on the exploration path there is nothing to
# carry — ``main()`` passes ``explore()`` no ``success_criteria`` at all, so its
# objective IS the prompt and already reaches the task.
criteria = criteria_block(mandate.success_criteria) if mandate is not None else ""
result = await debate.run(
f"Find a cost-saving measure for {project.id}.\nContext:\n{context}"
f"Find a cost-saving measure for {project.id}.{criteria}\nContext:\n{context}"
)
finally:
# ``finally``, the ``write_parse_failures`` precedent: any exception leaving the debate —
@ -1610,7 +1628,12 @@ async def run_project(
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]),
code_forms=classify_codes(
[item.code for item in proposal.affected_items],
# P20/B: the THIRD value, ``requirement``, needs the base's own vocabulary. Read off
# the SAME ``delivered`` the gate was handed, never a second composition.
delivered,
),
# 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.
@ -2290,6 +2313,32 @@ def _write_multibase_summary(
)
def announced_subject(project_id: str | None, across_bundle: Sequence[str]) -> str:
"""WHO the announcement is about: the project, the routed bases, or the portfolio (P20/C2).
MEASURED (P17b F5): ``--across-bundle`` takes no ``--project-id`` each base's project is read
from that base's own IR projection — so ``args.project_id or "the portfolio"`` announced a
multi-base commission as a portfolio pass, which is a different mode entirely.
The ids are resolved for NAMING ONLY, and a base that cannot be resolved falls back to its
directory name. That is the ``dimension_label`` precedent one line above the call site,
verbatim: a configuration that fails to load is left unnamed here and refused a moment later by
the dispatch, which stays the single owner of that refusal announcing must never change which
error an operator sees.
"""
if project_id:
return project_id
if not across_bundle:
return "the portfolio"
names = []
for raw in across_bundle:
try:
names.append(okf.reconcile_bundle_id(raw).id)
except (okf.BundleIdMismatch, FileNotFoundError, ValueError, OSError):
names.append(Path(raw).name)
return ", ".join(names)
def resolve_bundle_routing(
bundle_dirs: Sequence[str],
) -> tuple[tuple[str, str, str], ...]:
@ -4045,7 +4094,7 @@ def main(argv: list[str] | None = None) -> int:
print(
announce(
mandate,
project_id=args.project_id or "the portfolio",
project_id=announced_subject(args.project_id, args.across_bundle or ()),
# From ARGV, never the constants (P16 B2). The announcement is the one thing
# printed BEFORE the first paid call, and its whole job is to say what this run
# will do; reading the defaults was correct only while main() could not do

View file

@ -250,29 +250,50 @@ _GROUNDING_MIN_INERT_DOCUMENTS: Final = 10
#: 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.
#: The four forms, NAMED rather than reached by index: P20/B needs two of them by themselves
#: (a requirement/process number), and ``IDENTIFIER_FORMS[1:3]`` in a second module would be a
#: positional dependency on a tuple literal — the kø-(p) shape with no compiler to catch it.
_FORM_SEPARATED_UPPER: Final = re.compile(r"\b[A-ZÆØÅ][A-ZÆØÅ0-9]*(?:[-_][A-ZÆØÅ0-9]+)+\b")
_FORM_REQUIREMENT_NUMBER: Final = re.compile(r"Krav\s+\d+(?:\.\d+)*\s*[\u2014-]\s*\d+(?:_\d+)?")
_FORM_PROCESS_NUMBER: Final = re.compile(r"(?<![\d.])[1-9]\d{0,2}(?:\.\d{1,3}){1,4}\b(?!\.\d)")
_FORM_PROCESS_HEADING: Final = re.compile(r"(?<!\d )(?<![\d.])[1-9]\d{0,2} [A-ZÆØÅ]{5,}\b")
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"),
_FORM_SEPARATED_UPPER,
# ``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+)?"),
_FORM_REQUIREMENT_NUMBER,
# 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)"),
_FORM_PROCESS_NUMBER,
# ``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"),
_FORM_PROCESS_HEADING,
)
#: The two forms a REQUIREMENT or PROCESS number takes (P20/B). MEASURED over the four delivered
#: bases: these are the shapes that live in ``req_number`` ("Krav 4.1.2—1"), ``prosessnr``
#: ("'11.11'") and ``seksjon`` ("'10.4.3'") — the fields a base uses to number its own clauses.
#: The other two forms are NOT here: ``SHA-01``-style tokens are what a price schedule's cost lines
#: look like, and ``65 ASFALTDEKKER`` IS a schedule section row.
REQUIREMENT_FORMS: Final = (_FORM_REQUIREMENT_NUMBER, _FORM_PROCESS_NUMBER)
def has_requirement_form(code: str) -> bool:
"""Whether ``code`` is shaped like a requirement or process number. FULL-MATCH, never a search,
for ``has_identifier_form``'s reason: ``impulsventilator 12.1`` is not a clause number."""
return any(form.fullmatch(code) for form in REQUIREMENT_FORMS)
def identifier_tokens(text: str) -> set[str]:
"""Every DISTINCT token of any ``IDENTIFIER_FORMS`` shape in ``text``. One reader, two callers.
@ -296,9 +317,27 @@ def has_identifier_form(code: str) -> bool:
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}
def classify_codes(codes: Sequence[str], grounding: Grounding | None = None) -> dict[str, str]:
"""``{code: "identifier" | "prose" | "requirement"}`` — P19/B2's report, widened by P20/B, in
ONE place for both consumers.
``requirement`` is the third value: a code shaped like a clause number (``REQUIREMENT_FORMS``)
AND declared as one by the input's own documents. It is a REPORT about what the run made of a
code, not the gate the gate is ``_reference_refusal`` below and fires on the COMPLEMENT, a
requirement-shaped code the base's vocabulary does NOT contain.
``grounding=None`` is the pre-P20 answer exactly: without the input there is no vocabulary to
check against, so no code can be called a requirement. ``stress.py`` re-derives with ``None``
for runs that predate the field, and says so.
"""
vocabulary = frozenset() if grounding is None else grounding.reference_vocabulary
out: dict[str, str] = {}
for code in codes:
if has_requirement_form(code) and code in vocabulary:
out[code] = "requirement"
else:
out[code] = "identifier" if has_identifier_form(code) else "prose"
return out
@dataclass(frozen=True)
@ -320,6 +359,16 @@ class Grounding:
"""
documents: tuple[str, ...]
#: Every reference number the input's documents DECLARE in their own top-level frontmatter
#: (``okf.declared_reference_numbers``), one entry per declaration — the base's own vocabulary
#: of requirement, process and section numbers. P20/B checks a requirement-shaped code against
#: it.
#:
#: DEFAULTED, the ``skipped_links`` half rather than ``cost_baseline_anchored``'s: an empty
#: vocabulary is an honest POSITIVE statement ("this input declares no clause numbers"), and it
#: is what keeps every caller written before today — the road path, every fixture, ``of`` —
#: unchanged by construction, since the gate cannot fire without one.
declared_references: tuple[str, ...] = ()
@classmethod
def of(cls, text: str) -> Grounding:
@ -335,6 +384,11 @@ 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 reference_vocabulary(self) -> frozenset[str]:
"""The DISTINCT reference numbers this input declares. The denominator P20/B names."""
return frozenset(self.declared_references)
@cached_property
def identifiers(self) -> frozenset[str]:
"""Every distinct identifier-shaped token this input OFFERS (P8's count, P19/B3's guard).
@ -401,10 +455,60 @@ def _form_refusal(grounding: Grounding, code: str, anchored_codes: frozenset[str
)
def _reference_refusal(grounding: Grounding, code: str) -> str | None:
"""Why a requirement-shaped ``code`` cannot be a cost line of an UNANCHORED input (P20/B).
**The order's own rule was FELLED BY MEASUREMENT before anything was built on it.** It reads:
a code is a requirement when it matches form 2 or 3 AND "står som ``req_number``/``prosessnr``
i toppnivå-frontmatter" — refuse that. Measured 15.09 against the two known positives the same
order names:
* ``10.4`` (n500, tunnel-04, ``validated``) is declared NOWHERE in n500's frontmatter. The base
declares ``seksjon: 10.4.1`` ``10.4.4`` and ``req_number: Krav 10.4.32``; the bare ``10.4``
is a section PREFIX that occurs in 12 of 274 documents and is no document's own number;
* ``1.10.4`` (r761, lindaas a4, ``validated``) is not one of r761's 2 727 ``prosessnr`` nor one
of its 2 753 ``seksjon`` values. It occurs in ONE of 2 756 documents, as prose: "iht.
vegnormal N200 Vegbygging kap. 1.10.4".
So the ordered rule fires on NEITHER of its own known positives. The COMPLEMENT does, and it is
the better-grounded rule besides: ``_ground_against_input``'s docstring already admits that this
stage "fails OPEN … on a coincidental match", and for one shape a clause number the base
hands us the vocabulary needed to close exactly that hole. A form-3 token that is NOT one of the
numbers this base declares was matched in prose by accident.
MEASURED over every code of round 3 and P17b (24 codes, 10 runs): exactly two are
requirement-shaped, they are the two known positives, and neither is in its base's vocabulary.
All five of ``contexts/kontrakt-sorasen-2027``'s REAL process codes (``12.1``, ``12.12``,
``22.1``, ``52.11``, ``51.1``) ARE declared ``prosessnr`` and pass which is what keeps the
R761 risk the order names (a process number is both a clause and a settlement post) from
turning into a wholesale refusal of the one context set built on real codes.
**The generality guard, ``_form_refusal``'s pattern:** an input that declares no reference
numbers at all cannot be answered in a vocabulary it does not have, so the rule cannot fire
there. That is what leaves every pre-P20 fixture untouched rather than exempted.
The message NAMES THE DENOMINATOR (ansikt 4, and Step 5 feeds it verbatim into the next
attempt): "not one of the 2 765 it declares" is actionable where "ungrounded" is not.
"""
if not has_requirement_form(code):
return None
vocabulary = grounding.reference_vocabulary
if not vocabulary or code in vocabulary:
return None
sample = ", ".join(sorted(vocabulary)[:3])
return (
f"is shaped like a requirement or process number, but it is not one of the "
f"{len(vocabulary)} this knowledge base declares (for example {sample}) — it was matched "
"in prose by coincidence, and an unanchored base carries no price for a clause number"
)
def _ground_against_input(
proposal: SavingsProposal,
grounding: Grounding,
anchored_codes: frozenset[str] = frozenset(),
*,
anchored: bool = True,
) -> Rejection | None:
"""P7: every identifier the proposal builds on must appear VERBATIM in the input it was built
from, or the verdict falls.
@ -459,6 +563,15 @@ def _ground_against_input(
shapeless = _form_refusal(grounding, item.code, anchored_codes)
if shapeless is not None:
violations.append(f"ungrounded identifier {item.code!r}: it {shapeless}")
continue
# P20/B: shaped, grounded, not inert — and still a clause number the base never declared.
# UNANCHORED only: with a baseline, stage 0 has already ruled every code that reaches here
# a real line of this project, and the weaker stage must not overrule the stronger one (the
# sentence ``_form_refusal`` and ``_grounding_text`` both carry).
if not anchored:
coincidental = _reference_refusal(grounding, item.code)
if coincidental is not None:
violations.append(f"ungrounded identifier {item.code!r}: it {coincidental}")
if not violations:
return None
return Rejection(proposal=proposal, reason="; ".join(violations))
@ -504,6 +617,11 @@ def validate_proposal(
proposal,
grounding,
frozenset() if baseline is None else frozenset(baseline.items),
# P20/B: an EXPLICIT flag, never ``not anchored_codes``. A baseline with no items and
# no baseline at all are different facts, and conflating them is the very shape this
# repo refuses elsewhere (``cost_baseline_anchored`` is required without a default for
# the same reason).
anchored=baseline is not None,
)
if adrift is not None:
return adrift