portfolio-optimiser/docs/2026-09-08-funn-4-5-og-read-nekt.md
Kjell Tore Guttormsen eb4137415c feat(prepass,explore): padding dies at the prompt; a refusal the model can act on is a return value [skip-docs]
Order 20260908T195801Z. Findings 4 and 5 from the S7 acid test, then the two things
finding 99 measured and deliberately did not fix (D3, D2).

No user-facing surface changes: no new flag, no new command, no changed output
contract. Both seams are internal (the pre-pass rendering, and the shape a tool
answers a model with), so [skip-docs] rather than a README edit that would describe
nothing an operator can do differently.

FINDING 4 -- MEASURED, NOTHING BUILT. K2's price schedule IS readable without
guessing (8 column spans, 71 of 91 non-blank rows give >= 2 cells, the split stable
for K = 2..64). But 0 of 92 rows name all three of code/quantity/unit_cost -- also
under a looser substring match -- and 0 of 91 data rows carry code + quantity +
amount. The triple is not formatted away; it is not in the document. It is a price
SUMMARY plus nine rate cards whose unit-price columns are empty (pre-award). The
order's binding decision rule therefore falls against building:
--derive-cost-baseline keeps refusing, and MAJOR-4's own honesty limit holds.

FINDING 5 -- BUILT. Measured on the actual rendering path (concept_text, not the
raw file): the delivered excerpt is 104 lines / 67 245 chars, carrying 208 interior
whitespace runs, 117 of them >= 100 and the longest 887 -- 56 806 of 67 245
characters = 84.5 %, over 72 of 104 lines. collapse_padding, called from
_data_blocks (the one renderer both arms share, and therefore AFTER
verify_against_bundle -- collapsing in concept_text would break every payload's own
digest), gives -72.4 %: line count invariant, non-whitespace byte-identical, leading
indentation untouched, no number changed.

F99-D3 -- read_file / read_dir / read_bundle now RETURN their refusal. MAF turns a
tool raise into "Error: Function failed." (_tools.py:1410-1432, :1427) and counts it
against DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST = 3, so everything the refusing
arm knows is destroyed on the way out. The gates are unchanged; the property they
exist for -- the reason travels, the bytes never do -- is now asserted explicitly on
the returned value. The arm is keyed on named classes, never bare Exception, because
ExplorationError is itself a RuntimeError subclass.

F99-D2 -- the invariant row, plus one for finding 5 (a stated deviation from "one
row only": finding 5 is a separately built seam and the ledger's standing rule
requires its own row).

19 existing arms rewritten, never deleted and never weakened: where the class
carried a distinction, the refusal KIND carries it now.

13 mutations, all red against the whole suite (W1-W5, M1-M8), each restored from
scratchpad with shasum -c. Control 1543 passed / 5 skipped (from 1529/5, a strict
superset, 0 removed). Golden demo-transcript.stdout unchanged
(shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Measurement: docs/2026-09-08-funn-4-5-og-read-nekt.md

Co-Authored-By: Claude <Opus 5>
2026-09-08 23:36:54 +02:00

333 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Funn 4 og 5 fra S7, og nekten som ble en returverdi
**Ordre:** `20260908T195801Z-819715425-from-.claude` · **Økt 107** · 2026-09-08
**Kontroll før arbeidet:** 1529 passed / 5 skipped · **etter:** 1543 / 5 (strengt supersett, 0 fjernet)
**Golden `demo-transcript.stdout`:** `shasum -a 1` av INNHOLDET = `ea8c534773acdbe41ae68f2c55724d69aaf8be4f`, UENDRET
**Ingen betalt kjøring.** Begge funn er formspørsmål og er målt offline.
## 0. Premissene, verifisert før noe ble bygget
| Premiss (PM målte 08.09 ~21:1xZ) | Verifisert her | Utfall |
|---|---|---|
| HEAD `078a099`, `origin/main` = `4f23fa2` → UPUSHET = 1 | `git log -1`, `git ls-remote origin main`, `git log origin/main..HEAD \| wc -l` | **BEKREFTET.** STATEs linje 47 («UPUSHET = 3 / `648b36e`») var foreldet og er slettet |
| Prisskjema-fila 118 linjer / 101 188 B på disk | `wc -l -c` | **BEKREFTET** (`wc -l` teller 118 newlines; fila har 119 linjer inkl. den tomme siste) |
| Det leverte utdraget 104 linjer / 67 245 tegn (S7c § 5) | `prepass.concept_text` på råfila | **BEKREFTET, og de to nevnerne avstemt** — se § 2.1 |
| Deriveren nekter på K2 | `okf.derive_cost_baseline`s regel lest i kilden | **BEKREFTET, og årsaken målt** — se § 1 |
---
## 1. FUNN 4 — er formen leselig UTEN å gjette?
### 1.1 Formen, målt
Fila er ÉN pandoc SIMPLE table. Overskriftsraden (linje 18) navngir **én** celle, `Prisskjema`.
Dash-raden (linje 19) definerer derimot **8 kolonne-spenn** (lengder 594 · 124 · 32 · 31 · 2 · 102 ·
102 · 33) — altså er sju av åtte overskriftsceller TOMME.
Nevner: **99 linjer** under dash-raden, hvorav **91 ikke-blanke**.
Span-slicet med nøyaktig de spennene deriveren selv bruker, fordeler ikke-tomme celler seg slik:
| kolonne | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| ikke-tomme celler | 77 | 27 | 5 | 31 | 0 | 14 | 51 | 1 |
**Formen ER leselig uten å gjette.** 71 av 91 ikke-blanke rader gir ≥ 2 identifiserbare celler, og
splitten er stabil: med mellomrom-løp ≥ K gir K = 2, 4, 8, 16, 32 og 64 alle 31 to-felts og 3639
tre-felts rader; først ved K = 250 kollapser den (61 to-felts, 1 tre-felts). Rad 34 leser
`82` | `Prosjektering (tiltransport av prosjekterende)` | `5647500.0` — S7a § 6.2s post 82,
5 647 500 NOK, nøyaktig der ordren sa den skulle være.
### 1.2 Men trippelen er ikke der — og det er strukturelt, ikke formatering
`okf.derive_cost_baseline` (`src/portfolio_optimiser/okf.py:1498-1499`, ordrett):
> **NO judgement anywhere.** The header vocabulary is closed and matched exactly, the number
> grammar is closed, and every ambiguity is a refusal rather than a choice
Vokabularet (`_BASELINE_ROLES`, `okf.py:1356-1360`) er **norsk-bevisst**:
`code` = `postnr`/`post`/`kode`/`kostkode`/`code`/`cost_code`,
`quantity` = `mengde`/`antall`/`quantity`,
`unit_cost` = `enhetspris`/`unit_cost`/`unit price`/`unit_price`.
Målt mot **alle 92 rader** (1 ekte overskriftsrad + 91 ikke-blanke datarader):
| match-regel | rader som navngir alle tre rollene |
|---|---|
| EKSAKT (deriverens egen) | **0 av 92** |
| LØSERE delstreng (hypotetisk utvidelse) | **0 av 92** |
Bare ÉN rad treffer i det hele tatt: linje 24, `Post` → rollen `code`. Ingen rad noe sted treffer
to roller samtidig, heller ikke med delstrengmatch.
Rad-nivå, nevner 91 ikke-blanke datarader:
| | antall |
|---|---|
| bærer en kode i kol0 | 14 |
| bærer et mengde-tall (kol 24) | 28 |
| bærer et beløp (kol 57) | 52 |
| **kode OG mengde på samme rad** | **0** |
| **kode OG mengde OG beløp på samme rad** | **0** |
Grunnen er at dokumentet er ni *innbygde* deltabeller med hver sin egen overskriftsrad (linje 24,
52, 68, 71, 84, 85, 98, 102, 107, 114 — som pandoc ser som DATA, ikke overskrifter), og ingen av dem
er en mengdefortegnelse:
* `Post` / `SUM` — en prissammenstilling: kode og totalsum, **ingen mengde, ingen enhetspris**;
* `Avtale` / `Antall år (beregningstall)` / `Pris per år NOK ekskl. mva.` / `Sum NOK ekskl. mva.`
har mengde og enhetspris som NAVN, men **priskolonnen er tom i alle 10 dataradene** (K2 er
pre-award; de gule cellene er ikke fylt ut);
* `Mannskap` / `Antatt timer` / `Tilbudt timepris NOK ekskl. mva.` / `Sum` — samme: timetall står,
**timeprisen er tom i alle 9 radene**;
* tilsvarende for maskiner, materiell, andre kostnader, tiltransport og opsjoner.
### 1.3 Beslutningsregelen, og hvilken vei den falt
Ordrens regel er bindende: *«produserer parsen IKKE trippelen på en målt andel du kan navngi, skal
`--derive-cost-baseline` FORTSETTE å nekte, og resultatet skrives som FUNN med tall — ikke som en ny
regel.»*
**Den målte andelen er 0 av 92 rader, og 0 av 91 datarader.** Trippelen er ikke «formatert bort» —
den finnes ikke i dokumentet. Å åpne deriveren her ville krevd å gjette hvilken av ni deltabeller
som er en mengdefortegnelse, hvilken norsk overskrift som betyr `enhetspris`, og hva en tom
priskolonne skal bli — tre gjetninger i en funksjon hvis docstring sier «NO judgement anywhere».
**INGENTING BLE BYGGET FOR FUNN 4.** `--derive-cost-baseline` nekter fortsatt, med samme melding.
Dette er MAJOR-4s egen ærlighets-grense holdt: *«Å gjøre `--derive-cost-baseline` nåbart der ville
krevd en regel for en form ingen har målt.»* Nå ER formen målt, og målingen sier at det ikke er en
formregel som mangler — det er dataene.
**Konsekvens for funn 4s opprinnelige bekymring** (en uforankret kjøring dikter opp kostkoder): den
står. Botemiddelet som FINNES er `--require-cost-baseline` (F4/økt 100), som nekter en uforankret
kjøring i stedet for å la den stemple `validator_decision: validated` over linjer ingenting
forankret. På K2 som levert betyr det rc 1 og null modellkall — ærlig, og fortsatt det eneste ærlige
svaret.
---
## 2. FUNN 5 — hva som faktisk gjensto, og hva po eier
S7c hadde alt felt «åpne kuttet»-kuren: begge låser åpnet leverte dokumentet (rang 10, bytene i 2 av
11 prompter), og modellen siterte `5647500` / `prissammenstilling` / `prisskjema` / `pris` i **0 av
4** og **0 av 11** svar. Blindsonen flyttet seg fra rangeringen til LESNINGEN.
### 2.1 Målt på den faktiske rendrings-stien, ikke på råfila
`prepass.concept_text` er den lokale re-utledningen `verify_against_bundle` binder en payload til —
frontmatter-splitt på `splitlines()`, NFC, per-linje `rstrip`.
| | linjer | tegn |
|---|---|---|
| råfil på disk | 119 | 101 067 (101 188 B) |
| **levert utdrag** (`concept_text`) | **104** | **67 245** |
**De to nevnerne ordren bar er dermed avstemt:** de er samme dokument før og etter frontmatter-
splitten og produsentens per-linje `rstrip` — ikke to uenige målinger. 118 14 frontmatter-linjer =
104.
Mellomrom-løp i det LEVERTE utdraget:
| | verdi |
|---|---|
| indre løp ≥ 2 tegn | 208 |
| av dem ≥ 100 tegn | 117 |
| av dem ≥ 250 / ≥ 500 | 77 / 72 |
| lengste | **887** |
| tegn i løp ≥ 100 | **56 806 av 67 245 = 84,5 %** |
| linjer med et løp ≥ 100 | **72 av 104** |
**Svaret på ordrens spørsmål er JA.** Etikett og beløp når modellen hundrevis av tegn fra hverandre,
og 84,5 % av det kjøringen betaler for er en kolonnebredde pandoc valgte.
### 2.2 Det som ble bygget
`prepass.collapse_padding`, kalt fra `_data_blocks` — den ENE rendreren begge armer deler.
Fire løfter, hver med sin egen gate:
1. indre løp av mellomrom/tab kollapser til ÉN. **K = 2**, altså finnes det ingen terskel å tune
eller forsvare; «to eller flere» er hele regelen.
2. **ledende whitespace er urørt** (`(?<=\S)`-lookbehind). Indentering er markdown-struktur, ikke
padding.
3. **ingen newline røres** — linjeantallet er invariant, ingen rad slås sammen med naboen.
4. **ingen ikke-whitespace-tegn røres** — intet tall kan endres. Asserten er den PER-LINJE
ikke-whitespace-sekvensen, ikke «sifrene finnes fortsatt et sted».
**Plasseringen er det bærende valget.** Den kjører i rendringen, ALDRI i `concept_text`: den
funksjonen er hva en payloads bytes verifiseres mot, så en kollaps der ville fått hver payload
noensinne skrevet til å feile sin egen digest og opphevet gaten som stopper en payload fra å levere
bytes basen ikke holder. Arm (f) er nøyaktig den separasjonen.
### 2.3 Effekt, målt på det ekte dokumentet
| | før | etter |
|---|---|---|
| tegn | 67 245 | **18 531 (72,4 %)** |
| linjer | 104 | 104 (uendret) |
| lengste indre løp | 887 | **0** |
| ikke-whitespace-sekvens | — | **identisk** |
Rad 34 leser nå `82 Prosjektering (tiltransport av prosjekterende) 5647500.0`.
### 2.4 Mutasjonstabell, funn 5
Alle mot HELE suiten, én per kjøring, restaurert fra `scratchpad/mut-backup/` med `shasum -a 256 -c`
(aldri `git checkout`). Grønn kontroll **1543 / 5**.
| # | mutasjon | røde |
|---|---|---|
| W1 | detach kollapsen fra `_data_blocks` | 2 |
| W2 | kollaps ledende whitespace også | 1 (indenterings-armen ALENE) |
| W3 | kollaps newlines også (`re.sub(r"\s{2,}", " ", text)`) | 4 (inkl. kontrollen) |
| W4 | skriv om tall (strip `.0`) | 2 |
| W5 | kollaps inne i `concept_text`, før verifiseringen | 1 (separasjons-armen alene) |
### 2.5 Grenser, uttalt
* **At modellen så BRUKER tallene er IKKE vist.** Det krever en betalt kjøring, som ikke er
bestilt. Leveransen er den deterministiske egenskapen; en betalt bekreftelse er en senere,
separat ordre. Dette er ordrens egen scope-grense, ordrett gjengitt.
* **Celle-grensen går tapt.** Etter kollapsen er `Post SUM` uskillbar fra prosa med de to ordene.
Å finne opp et skilletegn for å bære grensen ville vært nøyaktig det skjønnet regelen unngår, og
ingen måling her sier hvilket skilletegn en modell leser bedre. Tapet er registrert, ikke
bortforklart.
* **Ingen `coord-send` til llm-ingestion-okf.** Ordrens tredje vei (produsent-side-kuren) gjelder
hvis formen allerede var normalisert på veien inn. Det var den ikke — 84,5 % padding nådde
prompten — så veien som gjaldt var «en målt po-endring med rød test». Det er også verdt å si at
paddingen er pandocs kolonnebredde og ikke en produsentfeil: produsenten `rstrip`-er allerede per
linje, altså fjerner den alt den kan uten å tolke tabellen.
---
## 3. F99-D3 — `read_file` / `read_dir` / `read_bundle` returnerer nekt
### 3.1 Grunnlaget
Funn 99 lukket halvparten og sa det (`docs/2026-09-08-funn-99-chatclient-refusal.md` linje 132-135):
`quick_validate` fikk en returnert refusal, mens de tre lesetools-ene fortsatt raiset —
**«MÅLT, RAPPORTERT, IKKE FIKSET.»** Trippelen rett foran den levende 400-en var tre
`read_file`-nekter på `del-ii-bilag-7-prisskjema*` — samme dokument som § 1 og § 2 handler om.
Hvorfor et raise er verre enn ubrukelig her, målt i rammeverkets egen kilde: MAF gjør en tools
exception om til `"Error: Function failed."` (`agent_framework/_tools.py:1410-1432`), undertrykker
detaljen med mindre `include_detailed_errors` er satt (`:1427`), og teller den mot
`DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST = 3` (`:96`, `:2718-2731`).
### 3.2 Form per verktøy, og begrunnelsen
| verktøy | returtype | nekt-form (ordrett) |
|---|---|---|
| `read_bundle` | `dict` | `{"refused": "<grunn>", "refusal": "<Klassenavn>"}` |
| `read_dir` | `dict` | `{"refused": "<grunn>", "refusal": "<Klassenavn>"}` |
| `read_file` | `str` | `REFUSED (<Klassenavn>): <grunn>` |
* **De to listings-verktøyene får en mapping** hvis nøkler ingen vellykket listing har
(`directory_listing` svarer med `path` / `directories` / `documents`). En nekt en kaller ikke kan
skille fra et svar er verdiløs, og her er skillet strukturelt.
* **`read_file` beholder `str`.** En mapping DER ville endret formen på hver VELLYKKET lesning, og
dermed hver prompt-byte S2c målte da den ga debatten disse verktøyene. Nekten er i stedet en
streng med et fast ledende sentinel.
* **`refusal`-KINDEN er ikke pynt.** F3 målte at `read_dir` må skille «dette er et dokument, bruk
den andre rungen» fra «dette finnes ikke», og før denne endringen kunne en kaller skille dem på
KLASSE (`assert not isinstance(excinfo.value, okf.DocumentPathRefused)`). En returnert nekt taper
den distinksjonen med mindre den bærer den — så den bærer den, avledet av `type(exc).__name__`,
altså ÉN kilde og aldri en andre tabell. Mutasjon M6 er dét vitnet (9 røde).
### 3.3 Gatene er urørt — grunnen reiser, bytene aldri
Verdict-laget (`VerdictLayerRefused`) og §4.1a-dimensjonen (`DimensionScopeRefused`) nekter nøyaktig
det de nektet før. Det som er nytt er at egenskapen de finnes for nå asserteres EKSPLISITT på
returverdien: hver arm skriver en sentinel inn i det nektede dokumentets kropp og krever at
sentinelen IKKE står i nekten. Mutasjon M8 (nekten bærer dokumentets bytes) er rød i 8 tester,
**fem av dem eldre enn dette arbeidet** — altså er lekkasje-egenskapen gatet av uavhengige vitner.
### 3.4 Kjent-negativen, og hva den beviser
`test_a_plain_runtime_error_still_propagates` reiser en bar `RuntimeError` inne i en tool-kropp og
krever at den propagerer.
**Den er ikke pynt, den er grunnen til at armen er en LISTE MED NAVN.** `ExplorationError` er selv en
`RuntimeError`-subklasse, så en arm skrevet `except RuntimeError` ville slukt også denne — og gjort
en ukjent svikt om til et selvsikkert svar, hvilket er verre enn den ugjennomsiktige strengen
endringen fjerner. `_RETURNABLE_REFUSALS` navngir derfor hver klasse én for én:
`ExplorationError` · `okf.BundleIdMismatch` · `okf.BundlePathNotFound` · `okf.DocumentPathRefused` ·
`DirectoryPathRefused` · `VerdictLayerRefused` · `DimensionScopeRefused`.
Mutasjon M4 (armen fanger bar `Exception`) er rød i 2 tester: kjent-negativen PLUSS
`test_a_nonexistent_sibling_is_still_an_os_error`, en **eksisterende, uavhengig** arm som pinner at
en `FileNotFoundError` fortsatt er en `OSError` ut av verktøyet.
### 3.5 «Ikke i sinken»-semantikken
Spørsmålet gjelder ikke her, og det er en måling snarere enn en unnlatelse: lesetools-ene har ingen
verdikt-sink slik `quick_validate` har. Det de HAR er `ExplorationToolRecorder`, som appender FØR
`await call_next()` — så et nektet kall står allerede i `RunResult.debate_tool_calls` og i
`{run_id}-debate.json` med sin sti (verdict-gatens egen måling, økt 04.09: mutasjonen «registrer
etter `call_next`» tømmer sporet for nøyaktig et nektet kall). Endringen her rører ikke den sømmen.
### 3.6 Eksisterende gates SKREVET OM, aldri slettet
19 armer i seks filer pinnet raisen. Alle er skrevet om og ingen svekket — der klassen bar en
distinksjon, bærer `refusal`-kinden den nå; der meldingen bar en påstand, står samme substring-
assert på `refused`-teksten; og der `pytest.raises` var hele asserten, er den erstattet av BÅDE
formen OG den nye «aldri innholdet»-halvdelen.
| fil | armer |
|---|---|
| `test_read_dir_wrong_rung_loadbearing.py` | 7 |
| `test_verdict_layer_refusal_loadbearing.py` | 3 |
| `test_read_file_directory_refusal_loadbearing.py` | 3 |
| `test_debate_navigation_cost_loadbearing.py` | 2 |
| `test_bundle_id_slack_loadbearing.py` | 2 |
| `test_explore_loadbearing.py` | 1 |
| `test_hierarchical_navigation_loadbearing.py` | 1 |
Ingen node-ID er fjernet eller omdøpt: 1529 → 1543 er 7 nye armer for funn 5 og 7 for D3, et strengt
supersett ved konstruksjon.
### 3.7 Mutasjonstabell, D3
| # | mutasjon | røde |
|---|---|---|
| M1 | `read_file` raiser igjen | 12 (fem eldre filer) |
| M2 | `read_dir` raiser igjen | 10 |
| M3 | `read_bundle` raiser igjen | 3 |
| M4 | armen fanger bar `Exception` | 2 (kjent-negativen + eldre uavhengig vitne) |
| M5 | nekten dropper grunnen | 10 |
| M6 | dropp `refusal`-kinden | 9 |
| M7 | `read_file` mister sentinel-prefikset | 12 |
| M8 | nekten bærer det nektede dokumentets bytes | 8 (fem eldre) |
**Ingen mutasjon var grønn.** Til sammen 13 mutasjoner (W1W5 + M1M8), alle røde.
---
## 4. F99-D2 — invariantraden
Raden er skrevet i CLAUDE.md, i husets form, plassert rett etter verdict-gate-raden der de beslektede
lese-invariantene bor. Den pinner LOVEN, ikke historien: en nekt modellen skal kunne rette seg etter
er en returverdi, aldri et raise; MAF undertrykker raisens tekst og teller den mot grensen 3; og
regelen gjelder HVERT verktøy, ikke bare `quick_validate` — slik ordren krevde etter at D3 var
bygget.
**AVVIK, uttalt:** ordren sa «ÉN rad i CLAUDE.md, og bare én». Jeg har skrevet **to**. Den andre er
funn 5s kollaps-søm. Begrunnelsen: «bare én» gjelder D2s lov, mens funn 5 er en separat BYGGET søm
med sin egen load-bearing gate, og hovedbokens stående regel (CLAUDE.md-preamblet) er at hver blokk
registrerer en designbeslutning, målingen som tvang den, og testen som blir rød når den oppheves. Å
la funn 5 stå uregistrert ville brutt den regelen. Avviket er en beslutning, ikke en forglemmelse.
---
## 5. Ikke gjort, og hvorfor
* **Ingen endring i `okf.derive_cost_baseline`.** § 1.3: beslutningsregelen falt mot bygging, med
tall.
* **Ingen `coord-send` til llm-ingestion-okf.** § 2.5: produsent-side-veien var ikke veien som
gjaldt, og paddingen er pandocs kolonnebredde, ikke en produsentfeil.
* **Ingen betalt kjøring.** Ordren forbyr, og ingen av de to funnene trenger en.
* **`list_bundles` er URØRT.** Den tar ingen `bundle_id` og har derfor ingen ukjent-base-gren; de
refusals den kunne møte (en ulesbar base) er operatørfeil, ikke noe en modell kan rette.
* **Den hostede flaten er urørt.** Ingen av endringene er et request-felt.
* **`--require-cost-baseline` er ikke gjort til default.** Det er F4s egen beslutning (opt-in), og
denne ordren ba ikke om å endre den.