fix(validator): stage 0 navngir ALLE baseline-overtredelser, ikke bare den foerste

K2-funn (b), maalt live i oekt 94 (docs/2026-09-06-major2-levende-k2.md § 4,
kjoering 3, max_attempts=3):

  1000/500 -> "quantity 1000 ... baseline 1250" -> 1188/350
           -> "unit_cost 350 ... baseline 850"  -> 1000/850 -> forsoekene brukt opp

Steg 5 mater avvisningsgrunnen ORDRETT inn i neste forsoeks prompt, saa en
melding som navngir ETT felt leses som en instruks om aa rette det feltet.
Modellen fant hver riktig verdi og aldri begge samtidig: den rettet feltet
avvisningen navnga og brakk det andre. Loekka oscillerte i stedet for aa
konvergere.

Endringen gjelder KUN meldingens fullstendighet. D6 er uendret: en hvilken som
helst overtredelse avviser fortsatt, i samme stage, foer loeseren.
max_attempts heves IKKE og eksponeres IKKE.

Hver overtredelse beholder dagens setning ORDRETT, sammenfoeyd med "; ", saa
NOEYAKTIG EN overtredelse rendres byte-identisk med foer - det er dette som
holder de eksisterende delstreng-assertene i S4.0-, reserve- og
levert-bundle-gatene staaende. Skilletegnet er valgt framfor linjeskift fordi
Rejection.reason ogsaa lander i outbox-JSON, de hostede payloadene og
terminal-notisene.

Rekkefoelgen er FORSLAGETS egen (linjer i oppgitt rekkefoelge, quantity foer
unit_cost i en linje), saa to identiske forsoek gir to identiske prompter. En
ukjent kostkode bidrar med sin ENE setning og ingen magnitude-setninger: det
finnes ingen baseline-linje aa avvike fra, og en sammenligning mot ingenting er
nettopp den fabrikasjonen dette steget finnes for.

Load-bearing MAALT (tests/test_stage0_all_violations_loadbearing.py, 6 armer),
seks mutasjoner ALLE ROEDE mot HELE suiten + groenn kontroll 1387/5 (fra
1381/5; +6, 0 fjernet - strengt supersett) og golden demo-transcript.stdout
BYTE-UENDRET (shasum -a 1 av INNHOLDET = ea8c534773acdbe41ae68f2c55724d69aaf8be4f):
returner ved foerste overtredelse (4 roede) - rapporter kun den siste (4) -
ustabil rekkefoelge i linja (1) - over-rapporter et felt som er INNENFOR
toleransen (28) - drift enkelt-overtredelsens form (1) - la en ukjent kode
ogsaa emittere magnitude-setninger (19).

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-07 00:38:50 +02:00
commit 78e8e39147
2 changed files with 187 additions and 16 deletions

View file

@ -163,32 +163,49 @@ def _reconcile_against_baseline(
than ``tolerance`` (relative to the BASELINE value, which is the ground truth) — a real code
carrying a fabricated magnitude.
Returns the first ``Rejection`` (validator's own type — never a new gate), or ``None`` when the
proposal reconciles. Items are checked in their stated order so the reason is deterministic.
A validation, never a repair: the proposal is rejected, not silently corrected to the baseline."""
Returns ONE ``Rejection`` (validator's own type — never a new gate) naming EVERY violation the
attempt carries, or ``None`` when the proposal reconciles. A validation, never a repair: the
proposal is rejected, not silently corrected to the baseline.
**Completeness is load-bearing (K2 finding (b), measured live in økt 94).** Step 5 feeds this
reason VERBATIM into the next attempt's prompt, so a message naming only the FIRST violation
reads as an instruction to fix that one field. Measured on a line broken in both fields
(``docs/2026-09-06-major2-levende-k2.md`` § 4, run 3): the proposer fixed the quantity and
rebroke the unit cost, then fixed the unit cost and rebroke the quantity, and ran out of
attempts — it found each correct value and never both at once. Reporting them together is what
lets the loop converge under the EXISTING cap (``max_attempts`` is not raised, and stays
unexposed). The verdict itself (D6) is unchanged: any violation still rejects, in this stage,
before the solver.
Each violation keeps its sentence VERBATIM, joined with ``"; "``, so exactly one violation
renders byte-identically to before. The joiner is a separator no single-line renderer can
break — chosen over a newline because ``Rejection.reason`` also lands in outbox JSON, the
hosted payloads and terminal notices. Order is the PROPOSAL's own — items in stated order,
``quantity`` before ``unit_cost`` within an item — so two identical attempts get two identical
prompts. An unknown code contributes its one sentence and NO magnitude sentences: there is no
baseline line for its figures to deviate from, and a comparison against nothing is exactly the
fabrication this stage exists to catch."""
violations: list[str] = []
for item in proposal.affected_items:
line = baseline.items.get(item.code)
if line is None:
return Rejection(
proposal=proposal,
reason=(
f"unknown cost code {item.code!r}: not in project {baseline.project_id}'s "
f"cost baseline ({len(baseline.items)} known codes)"
),
violations.append(
f"unknown cost code {item.code!r}: not in project {baseline.project_id}'s "
f"cost baseline ({len(baseline.items)} known codes)"
)
continue
for field, claimed, actual in (
("quantity", item.quantity, line.quantity),
("unit_cost", item.unit_cost, line.unit_cost),
):
if abs(claimed - actual) > tolerance * actual:
return Rejection(
proposal=proposal,
reason=(
f"{field} {claimed:g} for cost code {item.code!r} is outside the "
f"{tolerance:.1%} tolerance around the baseline {field} {actual:g}"
),
violations.append(
f"{field} {claimed:g} for cost code {item.code!r} is outside the "
f"{tolerance:.1%} tolerance around the baseline {field} {actual:g}"
)
return None
if not violations:
return None
return Rejection(proposal=proposal, reason="; ".join(violations))
def validate_proposal(