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:
parent
078a099898
commit
eb4137415c
13 changed files with 975 additions and 69 deletions
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -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
|
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
|
«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.
|
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
|
- **Eksperten kan svare på forslaget som ligger på BORDET — og svaret BRUKES, det registreres ikke
|
||||||
bare (MAJOR-2, økt 88–89):** før dette fantes ÉN menneske-søm inne i løkka, `plan_reviewer`, og
|
bare (MAJOR-2, økt 88–89):** 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`
|
den fyrer FØR noen hypotese finnes. **Nevneren er målt** (`docs/2026-09-02-misjonsreview-v2.md`
|
||||||
|
|
|
||||||
333
docs/2026-09-08-funn-4-5-og-read-nekt.md
Normal file
333
docs/2026-09-08-funn-4-5-og-read-nekt.md
Normal 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 36–39
|
||||||
|
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 2–4) | 28 |
|
||||||
|
| bærer et beløp (kol 5–7) | 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 (W1–W5 + M1–M8), 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.
|
||||||
|
|
@ -927,6 +927,58 @@ def _index_excerpt(body: str) -> tuple[str, bool]:
|
||||||
return (body[:cut] if cut > 0 else body[:_CATALOGUE_EXCERPT_CHARS]), True
|
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(
|
def navigator_tools(
|
||||||
bundle_dirs: Sequence[str], *, dimension: str | None = None
|
bundle_dirs: Sequence[str], *, dimension: str | None = None
|
||||||
) -> list[FunctionTool]:
|
) -> list[FunctionTool]:
|
||||||
|
|
@ -1022,6 +1074,12 @@ def navigator_tools(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def read_bundle(bundle_id: str) -> dict[str, Any]:
|
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_dir = _resolve_bundle(index, bundle_id)
|
||||||
bundle = okf.navigate_bundle(bundle_dir)
|
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
|
# 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]:
|
def read_dir(bundle_id: str, path: str) -> dict[str, Any]:
|
||||||
bundle_dir = _resolve_bundle(index, bundle_id)
|
try:
|
||||||
bundle = okf.navigate_bundle(bundle_dir)
|
bundle_dir = _resolve_bundle(index, bundle_id)
|
||||||
okf.assert_declared_ids_agree(bundle)
|
bundle = okf.navigate_bundle(bundle_dir)
|
||||||
return okf.directory_listing(bundle, path, dimension=dimension)
|
okf.assert_declared_ids_agree(bundle)
|
||||||
|
return okf.directory_listing(bundle, path, dimension=dimension)
|
||||||
|
except _RETURNABLE_REFUSALS as exc:
|
||||||
|
return _refused_mapping(exc)
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="read_file",
|
name="read_file",
|
||||||
description="Read ONE document inside a knowledge base, by base id and relative path.",
|
description="Read ONE document inside a knowledge base, by base id and relative path.",
|
||||||
)
|
)
|
||||||
def read_file(bundle_id: str, path: str) -> str:
|
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)
|
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
|
# 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
|
# model-chosen path is untrusted input by definition, so it goes through the same gate the
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
@ -574,14 +576,61 @@ def _excerpt_header(excerpt: PrepassExcerpt) -> str:
|
||||||
return f"--- BEGIN DATA {excerpt.concept_id} ({', '.join(fields)}) ---"
|
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]:
|
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 (kø-(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] = []
|
lines: list[str] = []
|
||||||
for excerpt in payload.excerpts:
|
for excerpt in payload.excerpts:
|
||||||
lines += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
_excerpt_header(excerpt),
|
_excerpt_header(excerpt),
|
||||||
excerpt.text,
|
collapse_padding(excerpt.text),
|
||||||
f"--- END DATA {excerpt.concept_id} ---",
|
f"--- END DATA {excerpt.concept_id} ---",
|
||||||
]
|
]
|
||||||
return lines
|
return lines
|
||||||
|
|
|
||||||
|
|
@ -311,9 +311,13 @@ def test_explore_addresses_a_base_by_its_declared_id(tmp_path: Path) -> None:
|
||||||
catalogue = tools["list_bundles"].func()
|
catalogue = tools["list_bundles"].func()
|
||||||
|
|
||||||
assert [entry["id"] for entry in catalogue] == [_DECLARED]
|
assert [entry["id"] for entry in catalogue] == [_DECLARED]
|
||||||
assert tools["read_bundle"].func(bundle_id=_DECLARED), "the declared id must OPEN the base"
|
opened = tools["read_bundle"].func(bundle_id=_DECLARED)
|
||||||
with pytest.raises(explore.ExplorationError):
|
assert opened and "refused" not in opened, "the declared id must OPEN the base"
|
||||||
tools["read_bundle"].func(bundle_id="explore-mount-name")
|
# 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:
|
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")
|
_declare(second, "corpus-beta")
|
||||||
|
|
||||||
tools = {t.name: t for t in explore.navigator_tools((str(base),))}
|
tools = {t.name: t for t in explore.navigator_tools((str(base),))}
|
||||||
with pytest.raises(okf.BundleIdMismatch):
|
# REWRITTEN, not weakened (F99-D3): the refusal is returned, and it is pinned to the KIND, so
|
||||||
tools["read_bundle"].func(bundle_id="two-minds-explored")
|
# 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:
|
async def test_two_mounts_declaring_one_id_collide_in_the_dispatcher(tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agent_framework import BaseChatClient
|
from agent_framework import BaseChatClient
|
||||||
|
|
||||||
from portfolio_optimiser import okf
|
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, (
|
assert _ASFALT_FILE not in listing, (
|
||||||
"a document from another dimension is still listed to the agents"
|
"a document from another dimension is still listed to the agents"
|
||||||
)
|
)
|
||||||
with pytest.raises(DimensionScopeRefused):
|
answer = await _invoke(tools["read_file"], bundle_id=bundle_id, path=_ASFALT_FILE)
|
||||||
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:
|
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")
|
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
|
||||||
with pytest.raises(DimensionScopeRefused):
|
answer = await _invoke(read_file, bundle_id=bundle_id, path=_ASFALT_FILE)
|
||||||
await _invoke(read_file, bundle_id=bundle_id, path=_ASFALT_FILE)
|
assert answer.startswith(f"REFUSED ({DimensionScopeRefused.__name__})")
|
||||||
|
assert _ASFALT_SENTINEL not in answer
|
||||||
|
|
|
||||||
|
|
@ -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 —
|
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.
|
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
|
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
|
still a refusal, and it still names what IS configured.
|
||||||
keeps the change SCOPED — those three tools still raise.
|
|
||||||
|
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",))
|
validate = explore.quick_validate_tool(("/tmp/base-a",))
|
||||||
verdict = validate.func(bundle_id="base-b", proposal_json="{}")
|
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"]
|
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")
|
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:
|
answer = read_file.func(bundle_id="base-b", path="x.md")
|
||||||
read_file.func(bundle_id="base-b", path="x.md")
|
assert answer.startswith("REFUSED (") and "base-a" in answer
|
||||||
assert "base-a" in str(excinfo.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_bases_with_the_same_name_are_refused() -> None:
|
def test_two_bases_with_the_same_name_are_refused() -> None:
|
||||||
|
|
|
||||||
184
tests/test_explore_read_tools_refusal.py
Normal file
184
tests/test_explore_read_tools_refusal.py
Normal 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 på 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"]
|
||||||
|
|
@ -54,7 +54,6 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from portfolio_optimiser import explore, okf
|
from portfolio_optimiser import explore, okf
|
||||||
from portfolio_optimiser.explore import navigator_tools
|
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
|
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
|
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)
|
base = _write_tree(tmp_path, "korpus", dirs=2, per_dir=2)
|
||||||
|
|
||||||
with pytest.raises(okf.BundlePathNotFound, match="kategori-99"):
|
refusal = _read_dir(base, "kategori-99")
|
||||||
_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 ----------------------------------------------
|
# --- (h) the description must not lie about the body ----------------------------------------------
|
||||||
|
|
|
||||||
179
tests/test_prepass_padding_collapse_loadbearing.py
Normal file
179
tests/test_prepass_padding_collapse_loadbearing.py
Normal 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 (kø-(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()
|
||||||
|
|
@ -37,7 +37,6 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from portfolio_optimiser import okf
|
from portfolio_optimiser import okf
|
||||||
from portfolio_optimiser.explore import navigator_tools
|
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."""
|
refusal that only says "no" leaves the caller with the same next move it just made."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.DocumentPathRefused) as excinfo:
|
refusal = _read_dir(base, f"{_ALPHA}.md")
|
||||||
_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 "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"
|
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."""
|
``.md`` form would answer one of them and not the other."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.DocumentPathRefused) as excinfo:
|
refusal = _read_dir(base, _ALPHA)
|
||||||
_read_dir(base, _ALPHA)
|
|
||||||
|
|
||||||
message = str(excinfo.value)
|
assert refusal["refusal"] == okf.DocumentPathRefused.__name__
|
||||||
|
message = refusal["refused"]
|
||||||
assert "read_file" in message
|
assert "read_file" in message
|
||||||
assert f"{_ALPHA}.md" 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 "
|
"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."""
|
unknown-path branch is unchanged and must stay unable to claim a document exists."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.BundlePathNotFound) as excinfo:
|
refusal = _read_dir(base, "kategori-99")
|
||||||
_read_dir(base, "kategori-99")
|
|
||||||
|
|
||||||
message = str(excinfo.value)
|
message = refusal["refused"]
|
||||||
assert "read_file" not in message, (
|
assert "read_file" not in message, (
|
||||||
"the unknown-path refusal names read_file, so the two branches say the same thing about "
|
"the unknown-path refusal names read_file, so the two branches say the same thing about "
|
||||||
"two different facts"
|
"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 ---------------------------------------------------
|
# --- (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."""
|
that does not resolve is worse than none. Proven by feeding it back."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.DocumentPathRefused) as excinfo:
|
named = _read_dir(base, _ALPHA)["refused"].split("'")[-2]
|
||||||
_read_dir(base, _ALPHA)
|
|
||||||
named = str(excinfo.value).split("'")[-2]
|
|
||||||
|
|
||||||
assert "alpha body." in _read_file(base, named)
|
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)."""
|
``read_file`` refuses outright (order 20260904T172353Z)."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.BundlePathNotFound) as excinfo:
|
refusal = _read_dir(base, "dom-beta.md")
|
||||||
_read_dir(base, "dom-beta.md")
|
|
||||||
|
|
||||||
assert "read_file" not in str(excinfo.value)
|
assert "read_file" not in refusal["refused"]
|
||||||
assert not isinstance(excinfo.value, okf.DocumentPathRefused)
|
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
|
||||||
|
|
||||||
|
|
||||||
def test_a_foreign_dimension_document_is_never_named(tmp_path: Path) -> None:
|
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."""
|
name only" S2c measured. ONE predicate (``in_dimension``) serves the listing and this."""
|
||||||
base = _base(tmp_path)
|
base = _base(tmp_path)
|
||||||
|
|
||||||
with pytest.raises(okf.BundlePathNotFound) as excinfo:
|
scoped = _read_dir(base, "energi-notat.md", dimension="tunnel")
|
||||||
_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
|
# 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.
|
# dimension deciding rather than the document being invisible.
|
||||||
with pytest.raises(okf.DocumentPathRefused):
|
assert _read_dir(base, "energi-notat.md")["refusal"] == okf.DocumentPathRefused.__name__
|
||||||
_read_dir(base, "energi-notat.md")
|
|
||||||
|
|
||||||
|
|
||||||
# --- (g) the LIVE case is not this class ----------------------------------------------------------
|
# --- (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)
|
base = _base(tmp_path)
|
||||||
assert (base / f"{_GAMMA}.md").exists(), "the fixture must hold the prefixed document"
|
assert (base / f"{_GAMMA}.md").exists(), "the fixture must hold the prefixed document"
|
||||||
|
|
||||||
with pytest.raises(okf.BundlePathNotFound) as excinfo:
|
refusal = _read_dir(base, _GAMMA.removeprefix("inbox-"))
|
||||||
_read_dir(base, _GAMMA.removeprefix("inbox-"))
|
|
||||||
|
|
||||||
assert "read_file" not in str(excinfo.value)
|
assert "read_file" not in refusal["refused"]
|
||||||
assert not isinstance(excinfo.value, okf.DocumentPathRefused)
|
assert refusal["refusal"] == okf.BundlePathNotFound.__name__
|
||||||
|
|
||||||
|
|
||||||
# --- (h) the channel ------------------------------------------------------------------------------
|
# --- (h) the channel ------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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."""
|
"""(1) the refusal says WHAT the path is and WHICH tool reads it."""
|
||||||
path = _a_directory(_NESTED)
|
path = _a_directory(_NESTED)
|
||||||
with pytest.raises(DirectoryPathRefused) as excinfo:
|
message = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
||||||
_tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
assert message.startswith(f"REFUSED ({DirectoryPathRefused.__name__})"), (
|
||||||
message = str(excinfo.value)
|
"the refusal is not distinguishable from a document's body"
|
||||||
|
)
|
||||||
assert path in message
|
assert path in message
|
||||||
assert "directory" in message
|
assert "directory" in message
|
||||||
assert "read_dir" in message, "a refusal that does not name the other rung strands the caller"
|
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).
|
wrapper: re-raising ``IsADirectoryError`` with a better message would pass arm (1).
|
||||||
"""
|
"""
|
||||||
path = _a_directory(_NESTED)
|
path = _a_directory(_NESTED)
|
||||||
with pytest.raises(ValueError) as excinfo:
|
answer = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
||||||
_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
|
||||||
assert not isinstance(excinfo.value, OSError)
|
# 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:
|
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.
|
would be absent from the one caller it was written for.
|
||||||
"""
|
"""
|
||||||
path = _a_directory(_NESTED)
|
path = _a_directory(_NESTED)
|
||||||
with pytest.raises(DirectoryPathRefused):
|
answer = await _invoke(_tools(_NESTED)["read_file"], bundle_id=_NESTED.name, path=path)
|
||||||
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:
|
def test_a_listing_already_separates_directories_from_documents() -> None:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
|
||||||
from agent_framework import BaseChatClient
|
from agent_framework import BaseChatClient
|
||||||
|
|
||||||
from portfolio_optimiser import okf
|
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
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
||||||
tools = _tools(bundle_dir)
|
tools = _tools(bundle_dir)
|
||||||
|
|
||||||
with pytest.raises(VerdictLayerRefused):
|
answer = await _invoke(tools["read_file"], bundle_id=bundle_id, path=_VERDICT_FILE)
|
||||||
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:
|
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"
|
assert captured, "the debate was never built"
|
||||||
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
|
read_file = next(t for t in captured[0] if getattr(t, "name", "") == "read_file")
|
||||||
with pytest.raises(VerdictLayerRefused):
|
answer = await _invoke(read_file, bundle_id=bundle_id, path=_VERDICT_FILE)
|
||||||
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
|
# --------------------------------------------------------------- (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), (
|
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"
|
"precondition: the orphan must be OUTSIDE the walk, or this arm proves nothing"
|
||||||
)
|
)
|
||||||
with pytest.raises(VerdictLayerRefused):
|
answer = await _invoke(
|
||||||
await _invoke(
|
_tools(bundle_dir)["read_file"], bundle_id=bundle_id, path="verdict-orphan.md"
|
||||||
_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
|
# ------------------------------------------------------------------------ (6) ladder intact
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue