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(