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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-08 23:36:54 +02:00
commit eb4137415c
13 changed files with 975 additions and 69 deletions

View file

@ -1839,6 +1839,71 @@ Python ≥3.10. MAF (`agent-framework-core` 1.16.0, `-orchestrations` 1.1.1 —
nekten er nøklet på den ENE erklærte typen ExpeL-folden eier, aldri på et bredere
«ikke i `context_files`»-komplement, som M4 viser ville rammet `index.md` — navigasjon, ikke en
dom. Måling: `docs/2026-09-04-s2c-debatt-k2.md` § 8.
- **En nekt modellen skal kunne RETTE SEG ETTER er en RETURVERDI, aldri et raise — og dét gjelder
HVERT verktøy, ikke bare det ene funn 99 rakk (funn 99 D2+D3, 08.09):** MAF gjør en tools
exception om til den ugjennomsiktige strengen `"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`). Alt den nektende armen VET — hvilke base-id-er som finnes, hvilket dokument stien
egentlig navngir, hvilken rung som leser det — tilintetgjøres altså på vei ut, og tre av dem
avslutter forespørselen. MÅLT live: modellen gjettet på JSON-formatet, som var riktig hele veien,
og trippelen rett foran en Azure-400 var tre `read_file`-nekter på samme dokument. `quick_validate`
returnerer `{"decision":"refused",…}` og hver gren — refusal inkludert — står i `quick_validations`;
`read_bundle`/`read_dir` returnerer `{"refused", "refusal"}` (ingen nøkkel en vellykket listing
har), `read_file` en streng med sentinel-prefikset `REFUSED (<kind>): ` fordi en mapping DER ville
endret formen på hver VELLYKKET lesning og dermed hver prompt-byte S2c målte. **`refusal`-KINDEN er
ikke pynt:** klasse-distinksjonen `pytest.raises` bar før (F3s «dette er et dokument» vs «dette
finnes ikke») ville forsvunnet med raisen, så den bæres som felt, avledet av `type(exc).__name__`
ÉN kilde, aldri en andre tabell. **GATENE ER URØRT** — verdict-laget og §4.1a-dimensjonen nekter
nøyaktig det de nektet før — og egenskapen de finnes for asserteres nå EKSPLISITT på returverdien:
**grunnen reiser, bytene aldri.** **Armen er nøklet på KLASSEN, aldri bar `Exception`:**
`_RETURNABLE_REFUSALS` navngir hver klasse én for én, fordi `ExplorationError` selv er en
`RuntimeError`-subklasse, så `except RuntimeError` ville slukt en feil ingen har navngitt og gjort
en ukjent svikt om til et selvsikkert svar — verre enn den ugjennomsiktige strengen. Load-bearing
MÅLT (`tests/test_explore_read_tools_refusal.py`, 7 armer), åtte mutasjoner alle røde mot HELE
suiten + grønn kontroll 1543/5 og golden BYTE-UENDRET: M1 `read_file` raiser igjen (12) · M2
`read_dir` (10) · M3 `read_bundle` (3) · M4 armen fanger bar `Exception` (2 — kjent-negativen PLUSS
et eldre, uavhengig vitne, `test_a_nonexistent_sibling_is_still_an_os_error`) · 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 i eldre tester). **Ærlighets-grenser,
uttalt:** et dokument hvis første tegn ER sentinelen ville vært uskillbar fra en nekt — en ekte
luke, skrevet ned i stedet for bortforklart; en `OSError` (f.eks. `FileNotFoundError` på en sti
som ikke finnes) står UTENFOR settet og propagerer fortsatt, gatet av en eksisterende arm; og at en
LEVENDE modell faktisk RETTER seg etter nekten er ikke bevist (structured-output-grensens klasse).
Måling: `docs/2026-09-08-funn-4-5-og-read-nekt.md` § 3.
- **Et regneark-renders PADDING dør på vei inn i prompten; ingenting annet gjør det (funn 5, 08.09):**
S7c hadde alt FELT den åpenbare kuren — begge låser åpnet LEVERTE K2s prisskjema (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 hadde flyttet seg fra RANGERINGEN til LESNINGEN, og dét po eier der
er rendringen. **MÅLT PÅ DEN FAKTISKE RENDRINGS-STIEN** (`prepass.concept_text`, ikke råfila): det
leverte utdraget er **104 linjer / 67 245 tegn** — hvilket AVSTEMMER de to nevnerne (119 linjer /
101 188 B på disk, samme dokument før frontmatter-splitten og produsentens per-linje `rstrip`) — og
bærer **208** indre mellomrom-løp ≥ 2, **117** av dem ≥ 100 og det lengste **887**, til sammen
**56 806 av 67 245 tegn = 84,5 %** over **72 av 104 linjer**. Etikett og beløp nådde altså modellen
hundrevis av tegn fra hverandre (`82` · 593 mellomrom · beskrivelse · 250 mellomrom · `5647500.0`).
Etter: **67 245 → 18 531 tegn (72,4 %)**, linjeantall uendret, ikke-whitespace byte-identisk, og
linja leser `82 Prosjektering (tiltransport av prosjekterende) 5647500.0`. **INGEN SKJØNN, og det er
fire løfter med hver sin gate:** indre løp av mellomrom/tab kollapser til ÉN (K = 2, altså finnes
det ingen terskel å forsvare); **LEDENDE whitespace er URØRT** (indentering er markdown-STRUKTUR —
nestede lister, indentert kode — og en regel som flatet den ville omskrevet dokumenter i stedet for
å av-padde dem; dét er hva `(?<=\S)` kjøper); **ingen newline røres**, så linjeantallet er invariant
og ingen rad slås sammen med naboen (en naiv `re.sub(r"\s+", " ", text)` gjør en tabell til et
avsnitt); **ingen ikke-whitespace-tegn røres**, så intet tall kan endres — asserten er den
PER-LINJE ikke-whitespace-SEKVENSEN, ikke «sifrene finnes fortsatt et sted». **HVOR den bor er det
bærende valget:** i `_data_blocks`, den ENE rendreren begge armer deler (kø-(p) — en fiks i
`render_context` alene ville latt utforskningen lese padding), og derfor ETTER
`verify_against_bundle`. `concept_text` er re-utledningen en payloads bytes bindes til, 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. Load-bearing MÅLT
(`tests/test_prepass_padding_collapse_loadbearing.py`, 7 armer), fem mutasjoner alle røde mot HELE
suiten + grønn kontroll 1543/5 og golden BYTE-UENDRET: W1 detach kollapsen fra `_data_blocks` (2) ·
W2 kollaps ledende whitespace også (1, indenterings-armen ALENE) · W3 kollaps newlines også (4,
inkl. kontrollen) · W4 skriv om tall (2) · W5 kollaps inne i `concept_text` (1, separasjons-armen
alene). **Ærlighets-grenser, uttalt:** kollaps til ett mellomrom mister CELLE-GRENSEN — `Post SUM`
blir uskillbar fra prosa med de to ordene — og å finne opp en skilletegn ville vært nøyaktig det
skjønnet regelen unngår, uten en måling som sier hvilket en modell leser bedre; at en modell så
BRUKER tallene er IKKE vist (det krever en betalt kjøring, som ikke er bestilt).
Måling: `docs/2026-09-08-funn-4-5-og-read-nekt.md` § 2.
- **Eksperten kan svare på forslaget som ligger på BORDET — og svaret BRUKES, det registreres ikke
bare (MAJOR-2, økt 8889):** før dette fantes ÉN menneske-søm inne i løkka, `plan_reviewer`, og
den fyrer FØR noen hypotese finnes. **Nevneren er målt** (`docs/2026-09-02-misjonsreview-v2.md`

View file

@ -0,0 +1,333 @@
# 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.

View file

@ -927,6 +927,58 @@ def _index_excerpt(body: str) -> tuple[str, bool]:
return (body[:cut] if cut > 0 else body[:_CATALOGUE_EXCERPT_CHARS]), True
#: The refusal classes the navigator's read tools turn into an ANSWER instead of an exception,
#: named ONE BY ONE and never as a base class. ``ExplorationError`` is itself a ``RuntimeError``
#: subclass, so an arm written as ``except RuntimeError`` would also swallow a failure nobody
#: named -- turning an unknown fault into a confident-looking answer, which is worse than the
#: opaque string this conversion removes. MEASURED: inside these three tool bodies the only
#: reachable ``ExplorationError`` is ``_resolve_bundle``'s unknown-base refusal.
_RETURNABLE_REFUSALS: Final = (
ExplorationError,
okf.BundleIdMismatch,
okf.BundlePathNotFound,
okf.DocumentPathRefused,
DirectoryPathRefused,
VerdictLayerRefused,
DimensionScopeRefused,
)
def _refusal_kind(exc: Exception) -> str:
"""The refusal's KIND, from the exception's own class name -- one source, never a second table.
F3 measured that ``read_dir`` must tell "this is a document, use the other rung" apart from
"this does not exist", and before this change a caller could tell them apart by CLASS. A
returned refusal would lose that distinction unless it carries it, so it does.
"""
return type(exc).__name__
def _refused_mapping(exc: Exception) -> dict[str, Any]:
"""A listing tool's refusal: a mapping with no key a successful listing has.
``directory_listing`` answers with ``path`` / ``directories`` / ``documents``, so ``refused``
and ``refusal`` cannot be mistaken for a result -- the property that makes a returned refusal
worth anything at all.
"""
return {"refused": str(exc), "refusal": _refusal_kind(exc)}
def _refused_text(exc: Exception) -> str:
"""``read_file``'s refusal, as text, because that tool answers with text.
**Why not a mapping here.** Making ``read_file`` return a dict would change the shape of every
SUCCESSFUL read, and therefore every prompt byte S2c measured when it gave the debate these
tools. The refusal is a string with a fixed leading sentinel instead.
**Honesty limit, stated.** A document whose first characters were exactly this sentinel would
be indistinguishable from a refusal. That is a real gap and it is recorded rather than papered
over; what IS guaranteed is the direction that matters -- a refusal never carries the bytes of
the document it refused, which is the property the verdict and dimension gates exist for.
"""
return f"REFUSED ({_refusal_kind(exc)}): {exc}"
def navigator_tools(
bundle_dirs: Sequence[str], *, dimension: str | None = None
) -> list[FunctionTool]:
@ -1022,6 +1074,12 @@ def navigator_tools(
),
)
def read_bundle(bundle_id: str) -> dict[str, Any]:
try:
return _read_bundle(bundle_id)
except _RETURNABLE_REFUSALS as exc:
return _refused_mapping(exc)
def _read_bundle(bundle_id: str) -> dict[str, Any]:
bundle_dir = _resolve_bundle(index, bundle_id)
bundle = okf.navigate_bundle(bundle_dir)
# The base is OPENED here, so this is where it must be able to say what it IS (S7a-3
@ -1044,16 +1102,25 @@ def navigator_tools(
),
)
def read_dir(bundle_id: str, path: str) -> dict[str, Any]:
bundle_dir = _resolve_bundle(index, bundle_id)
bundle = okf.navigate_bundle(bundle_dir)
okf.assert_declared_ids_agree(bundle)
return okf.directory_listing(bundle, path, dimension=dimension)
try:
bundle_dir = _resolve_bundle(index, bundle_id)
bundle = okf.navigate_bundle(bundle_dir)
okf.assert_declared_ids_agree(bundle)
return okf.directory_listing(bundle, path, dimension=dimension)
except _RETURNABLE_REFUSALS as exc:
return _refused_mapping(exc)
@tool(
name="read_file",
description="Read ONE document inside a knowledge base, by base id and relative path.",
)
def read_file(bundle_id: str, path: str) -> str:
try:
return _read_file(bundle_id, path)
except _RETURNABLE_REFUSALS as exc:
return _refused_text(exc)
def _read_file(bundle_id: str, path: str) -> str:
bundle_dir = _resolve_bundle(index, bundle_id)
# safe_resolve is the ONE in-/out-of-bundle test in this repo, and it is fail-closed. A
# model-chosen path is untrusted input by definition, so it goes through the same gate the

View file

@ -31,10 +31,12 @@ from __future__ import annotations
import hashlib
import json
import re
import unicodedata
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from pydantic import BaseModel, ConfigDict
@ -574,14 +576,61 @@ def _excerpt_header(excerpt: PrepassExcerpt) -> str:
return f"--- BEGIN DATA {excerpt.concept_id} ({', '.join(fields)}) ---"
#: Two or more. There is deliberately no threshold to tune: "a run of horizontal whitespace inside
#: a line is a column gap" is the whole rule, so no K can drift and no K has to be defended.
_PADDING_RUN_RE: Final = re.compile(r"(?<=\S)[ \t]{2,}")
def collapse_padding(text: str) -> str:
r"""Drop a spreadsheet render's column PADDING on the way into the prompt, and nothing else.
**Measured (finding 5, order 20260908T195801Z)** on K2's price schedule as
``concept_text`` delivers it -- 104 lines / 67 245 characters: 208 interior whitespace runs of
two or more, **117 of them >= 100 characters and the longest 887**, together **56 806 of
67 245 characters = 84.5 %** of the excerpt, over 72 of 104 lines. So a label and its amount
reached the model hundreds of characters apart (``82`` / 593 spaces / a description / 250
spaces / ``5647500.0``), and most of what the run paid for was a column width pandoc chose.
**No judgement anywhere**, which is four separate promises and each is gated:
* interior runs of spaces and tabs collapse to ONE space;
* **LEADING whitespace is untouched** -- indentation is markdown structure (nested lists,
indented code), not padding, and flattening it would rewrite documents rather than unpad
them. That is what the ``(?<=\S)`` lookbehind buys;
* **no newline is touched**, so the line count is invariant and no row is merged with its
neighbour -- a naive ``re.sub(r"\s+", " ", text)`` turns a table into a paragraph;
* **no non-whitespace character is touched**, so no number can change. The gate asserts the
per-line sequence of non-whitespace characters is identical, not merely that the digits are
still somewhere.
**It runs in the RENDERING, never in :func:`concept_text`.** That function is the local
re-derivation ``verify_against_bundle`` binds a payload's bytes to; collapsing there would make
every payload ever written fail its own digest and would defeat the gate that stops a payload
delivering bytes the base does not hold. This is a display transform applied AFTER that gate.
**Honesty limit, stated.** Collapsing to a single space loses the CELL BOUNDARY: ``Post SUM``
becomes indistinguishable from prose containing those two words. Inventing a delimiter to carry
the boundary would be exactly the judgement this rule avoids, and no measurement says which
delimiter a model reads better -- so the loss is recorded rather than papered over. That a model
then USES the numbers is likewise not shown: it would take a paid run, which is a separate
order.
"""
return "\n".join(_PADDING_RUN_RE.sub(" ", line) for line in text.split("\n"))
def _data_blocks(payload: PrepassPayload) -> list[str]:
"""The delimited DATA blocks, one per delivered excerpt (SS 9.3), shared by both arms."""
"""The delimited DATA blocks, one per delivered excerpt (SS 9.3), shared by both arms.
:func:`collapse_padding` runs HERE, in the one renderer both arms share (-(p)): a fix in
``render_context`` alone would leave the exploration reading padding, and two copies of a
display rule drift into two answers about one excerpt.
"""
lines: list[str] = []
for excerpt in payload.excerpts:
lines += [
"",
_excerpt_header(excerpt),
excerpt.text,
collapse_padding(excerpt.text),
f"--- END DATA {excerpt.concept_id} ---",
]
return lines

View file

@ -311,9 +311,13 @@ def test_explore_addresses_a_base_by_its_declared_id(tmp_path: Path) -> None:
catalogue = tools["list_bundles"].func()
assert [entry["id"] for entry in catalogue] == [_DECLARED]
assert tools["read_bundle"].func(bundle_id=_DECLARED), "the declared id must OPEN the base"
with pytest.raises(explore.ExplorationError):
tools["read_bundle"].func(bundle_id="explore-mount-name")
opened = tools["read_bundle"].func(bundle_id=_DECLARED)
assert opened and "refused" not in opened, "the declared id must OPEN the base"
# REWRITTEN, not weakened (F99-D3): the tool returns its refusal instead of raising it. Both
# halves stand -- the mount name does NOT open the base, and the answer is a refusal rather
# than a listing, which is what "two schemes" would have produced.
refusal = tools["read_bundle"].func(bundle_id="explore-mount-name")
assert "refused" in refusal and "directories" not in refusal
def test_read_bundle_refuses_a_base_that_cannot_say_what_it_is(tmp_path: Path) -> None:
@ -325,8 +329,12 @@ def test_read_bundle_refuses_a_base_that_cannot_say_what_it_is(tmp_path: Path) -
_declare(second, "corpus-beta")
tools = {t.name: t for t in explore.navigator_tools((str(base),))}
with pytest.raises(okf.BundleIdMismatch):
tools["read_bundle"].func(bundle_id="two-minds-explored")
# REWRITTEN, not weakened (F99-D3): the refusal is returned, and it is pinned to the KIND, so
# a base whose concepts name two corpora is still told apart from one that merely does not
# exist -- the distinction ``pytest.raises(okf.BundleIdMismatch)`` used to carry.
refusal = tools["read_bundle"].func(bundle_id="two-minds-explored")
assert refusal["refusal"] == okf.BundleIdMismatch.__name__
assert "directories" not in refusal
async def test_two_mounts_declaring_one_id_collide_in_the_dispatcher(tmp_path: Path) -> None:

View file

@ -42,7 +42,6 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from agent_framework import BaseChatClient
from portfolio_optimiser import okf
@ -333,8 +332,11 @@ async def test_a_foreign_dimension_document_is_neither_listed_nor_readable(tmp_p
assert _ASFALT_FILE not in listing, (
"a document from another dimension is still listed to the agents"
)
with pytest.raises(DimensionScopeRefused):
await _invoke(tools["read_file"], bundle_id=bundle_id, path=_ASFALT_FILE)
answer = await _invoke(tools["read_file"], bundle_id=bundle_id, path=_ASFALT_FILE)
assert answer.startswith(f"REFUSED ({DimensionScopeRefused.__name__})")
# F99-D3 returns the refusal, so §4.1a's property is asserted on the value: the reason
# travels, the out-of-scope document's bytes do not.
assert _ASFALT_SENTINEL not in answer
async def test_without_a_dimension_the_same_document_is_listed_and_readable(tmp_path) -> None:
@ -380,5 +382,6 @@ async def test_a_dimension_scoped_run_gives_the_debate_scoped_tools(tmp_path, mo
)
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
with pytest.raises(DimensionScopeRefused):
await _invoke(read_file, bundle_id=bundle_id, path=_ASFALT_FILE)
answer = await _invoke(read_file, bundle_id=bundle_id, path=_ASFALT_FILE)
assert answer.startswith(f"REFUSED ({DimensionScopeRefused.__name__})")
assert _ASFALT_SENTINEL not in answer

View file

@ -758,8 +758,13 @@ def test_an_unknown_knowledge_base_is_refused_by_name() -> None:
failed."`` and counts three of them as grounds to stop function calling for the whole request —
so the one thing this refusal knows and the model does not (which ids exist) never reached it.
The property T20 was written for is untouched and asserted here in both halves: the answer is
still a refusal, and it still names what IS configured. The ``navigator_tools`` half is what
keeps the change SCOPED those three tools still raise.
still a refusal, and it still names what IS configured.
REWRITTEN A SECOND TIME (F99-D3, order 20260908T195801Z), for the reason the note above gave
for the first rewrite: the three navigator tools no longer raise either, so the same
measurement now applies to them and the arm asserts the same two halves through their return
value. What is NOT relaxed is the class the arm is keyed on a failure nobody named still
propagates, gated by ``test_explore_read_tools_refusal``'s known-negative.
"""
validate = explore.quick_validate_tool(("/tmp/base-a",))
verdict = validate.func(bundle_id="base-b", proposal_json="{}")
@ -767,9 +772,8 @@ def test_an_unknown_knowledge_base_is_refused_by_name() -> None:
assert "base-b" in verdict["reason"] and "base-a" in verdict["reason"]
read_file = next(t for t in explore.navigator_tools(("/tmp/base-a",)) if t.name == "read_file")
with pytest.raises(explore.ExplorationError) as excinfo:
read_file.func(bundle_id="base-b", path="x.md")
assert "base-a" in str(excinfo.value)
answer = read_file.func(bundle_id="base-b", path="x.md")
assert answer.startswith("REFUSED (") and "base-a" in answer
def test_two_bases_with_the_same_name_are_refused() -> None:

View file

@ -0,0 +1,184 @@
"""``read_file`` / ``read_dir`` / ``read_bundle`` RETURN their refusal; they no longer raise.
**F99-D3.** Finding 99 closed exactly one half of this and said so
(``docs/2026-09-08-funn-99-chatclient-refusal.md`` lines 132-135): ``quick_validate`` was made to
return ``{"decision": "refused", ...}``, while "``read_file`` / ``read_dir`` / ``read_bundle``
raiser fortsatt, og deres raises telles nøyaktig samme måte ... **MÅLT, RAPPORTERT, IKKE
FIKSET.**" The trio measured immediately before the live 400 was three ``read_file`` refusals on
``del-ii-bilag-7-prisskjema*`` -- the same document findings 4 and 5 of this order are about.
**Why a raise is worse than useless here, MEASURED in the framework's own source.** MAF turns a
tool's exception into the opaque string ``"Error: Function failed."``
(``agent_framework/_tools.py:1410-1432``), suppressing the detail unless ``include_detailed_errors``
is set (``:1427``), and counts it against ``DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST = 3``
(``:96``, ``:2718-2731``). So everything the refusing arm KNOWS -- which base ids exist, which
document the path really names, which rung reads it -- is destroyed on the way out, and three of
them end the request. The model is left guessing, which is what finding 99 recorded it doing in its
own words.
**The gates themselves are UNCHANGED.** This is the shape of the answer, never its content: the
verdict layer and the §4.1a dimension scope refuse exactly what they refused before, and the
property they exist for -- **the bytes never reach the model** -- is asserted explicitly on the
returned value, not assumed from the fact that an exception used to be raised.
**Form per tool, and the difference is forced by the return type.** ``read_bundle`` and ``read_dir``
return ``dict``, so the refusal is a mapping with no key a successful listing has. ``read_file``
returns ``str``: making it a mapping would change the shape of every SUCCESSFUL read and therefore
every prompt byte S2c measured, so the refusal is a string with a fixed leading sentinel. A
``str`` refusal a caller cannot tell from a document would be worthless, and the honesty limit --
a document whose first characters happen to be that sentinel -- is stated in the docstring rather
than pretended away.
**Keyed on the class, never on bare ``Exception``.** The known-negative below raises a plain
``RuntimeError`` from inside a tool body and requires it to propagate: we are not in the business of
hiding failures we did not name, and ``ExplorationError`` being itself a ``RuntimeError`` subclass
is exactly why the arm has to be a list of names rather than a base class.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import okf
from portfolio_optimiser.explore import navigator_tools
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_VERDICT_FILE = "verdict-led-fro.md"
_CONCEPT_FILE = "metode-ipmvp-a.md"
_SENTINEL = "READ-TOOL-LEAK-SENTINEL-z9y8x7"
def _tools(bundle_dir: str | Path, dimension: str | None = None) -> dict[str, Any]:
return {t.name: t for t in navigator_tools([str(bundle_dir)], dimension=dimension)}
def _marked_copy(tmp_path: Path, target: str) -> Path:
"""A copy of the fixture base with ``_SENTINEL`` appended to one document's body, so a leak of
that document's CONTENT into a refusal is detectable by a string that exists nowhere else."""
copy = tmp_path / "bundle"
shutil.copytree(BUNDLE_DIR, copy)
doc = copy / target
doc.write_text(doc.read_text(encoding="utf-8") + f"\n{_SENTINEL}\n", encoding="utf-8")
return copy
# --- (1) the unknown base id: the trio measured in front of the live 400 ------------------------
def test_an_unknown_base_is_returned_as_a_refusal_by_all_three_tools() -> None:
"""(1) The measured case. All three name the configured ids, so the model can correct itself
instead of receiving ``"Error: Function failed."`` three times and ending the request."""
tools = _tools(BUNDLE_DIR)
guessed = "renholdstekniske_funksjonskrav"
listing = tools["read_bundle"].func(bundle_id=guessed)
assert isinstance(listing, dict) and "refused" in listing
assert BUNDLE_DIR.name in listing["refused"]
directory = tools["read_dir"].func(bundle_id=guessed, path="x")
assert isinstance(directory, dict) and "refused" in directory
assert BUNDLE_DIR.name in directory["refused"]
document = tools["read_file"].func(bundle_id=guessed, path="index.md")
assert isinstance(document, str) and document.startswith("REFUSED")
assert BUNDLE_DIR.name in document
# --- (2) the verdict layer: the reason travels, the bytes never do ------------------------------
def test_the_verdict_layer_refusal_carries_the_reason_and_never_the_content(
tmp_path: Path,
) -> None:
"""(2) The gate is unchanged; only the shape of the answer is. The assertion the order requires
is the second one: a returned refusal must not become a channel for the bytes the gate exists
to withhold."""
base = _marked_copy(tmp_path, _VERDICT_FILE)
answer = _tools(base)["read_file"].func(bundle_id=base.name, path=_VERDICT_FILE)
assert isinstance(answer, str) and answer.startswith("REFUSED")
assert "verdict" in answer.lower()
assert _SENTINEL not in answer, "the refusal leaked the document it refused"
def test_an_ordinary_document_in_the_same_base_still_reads_whole(tmp_path: Path) -> None:
"""(2) CONTROL. Without this, arm (2) is satisfied by a tool that refuses everything."""
base = _marked_copy(tmp_path, _CONCEPT_FILE)
body = _tools(base)["read_file"].func(bundle_id=base.name, path=_CONCEPT_FILE)
assert _SENTINEL in body
assert not body.startswith("REFUSED")
# --- (3) the §4.1a dimension scope --------------------------------------------------------------
def test_the_dimension_refusal_carries_the_reason_and_never_the_content(tmp_path: Path) -> None:
"""(3) The other gate this change touches. Same two halves: the reason travels, the body does
not."""
base = _marked_copy(tmp_path, _CONCEPT_FILE)
foreign = base / "fremmed-dimensjon.md"
foreign.write_text(
f"---\ntype: methodology\ntitle: Asfalt\ndimension: asfalt\n---\n\n{_SENTINEL}\n",
encoding="utf-8",
)
index = base / "index.md"
index.write_text(
index.read_text(encoding="utf-8") + "\n- [Asfalt](fremmed-dimensjon.md)\n", encoding="utf-8"
)
answer = _tools(base, dimension="tunnel")["read_file"].func(
bundle_id=base.name, path="fremmed-dimensjon.md"
)
assert isinstance(answer, str) and answer.startswith("REFUSED")
assert "tunnel" in answer
assert _SENTINEL not in answer, "the refusal leaked the out-of-scope document"
# --- (4) the wrong rung, both directions --------------------------------------------------------
def test_the_wrong_rung_is_returned_by_both_tools() -> None:
"""(4) F3's asymmetry, preserved through the change: each refusal still names the rung that
WOULD read the thing, and the two branches still share no wording."""
tools = _tools(BUNDLE_DIR)
as_document = tools["read_dir"].func(bundle_id=BUNDLE_DIR.name, path=_CONCEPT_FILE)
assert "refused" in as_document and "read_file" in as_document["refused"]
unknown = tools["read_dir"].func(bundle_id=BUNDLE_DIR.name, path="kategori-99")
assert "refused" in unknown and "read_file" not in unknown["refused"]
assert unknown["refusal"] != as_document["refusal"], (
"a wrong rung and a path that does not exist are different facts; collapsing them into one "
"kind leaves a caller unable to tell 'use the other tool' from 'this does not exist'"
)
# --- (5) KNOWN-NEGATIVE: an unnamed failure still propagates ------------------------------------
def test_a_plain_runtime_error_still_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
"""(5) KNOWN-NEGATIVE, and the reason the arm is a list of NAMES. ``ExplorationError`` is itself
a ``RuntimeError`` subclass, so an arm written as ``except RuntimeError`` would swallow this
one too -- turning an unknown failure into a confident-looking answer, which is worse than the
opaque string this change removes."""
tools = _tools(BUNDLE_DIR)
def boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("something we never named")
monkeypatch.setattr(okf, "navigate_bundle", boom)
with pytest.raises(RuntimeError, match="something we never named"):
tools["read_bundle"].func(bundle_id=BUNDLE_DIR.name)
with pytest.raises(RuntimeError, match="something we never named"):
tools["read_dir"].func(bundle_id=BUNDLE_DIR.name, path="x")
# --- (6) a successful answer can never be mistaken for a refusal --------------------------------
def test_a_successful_listing_carries_no_refusal_key() -> None:
"""(6) The other half of "a refusal a caller cannot tell from an answer is worthless"."""
listing = _tools(BUNDLE_DIR)["read_bundle"].func(bundle_id=BUNDLE_DIR.name)
assert "refused" not in listing and "refusal" not in listing
assert listing["documents"]

View file

@ -54,7 +54,6 @@ import json
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import explore, okf
from portfolio_optimiser.explore import navigator_tools
@ -269,11 +268,18 @@ def test_an_unknown_directory_is_refused_by_name(tmp_path: Path) -> None:
Pinned to ``BundlePathNotFound`` by NAME since F3 added its sibling: both refusals quote the
caller's path, so a match on the path alone could no longer tell "this is nothing" from "this
is a document" - the substring two branches share."""
is a document" - the substring two branches share.
REWRITTEN, not weakened (F99-D3): the tool RETURNS the refusal instead of raising it, so the
name it is pinned to is the ``refusal`` kind. Both halves stand - the path is quoted, and the
kind still separates it from ``DocumentPathRefused``."""
base = _write_tree(tmp_path, "korpus", dirs=2, per_dir=2)
with pytest.raises(okf.BundlePathNotFound, match="kategori-99"):
_read_dir(base, "kategori-99")
refusal = _read_dir(base, "kategori-99")
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
assert refusal["refusal"] != okf.DocumentPathRefused.__name__
assert "kategori-99" in refusal["refused"]
# --- (h) the description must not lie about the body ----------------------------------------------

View file

@ -0,0 +1,179 @@
"""A spreadsheet render's PADDING is dropped on the way into the prompt; nothing else is.
**Finding 5 of the S7 acid test** (``docs/2026-09-07-syretest-s7-prepass-k2.md`` § 10 pkt. 5), after
S7c had already felled the obvious cure. Opening both locks DID deliver K2's price schedule -- it
came back at rank 10 and its bytes stood in 2 of 11 prompts -- and the model still cited
``5647500`` / ``prissammenstilling`` / ``prisskjema`` / ``pris`` in **0 of 4** and **0 of 11**
answers (``docs/2026-09-08-syretest-s7c-begge-laaser-k2.md`` § 5). The blind spot had moved off the
ranking and onto the READING, and what po owns there is the rendering of the excerpt that reaches
the prompt.
**MEASURED FIRST, on the actual rendering path** (``prepass.concept_text``, the local re-derivation
that ``verify_against_bundle`` binds a payload to -- not the raw file), against
``~/repos/portfolio-optimiser/scratchpad/s7-prepass/k2-bundle-s7/del-ii-bilag-7-prisskjema/prissammenstilling-sheet-1.md``:
* raw on disk 119 lines / 101 188 B; delivered excerpt **104 lines / 67 245 characters** -- which
reconciles the two denominators the order carried: the same document, before and after the
frontmatter split and the per-line ``rstrip`` the producer does.
* the delivered text still holds **208** interior whitespace runs of >= 2 characters, **117** of
them >= 100 and the longest **887**;
* those >= 100 runs are **56 806 of 67 245 characters = 84.5 %** of the excerpt, spread over
**72 of 104 lines**.
So a label and its amount reach the model hundreds of characters apart -- ``82`` then 593 spaces
then ``Prosjektering (tiltransport av prosjekterende)`` then 250 spaces then ``5647500.0`` -- and
84.5 % of what the run pays for is a column width pandoc chose.
**The collapse is DETERMINISTIC and carries no judgement**, and each half of that is gated below:
* interior runs of spaces/tabs collapse to ONE space -- K = 2, i.e. there is no threshold to argue
about; "two or more" is the whole rule.
* LEADING whitespace is untouched. Indentation is markdown structure (nested lists, indented code),
not padding, and a rule that flattened it would rewrite documents rather than unpad them.
* no newline is touched, so the line count is invariant and no row is merged with its neighbour.
* no non-whitespace character is touched, so no number can change -- asserted as the per-line
non-whitespace sequence, not merely as "the digits are still somewhere".
**WHERE it lives is the load-bearing choice.** It is in ``_data_blocks`` -- the ONE renderer both
arms share (-(p)) -- and therefore AFTER ``verify_against_bundle``. ``concept_text`` is the
re-derivation a payload's bytes are checked against, so collapsing there would make every payload
ever written fail its own digest, and would defeat the gate that stops a payload delivering bytes
the base does not hold. Arm (f) is that separation.
**Scope limit, stated.** That a model then USES the numbers cannot be shown here -- it would take a
paid run, and none is ordered. What is delivered is the deterministic property; a paid confirmation
is a later, separate order.
**Honesty limit, stated.** Collapsing to a single space loses the CELL BOUNDARY: after the collapse
``Post SUM`` is indistinguishable from prose that happens to contain those two words. That is a
real loss. Inventing a delimiter to carry the boundary would be exactly the judgement this rule is
written to avoid, and no measurement here says which delimiter a model reads better; so the loss is
recorded rather than papered over.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from portfolio_optimiser import prepass
FIXTURE = Path(__file__).parent / "fixtures" / "prepass" / "bygg-energi-mikro-fixture.payload.json"
#: A K2-shaped row: code, 593 spaces, description, 250 spaces, amount. The widths are the ones
#: measured on line 34 of the real price schedule, so the fixture is the measurement rather than a
#: round number chosen for the test.
_CODE = "82"
_DESC = "Prosjektering (tiltransport av prosjekterende)"
_AMOUNT = "5647500.0"
_PADDED_ROW = f" {_CODE}{' ' * 593}{_DESC}{' ' * 250}{_AMOUNT}"
#: An indented fenced block: leading whitespace is structure and must survive untouched.
_INDENTED = " indented code line"
_PADDED_TEXT = "\n".join(["## Prissammenstilling", "", _PADDED_ROW, _INDENTED, ""])
def _payload_with(text: str) -> prepass.PrepassPayload:
raw: dict[str, Any] = json.loads(FIXTURE.read_text(encoding="utf-8"))
raw["excerpts"] = [raw["excerpts"][0]]
raw["excerpts"][0]["text"] = text
return prepass.PrepassPayload.model_validate(raw)
def _longest_interior_run(text: str) -> int:
"""The longest run of horizontal whitespace that is NOT a line's leading indentation."""
longest = 0
for line in text.split("\n"):
body = line.lstrip(" \t")
for match in re.finditer(r"[ \t]{2,}", body):
longest = max(longest, len(match.group(0)))
return longest
# --- (a) the delivered excerpt reaches the prompt with a BOUNDED separator ---------------------
def test_the_rendered_excerpt_separates_label_and_amount_by_a_bounded_separator() -> None:
"""(a) The property finding 5 is about. Detach point: remove the collapse from
``_data_blocks`` -> RED, because the 593- and 250-character runs ride into the prompt."""
rendered = prepass.render_context(_payload_with(_PADDED_TEXT))
assert _longest_interior_run(rendered) <= 1, (
"the rendered excerpt still carries a padding run; a label and its amount reach the "
"model hundreds of characters apart"
)
assert f"{_CODE} {_DESC} {_AMOUNT}" in rendered, (
"the row's three cells no longer read as one row"
)
def test_a_text_that_needs_no_collapse_renders_unchanged() -> None:
"""(a) CONTROL. An excerpt with no padding must render exactly as before, or arm (a) would be
satisfied by a renderer that mangles every payload equally."""
plain = "## Heading\n\nOne ordinary sentence with single spaces.\n"
assert plain in prepass.render_context(_payload_with(plain))
# --- (b) known-negative: no number, and no other character, is ever changed --------------------
def test_no_non_whitespace_character_is_changed() -> None:
"""(b) KNOWN-NEGATIVE. The per-line sequence of non-whitespace characters is identical before
and after. Stronger than "the digits are still somewhere": a rule that stripped a trailing
``.0``, or reordered two cells, would pass a looser assertion and fail this one."""
collapsed = prepass.collapse_padding(_PADDED_TEXT)
for before, after in zip(_PADDED_TEXT.split("\n"), collapsed.split("\n"), strict=True):
assert re.sub(r"\s+", "", before) == re.sub(r"\s+", "", after)
assert _AMOUNT in collapsed
# --- (c) known-negative: no line is merged with its neighbour ----------------------------------
def test_no_line_is_merged_with_its_neighbour() -> None:
"""(c) KNOWN-NEGATIVE. Newlines are not whitespace this rule may touch: merging rows is how a
naive ``re.sub(r"\\s+", " ", text)`` would silently turn a table into a paragraph."""
collapsed = prepass.collapse_padding(_PADDED_TEXT)
assert collapsed.split("\n") != []
assert len(collapsed.split("\n")) == len(_PADDED_TEXT.split("\n"))
# --- (d) leading indentation is structure, not padding -----------------------------------------
def test_leading_indentation_survives() -> None:
"""(d) An indented line keeps its indentation: markdown reads it as structure, and collapsing
it would rewrite the document rather than unpad it."""
collapsed = prepass.collapse_padding(_PADDED_TEXT)
assert _INDENTED in collapsed.split("\n")
# --- (e) ONE seam, both arms --------------------------------------------------------------------
def test_both_arms_collapse_because_they_share_one_renderer() -> None:
"""(e) ``render_seed`` is the exploration's arm and ``render_context`` the debate's; they share
``_data_blocks``. A fix in one only would leave the other reading padding."""
payload = _payload_with(_PADDED_TEXT)
for rendered in (prepass.render_context(payload), prepass.render_seed(payload)):
assert _longest_interior_run(rendered) <= 1
# --- (f) the gate is untouched: verification reads the UNCOLLAPSED text -------------------------
def test_the_local_re_derivation_is_not_collapsed() -> None:
"""(f) ``concept_text`` is what ``verify_against_bundle`` binds a payload's bytes to. Collapsing
THERE would make every payload ever written fail its own digest and would break the gate that
stops a payload delivering bytes the base does not hold. Detach point: move the collapse into
``concept_text`` -> RED."""
padded = Path(__file__).parent / "fixtures" / "prepass" / "_padded-concept.md"
padded.parent.mkdir(parents=True, exist_ok=True)
padded.write_text(f"---\ntype: reference\n---\n\n{_PADDED_ROW}\n", encoding="utf-8")
try:
derived = prepass.concept_text(padded)
assert " " * 593 in derived, (
"the local re-derivation collapsed padding; a payload's own digest can no longer match"
)
finally:
padded.unlink()

View file

@ -37,7 +37,6 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import okf
from portfolio_optimiser.explore import navigator_tools
@ -106,10 +105,10 @@ def test_read_dir_on_a_documents_exact_name_names_read_file_and_the_path(tmp_pat
refusal that only says "no" leaves the caller with the same next move it just made."""
base = _base(tmp_path)
with pytest.raises(okf.DocumentPathRefused) as excinfo:
_read_dir(base, f"{_ALPHA}.md")
refusal = _read_dir(base, f"{_ALPHA}.md")
message = str(excinfo.value)
assert refusal["refusal"] == okf.DocumentPathRefused.__name__
message = refusal["refused"]
assert "read_file" in message, "the refusal does not name the rung that reads a document"
assert f"{_ALPHA}.md" in message, "the refusal does not name the path read_file would take"
@ -119,10 +118,10 @@ def test_the_suffix_is_not_what_makes_it_a_document(tmp_path: Path) -> None:
``.md`` form would answer one of them and not the other."""
base = _base(tmp_path)
with pytest.raises(okf.DocumentPathRefused) as excinfo:
_read_dir(base, _ALPHA)
refusal = _read_dir(base, _ALPHA)
message = str(excinfo.value)
assert refusal["refusal"] == okf.DocumentPathRefused.__name__
message = refusal["refused"]
assert "read_file" in message
assert f"{_ALPHA}.md" in message, (
"the refusal echoed the caller's path instead of the document's real name; a path that "
@ -138,15 +137,18 @@ def test_an_unknown_path_keeps_its_own_wording(tmp_path: Path) -> None:
unknown-path branch is unchanged and must stay unable to claim a document exists."""
base = _base(tmp_path)
with pytest.raises(okf.BundlePathNotFound) as excinfo:
_read_dir(base, "kategori-99")
refusal = _read_dir(base, "kategori-99")
message = str(excinfo.value)
message = refusal["refused"]
assert "read_file" not in message, (
"the unknown-path refusal names read_file, so the two branches say the same thing about "
"two different facts"
)
assert not isinstance(excinfo.value, okf.DocumentPathRefused)
# F99-D3: the class distinction that used to be observable through ``isinstance`` is carried
# by the ``refusal`` KIND now that the refusal is returned rather than raised. Dropping it
# would have been a silent weakening of exactly this assertion.
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
assert refusal["refusal"] != okf.DocumentPathRefused.__name__
# --- (d) anti-vacuity: the named path must WORK ---------------------------------------------------
@ -157,9 +159,7 @@ def test_the_named_path_is_usable_verbatim(tmp_path: Path) -> None:
that does not resolve is worse than none. Proven by feeding it back."""
base = _base(tmp_path)
with pytest.raises(okf.DocumentPathRefused) as excinfo:
_read_dir(base, _ALPHA)
named = str(excinfo.value).split("'")[-2]
named = _read_dir(base, _ALPHA)["refused"].split("'")[-2]
assert "alpha body." in _read_file(base, named)
@ -173,11 +173,10 @@ def test_the_verdict_layer_is_never_named(tmp_path: Path) -> None:
``read_file`` refuses outright (order 20260904T172353Z)."""
base = _base(tmp_path)
with pytest.raises(okf.BundlePathNotFound) as excinfo:
_read_dir(base, "dom-beta.md")
refusal = _read_dir(base, "dom-beta.md")
assert "read_file" not in str(excinfo.value)
assert not isinstance(excinfo.value, okf.DocumentPathRefused)
assert "read_file" not in refusal["refused"]
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
def test_a_foreign_dimension_document_is_never_named(tmp_path: Path) -> None:
@ -185,14 +184,13 @@ def test_a_foreign_dimension_document_is_never_named(tmp_path: Path) -> None:
name only" S2c measured. ONE predicate (``in_dimension``) serves the listing and this."""
base = _base(tmp_path)
with pytest.raises(okf.BundlePathNotFound) as excinfo:
_read_dir(base, "energi-notat.md", dimension="tunnel")
scoped = _read_dir(base, "energi-notat.md", dimension="tunnel")
assert "read_file" not in str(excinfo.value)
assert "read_file" not in scoped["refused"]
assert scoped["refusal"] == okf.BundlePathNotFound.__name__
# The control: without a scope the SAME path is the wrong-rung class, so the arm above is the
# dimension deciding rather than the document being invisible.
with pytest.raises(okf.DocumentPathRefused):
_read_dir(base, "energi-notat.md")
assert _read_dir(base, "energi-notat.md")["refusal"] == okf.DocumentPathRefused.__name__
# --- (g) the LIVE case is not this class ----------------------------------------------------------
@ -207,11 +205,10 @@ def test_the_measured_live_path_is_still_the_unknown_class(tmp_path: Path) -> No
base = _base(tmp_path)
assert (base / f"{_GAMMA}.md").exists(), "the fixture must hold the prefixed document"
with pytest.raises(okf.BundlePathNotFound) as excinfo:
_read_dir(base, _GAMMA.removeprefix("inbox-"))
refusal = _read_dir(base, _GAMMA.removeprefix("inbox-"))
assert "read_file" not in str(excinfo.value)
assert not isinstance(excinfo.value, okf.DocumentPathRefused)
assert "read_file" not in refusal["refused"]
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
# --- (h) the channel ------------------------------------------------------------------------------

View file

@ -72,9 +72,10 @@ def _a_directory(bundle_dir: Path) -> str:
def test_a_directory_path_is_refused_and_the_other_rung_is_named() -> None:
"""(1) the refusal says WHAT the path is and WHICH tool reads it."""
path = _a_directory(_NESTED)
with pytest.raises(DirectoryPathRefused) as excinfo:
_tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
message = str(excinfo.value)
message = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
assert message.startswith(f"REFUSED ({DirectoryPathRefused.__name__})"), (
"the refusal is not distinguishable from a document's body"
)
assert path in message
assert "directory" in message
assert "read_dir" in message, "a refusal that does not name the other rung strands the caller"
@ -88,9 +89,14 @@ def test_the_refusal_is_a_value_error_not_an_os_error() -> None:
wrapper: re-raising ``IsADirectoryError`` with a better message would pass arm (1).
"""
path = _a_directory(_NESTED)
with pytest.raises(ValueError) as excinfo:
_tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
assert not isinstance(excinfo.value, OSError)
answer = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
# F99-D3: the refusal is RETURNED now, so the branch is identified by the kind it names. The
# channel assertion moves onto the class itself and keeps both halves: it is on the CLI's
# refusal tuple, and it is NOT a re-raised ``IsADirectoryError`` wearing a better message --
# an ``OSError`` is outside ``_RETURNABLE_REFUSALS`` and would propagate rather than answer.
assert answer.startswith(f"REFUSED ({DirectoryPathRefused.__name__})")
assert issubclass(DirectoryPathRefused, ValueError)
assert not issubclass(DirectoryPathRefused, OSError)
async def test_the_refusal_holds_through_the_real_tool_surface() -> None:
@ -100,8 +106,9 @@ async def test_the_refusal_holds_through_the_real_tool_surface() -> None:
would be absent from the one caller it was written for.
"""
path = _a_directory(_NESTED)
with pytest.raises(DirectoryPathRefused):
await _invoke(_tools(_NESTED)["read_file"], bundle_id=_NESTED.name, path=path)
answer = await _invoke(_tools(_NESTED)["read_file"], bundle_id=_NESTED.name, path=path)
assert answer.startswith(f"REFUSED ({DirectoryPathRefused.__name__})")
assert "read_dir" in answer
def test_a_listing_already_separates_directories_from_documents() -> None:

View file

@ -42,7 +42,6 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from agent_framework import BaseChatClient
from portfolio_optimiser import okf
@ -155,8 +154,11 @@ async def test_the_exploration_path_refuses_a_verdict_document(tmp_path) -> None
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
tools = _tools(bundle_dir)
with pytest.raises(VerdictLayerRefused):
await _invoke(tools["read_file"], bundle_id=bundle_id, path=_VERDICT_FILE)
answer = await _invoke(tools["read_file"], bundle_id=bundle_id, path=_VERDICT_FILE)
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
# F99-D3 made the refusal a RETURN VALUE, so the property the gate exists for has to be
# asserted on that value: the reason travels, the bytes never do.
assert _VERDICT_SENTINEL not in answer
async def test_an_ordinary_document_in_the_same_base_still_reads(tmp_path) -> None:
@ -215,8 +217,9 @@ async def test_the_debate_path_refuses_a_verdict_document(tmp_path, monkeypatch)
assert captured, "the debate was never built"
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
with pytest.raises(VerdictLayerRefused):
await _invoke(read_file, bundle_id=bundle_id, path=_VERDICT_FILE)
answer = await _invoke(read_file, bundle_id=bundle_id, path=_VERDICT_FILE)
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
assert _VERDICT_SENTINEL not in answer
# --------------------------------------------------------------- (3) the gated route survives
@ -324,10 +327,11 @@ async def test_an_unlinked_verdict_is_refused_too(tmp_path) -> None:
assert not any(f.name == "verdict-orphan.md" for f in okf.navigate_bundle(bundle_dir).files), (
"precondition: the orphan must be OUTSIDE the walk, or this arm proves nothing"
)
with pytest.raises(VerdictLayerRefused):
await _invoke(
_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path="verdict-orphan.md"
)
answer = await _invoke(
_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path="verdict-orphan.md"
)
assert answer.startswith(f"REFUSED ({VerdictLayerRefused.__name__})")
assert _VERDICT_SENTINEL not in answer
# ------------------------------------------------------------------------ (6) ladder intact