feat(explore): stigen faar sitt manglende trinn - read_bundle gir ETT nivaa, read_dir det neste

S7a-3 pkt. 2. MAJOR-3 bygde read_bundle om fra HELE basen til en oppfoering per
konseptfil. Saa kom det foerste ekte korpuset: K2 navigerer til 629 konsepter bak
478 nestede indekser, og en listing av 629 koster 42 761 o200k-tokens som rir i
7 av 12 prompter = 89 % av alle prompt-tokens. Bindingen holdt asymptotisk og
priset likevel hele korpuset. De 478 indeksene ble bygget, konsumert og flatet ut
- agenten saa 629 soesken og fikk aldri vite at korpuset hadde en form.

MAALT (BEFORE og AFTER i samme oekt, samme kode, BEFORE som mutasjon):
  read_bundle-nyttelast  110 581 tegn / 42 761 tok  ->  3 954 tegn / 1 495 tok
  listing-tokens totalt         307 573 (89 %)      ->  12 595 (26 %)
  prompt-tokens i kjoeringen         343 826        ->  49 225   (-86 %)
BEFORE reproduserer S7a-2s publiserte tall til 0,03 % - kjent-positiv kontroll
paa instrumentet, som ogsaa maatte rettes (resultatet baerer name=None, saa en
sonde nøklet paa verktoeynavn rapporterer 0 kopier og leses som en ekte null).

- okf.directory_listing er ENESTE renderer; begge verktoey ER den paa hvert sitt
  nivaa. Kataloger utledes av STIER, aldri av index.md. Bygget av context_files,
  ALDRI files. Hver sti er bundle-relativ, brukbar ordrett i neste kall.
- Ukjent sti NEKTES ved navn (BundlePathNotFound) - en tom listing er umulig aa
  skille fra en katalog som finnes og er tom.
- Verktoeybeskrivelsene og navigatoerinstruksjonen flyttet i SAMME commit.

PREMISS FELT FOER BYGGING: context_files har aldri holdt hierarkiet tilbake -
navnene er fulle bundle-relative stier; det var RENDERINGEN som flatet det ut.
Derfor er bundle_context og begge nav-goldenene byte-identiske, gratis.

AVVIK fra ordren, uttalt: K2 kan ikke vaere testavhengighet (utenfor repoet), og
1 500 tegn er ikke oppnaaelig for en rot med 39 identifiserbare oppfoeringer
(maalt 3 954). Gaten binder 1 500 tegn per listing over basene den KAN se, pluss
egenskapen, med en FLAT kontroll over 5x taket.

Load-bearing MAALT: 9 mutasjoner alle roede mot HELE suiten, groenn kontroll
1252 passed / 5 skipped, golden byte-uendret. N1 4 / N2 7 / N3 12 / N4 5 / N5 1 /
N6 6 / N7 1 / N8 2 / N9 1.

Maaling: docs/2026-09-03-hierarkisk-navigasjon-k2.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-03 07:38:30 +02:00
commit baa6f450e8
8 changed files with 584 additions and 40 deletions

View file

@ -1529,6 +1529,54 @@ Python ≥3.10. MAF (`agent-framework-core` 1.16.0, `-orchestrations` 1.1.1 —
rekkefølge, urørt); og dispatcherens «to baser deler id»-nekt er NYLIG NÅBAR — den krevde før to rekkefølge, urørt); og dispatcherens «to baser deler id»-nekt er NYLIG NÅBAR — den krevde før to
monteringer med samme basenavn, nå kolliderer to ULIKT navngitte kataloger som erklærer samme id. monteringer med samme basenavn, nå kolliderer to ULIKT navngitte kataloger som erklærer samme id.
Kontrakt: `docs/okf-konsum-kontrakter.md § 3.1`. Kontrakt: `docs/okf-konsum-kontrakter.md § 3.1`.
- **Stigen har tre trinn: `list_bundles``read_bundle``read_dir``read_file` (S7a-3 pkt. 2,
økt 81):** MAJOR-3 bygde `read_bundle` om fra HELE basen til én oppføring per konseptfil, og på de
tre eksempelbasene var det 77…91 %. Så kom det første EKTE korpuset: K2 navigerer til **629
konsepter bak 478 nestede indekser**, og en listing av 629 koster **42 761 o200k-tokens** som rir
i 7 av 12 prompter = **89 % av alle prompt-tokens**. Bindingen holdt asymptotisk og priset likevel
hele korpuset, fordi prisen på å finne ut HVA en base inneholder var satt av hvor mye den
inneholder. De 478 indeksene ble dessuten BYGGET av navigasjonen, KONSUMERT av den og så flatet
ut — agenten så 629 søsken og fikk aldri vite at korpuset hadde en form. **MÅLT ETTER:** rotnivået
er **3 954 tegn / 1 495 tok**, listing-tokens **307 573 → 12 595**, og utforskningens
prompt-tokens **343 826 → 49 225 (86 %)**. BEFORE og AFTER er kjørt i SAMME økt med samme kode
(BEFORE er en mutasjon), og BEFORE reproduserer S7a-2s publiserte tall til 0,03 % — den
kjent-positive kontrollen på instrumentet. **`okf.directory_listing` er ENESTE renderer** og
begge verktøy ER den på hvert sitt nivå; to kopier av en listing-regel ville drevet til to svar om
én base (kø-(p)). **ET PREMISS I ORDREN BLE FELT FØR NOE BLE BYGGET PÅ DET:** `context_files` har
ALDRI holdt hierarkiet tilbake — hver `BundleFile.name` er allerede den fulle bundle-relative
stien, så treet var utledbart fra navnene; det var RENDERINGEN som flatet det ut. Dét er grunnen
til at `bundle_context` og begge nav-goldenene er BYTE-IDENTISKE etter endringen (ordrens eget
krav), gratis og ikke av forsiktighet. **Kataloger utledes av STIER, aldri av `index.md`** (en
nestet indeks er navigasjon, ikke innhold), og listingen bygges av `context_files`, ALDRI `files`
— en katalog-TELLING fra `files` ville reklamert for dokumenter navigatøren ikke får se (N2 → 7
røde). **Hver sti er BUNDLE-RELATIV, altså brukbar ordrett som neste kalls argument** — en
nivå-relativ form måtte komponeres av en modell, og en sti som aldri fantes er verre enn ingen
sti (`_index_excerpt`-regelen, ett trinn opp; N7 → 1 rød). `documents` på en katalog-oppføring er
antallet i hele SUBTREET — hva subtreet holder, ikke hva ett `read_dir` returnerer — og
beskrivelsen sier hvilken av de to det er. Ukjent sti NEKTES ved navn (`BundlePathNotFound`,
`ValueError`-subklasse som `BundleIdMismatch`): en tom listing er umulig å skille fra en katalog
som finnes og er tom (N5 → 1 rød). **Verktøybeskrivelsene og navigatørens instruksjon flyttet i
SAMME commit** — en beskrivelse som lyver om kroppen ER modellens instruks (Fase 3-klassen; N9 →
1 rød). **TO EKSISTERENDE ASSERTS VILLE BLITT VAKUØSE I STILLHET** og er styrket: `len(payload)`
er nå antallet NØKLER (tre, alltid), så `test_read_bundle_cost_loadbearing`s størrelses-arm talte
nøkler, og write-frihets-armen i `test_explore_loadbearing` kalte ikke det nye verktøyet i det
hele tatt. **AVVIK fra ordren, uttalt og målt:** ordren binder «K2 < 1 500 tegn» — K2 kan ikke
være en testavhengighet (utenfor repoet; MAJOR-3-gaten har samme begrensning), og tallet er ikke
oppnåelig: rota bærer 39 identifiserbare oppføringer og måler 3 954 tegn. Gaten binder derfor
1 500 tegn per listing over basene den KAN se, pluss egenskapen som ga fallet (kostnaden følger
oppføringer på ETT nivå, ikke dokumenter i basen), med en FLAT kontroll over 5× taket — uten den
ville en grønn binding like gjerne betydd at fixturen var liten. Load-bearing MÅLT
(`tests/test_hierarchical_navigation_loadbearing.py`, 9 armer), **ni mutasjoner alle røde mot HELE
suiten** + grønn kontroll **1252/5** og golden BYTE-UENDRET (`shasum -a 1` =
`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): N1 reverter sømmen (4) · N2 bygg fra `files` (7) ·
N3 bundet men VAKUØST tom (12) · N4 dropp `chars` (5) · N5 ukjent sti blir en tom listing (1) ·
N6 `read_dir` ikke registrert (6) · N7 nivå-relative navn (1) · N8 katalog-tellingen konstant (2)
· N9 instruksjonen beskriver den gamle stigen (1). **Ærlighets-grenser, uttalt:** dette er ikke
«86 % for enhver kjøring» (en navigatør som stiger ned *k* nivåer betaler *k* listinger); at en
LEVENDE modell navigerer bedre med en struktur enn med 629 flate dokumenter er IKKE bevist
(structured-output-grensens klasse); multiplikatoren gjelder dette manuset; og `navigate_bundle`
kalles fortsatt per verktøykall (I/O, ikke tokens — ikke målt). Måling:
`docs/2026-09-03-hierarkisk-navigasjon-k2.md`.
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet. - **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase. - Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.

View file

@ -0,0 +1,103 @@
# Hierarkisk navigasjon — målt på den ekte K2-bundelen
**Dato:** 2026-09-03 · **Ordre:** `20260903T034323Z-7704222791` punkt 2 (S7a-3) · **Økt 81**
**Base:** `~/corpora/okf-telling-20260829/K2-bundle-20260903` — 629 konsepter, 478 nestede
indekser, 1 108 filer på disk. Åpnet **slik den er levert** (mulig først etter punkt 1 i samme
ordre; før det nektet `BundleIdMismatch` fordi katalogen heter noe annet enn den erklærte id-en).
## 1. Hva som ble målt, og med hvilket instrument
Instrumentet er S7a-2s rettede sonde: hver prompt telles som **tekst + `function_call` +
`function_result`**, aldri `Message.text` alene (som måler en kontekstbærende prompt til noen få
tegn). Tokenisering: `o200k_base` (`tiktoken`), som er en utviklingsavhengighet i målingen og
**ikke** i pakka.
**Instrumentfeil funnet og rettet under målingen** (samme klasse som S7a-2s midtskive-sonde):
verktøyresultatet bærer `name=None` på transporten, så en sonde som nøkler på
`result:read_bundle` rapporterer **0 kopier** — som er umulig å skille fra en ekte null.
Sonden nøkler nå på resultatets eget INNHOLD (`"directories"`), og BEFORE-kjøringen reproduserer
S7a-2s publiserte tall (307 573 mot 307 496 tokens, 0,03 % avvik), som er den kjent-positive
kontrollen på at instrumentet kan finne det det leter etter.
BEFORE og AFTER er kjørt **i samme økt med samme kode**, der BEFORE er en mutasjon som gir
`read_bundle` den flate MAJOR-3-formen tilbake (mutasjons-harness-idiomet), ikke en historisk
kjøring hentet fra en rapport.
## 2. Resultat
| | BEFORE (flat listing) | AFTER (ett nivå) |
|---|---|---|
| `read_bundle`-nyttelast alene | **110 581 tegn / 42 761 tok** (629 oppføringer) | **3 954 tegn / 1 495 tok** (28 kataloger + 11 dokumenter) |
| Kopier i prompter | 7 | 8 (rot) + 7 (én `read_dir`) |
| Listing-tokens totalt | **307 573** | **12 595** |
| Andel av alle prompt-tokens | **89 %** | **26 %** |
| Prompt-tokens i hele kjøringen | **343 826** | **49 225** |
| Antall prompter | 12 | 13 |
**Utforskningens prompt-tokens faller 86 %** (343 826 → 49 225). `read_bundle`-nyttelasten alene
faller **96,5 %** (42 761 → 1 495 tokens).
**Den 13. prompten er en ærlig kostnad, ikke støy:** navigatøren tar nå ett steg til (`read_dir`
ned i prisskjema-katalogen) før den leser et dokument. Stigen har fått et trinn, og et trinn
koster en tur.
## 3. Premisset som ble felt før noe ble bygget på det
Ordren sier at `context_files` «ikke lenger skal forkaste hierarkiet». **Den har aldri holdt det
tilbake.** Hver `BundleFile.name` er allerede den fulle bundle-relative posix-stien
(`del-ii-bilag-7-prisskjema/prissammenstilling-sheet-1.md`), så treet var alltid utledbart fra
navnene alene. Det som flatet det ut var **renderingen**.
Det er ikke pedanteri — det er grunnen til at `okf.bundle_context` og begge nav-goldenene er
**byte-identiske** etter endringen, som ordren krever eksplisitt. Egenskapen kom gratis, ikke av
forsiktighet.
Kataloger utledes derfor av STIER, aldri av `index.md`-filer: en nestet indeks er navigasjon, ikke
innhold (`context_files` dropper den på hvert nivå, og må fortsette med det, ellers følger
dom-laget med). Utledning fra navn betyr også at en katalog nådd via en lenke som hoppet over
indeksen fortsatt er synlig.
## 4. Avvik fra ordrens ordlyd, målt
Ordren formulerer taket som «`read_bundle` over K2-bundelen < 1 500 tegn». **Begge halvdeler av den
setningen holder ikke, og begge er målt:**
1. **K2 kan ikke være en testavhengighet.** Basen ligger utenfor repoet (`~/corpora/`). MAJOR-3-gaten
hadde samme begrensning og løste den likt: gaten binder den største basen som faktisk shippes,
og K2-tallene bor i en rapport. Gaten her binder derfor `shared/examples/tunnel-hauglia` (flat),
`shared/examples/nav-golden-hierarchy/bundle` (nestet) og en syntetisk base på 242 konsepter bak
20 kataloger — der den FLATE formen er over 5× taket den nivå-formen ligger godt innenfor.
2. **1 500 tegn er ikke oppnåelig for K2s rotnivå, og skal ikke være det.** Rota bærer **39
identifiserbare oppføringer** (28 kataloger + 11 toppdokumenter) og måler **3 954 tegn ≈ 1 366
o200k-tokens** (2,89 tegn/token). Å presse den under 1 500 tegn ville krevd at titler eller
oppføringer ble droppet — repoets vakuøs-gate-klasse. Taket i testen er 1 500 TEGN per listing
over de basene gaten kan se, og egenskapen som produserte fallet er gatet direkte (kostnaden
følger oppføringer på ETT nivå, ikke dokumenter i basen).
## 5. Ærlighets-grenser
- **Dette er ikke «86 % kostnad» for enhver kjøring.** En navigatør som stiger ned *k* nivåer
betaler *k* listinger; gevinsten er at den betaler for det den VALGTE, og at hver listing rir fra
SITT kall og framover.
- **At en LEVENDE modell navigerer BEDRE med en katalogstruktur enn med 629 flate dokumenter er
IKKE bevist** (samme klasse som structured-output-grensen). Manuset er skriptet.
- **Multiplikatoren gjelder dette manuset.** 8 og 7 kopier er hva denne kontrakten og dette
manuset ga; en annen kontrakt gir andre tall.
- **`navigate_bundle` kalles fortsatt per verktøykall** — I/O og veggklokke, ikke tokens; ikke målt
her (samme grense som katalogmålingen i økt 65).
- **Debatten er urørt.** `okf.bundle_context` er byte-identisk, og demo-transkriptet er
byte-uendret (`shasum -a 1` = `ea8c534773acdbe41ae68f2c55724d69aaf8be4f`).
## 6. Kommandoer
```sh
# AFTER
uv run --with tiktoken python scratchpad/s7a3/k2_profile.py after
# BEFORE (patcher kilden, kjører, gjenoppretter)
uv run python scratchpad/s7a3/k2_before.py
# gatens egne tall
uv run python scratchpad/s7a3/measure_sizes.py
```
Kontroll ved slutten av punkt 2: **1 252 passed / 5 skipped**, `ruff` og `mypy` rene, golden
byte-uendret. Ni mutasjoner, alle røde mot HELE suiten (N1N9).

View file

@ -193,10 +193,12 @@ HYPOTHESIS_MARKER: Final = "HYPOTHESIS:"
_INSTRUCTIONS: Final = { _INSTRUCTIONS: Final = {
NAVIGATOR_ROLE: ( NAVIGATOR_ROLE: (
"You read the project's knowledge bases. Use list_bundles to see what exists, " "You read the project's knowledge bases, one level at a time. Use list_bundles to see "
"read_bundle to list the documents inside ONE of them, and read_file to read a document " "what exists, read_bundle to open ONE of them and see its top level, read_dir to open a "
"you picked from that list. read_bundle returns a LISTING, never the documents — quote " "directory that listing named, and read_file to read a document you picked. read_bundle "
"only what read_file gave you, and never guess at content you have not read." "and read_dir return LISTINGS, never the documents — a knowledge base can hold hundreds, "
"so descend to the part that matters instead of asking for all of it. Quote only what "
"read_file gave you, and never guess at content you have not read."
), ),
HYPOTHESISER_ROLE: ( HYPOTHESISER_ROLE: (
"You shape ONE candidate cost-saving direction at a time from what the navigator found. " "You shape ONE candidate cost-saving direction at a time from what the navigator found. "
@ -893,12 +895,14 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
@tool( @tool(
name="read_bundle", name="read_bundle",
description=( description=(
"Open ONE knowledge base by id and list what it holds: one entry per concept " "Open ONE knowledge base by id and list its TOP LEVEL: the directories it is divided "
"document with its name, declared type, title and size in characters. This is a " "into (each with how many concept documents its whole subtree holds) and the documents "
"LISTING, not the documents themselves — read_file(id, name) returns one whole." "that sit directly at the top, each with name, declared type, title and size in "
"characters. Paths are usable as they are — read_dir(id, path) opens a directory, "
"read_file(id, name) returns one document whole."
), ),
) )
def read_bundle(bundle_id: str) -> list[dict[str, Any]]: 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
@ -906,25 +910,25 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
# filesystem accident, recorded by the run rather than blocked here — but two concepts # filesystem accident, recorded by the run rather than blocked here — but two concepts
# declaring two different corpora is a base no fallback can settle. # declaring two different corpora is a base no fallback can settle.
okf.assert_declared_ids_agree(bundle) okf.assert_declared_ids_agree(bundle)
# ``context_files``, NEVER ``files``: it is the property that drops the ``type: verdict`` # ONE renderer for both rungs (kø-(p)): this tool and ``read_dir`` differ only in WHICH
# layer AND nested ``index.md`` at every level. Building from ``files`` would put prior # level they ask for, and two copies of a listing rule would drift into two answers about
# verdicts in front of the navigator around the gated ExpeL fold (målbilde §4), and the # one bundle. ``okf`` owns it, so the context seam stays framework-neutral.
# cost arms of the gate would all stay green while it happened. return okf.directory_listing(bundle)
return [
{ @tool(
"name": f.name, name="read_dir",
# ``or "document"`` mirrors ``okf.bundle_context``'s own fallback for a file with description=(
# no declared type, so the two renderings of one bundle cannot disagree about it. "Open ONE directory inside a knowledge base, by base id and the path a previous "
"type": f.type or "document", "listing gave you. Answers in the same shape as read_bundle: the directories one level "
# ``okf`` owns the unquoting rule; a hand-rolled strip here would be a second copy "further down, and the concept documents that sit in this one. An unknown path is "
# of it, and a second copy is the one that drifts (kø-(p)). "refused rather than answered as an empty directory."
"title": okf.unquote_scalar(f.frontmatter.get("title", f.name)), ),
# What the next rung COSTS, in the unit the ceiling is measured in. A navigator )
# that cannot see the price cannot choose against a budget. def read_dir(bundle_id: str, path: str) -> dict[str, Any]:
"chars": len(f.body), bundle_dir = _resolve_bundle(index, bundle_id)
} bundle = okf.navigate_bundle(bundle_dir)
for f in bundle.context_files okf.assert_declared_ids_agree(bundle)
] return okf.directory_listing(bundle, path)
@tool( @tool(
name="read_file", name="read_file",
@ -937,7 +941,7 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
# navigation walk uses rather than a second, laxer check. # navigation walk uses rather than a second, laxer check.
return Path(safe_resolve(bundle_dir, path)).read_text(encoding="utf-8") return Path(safe_resolve(bundle_dir, path)).read_text(encoding="utf-8")
return [list_bundles, read_bundle, read_file] return [list_bundles, read_bundle, read_dir, read_file]
def quick_validate_tool( def quick_validate_tool(

View file

@ -1015,6 +1015,84 @@ def assert_declared_ids_agree(bundle: Bundle) -> None:
) )
class BundlePathNotFound(ValueError):
"""A listing was asked for a directory the navigated bundle does not have.
A ``ValueError``, the ``BundleIdMismatch`` precedent: it must land on the CLI's refusal tuple
and hosting's 400 arm rather than the crash channel. Refusing is the point — an unknown path
rendered as an empty listing is indistinguishable from a directory that exists and holds
nothing, and the caller is a model choosing a path out of a previous listing.
"""
def directory_listing(bundle: Bundle, path: str = "") -> dict[str, Any]:
"""One LEVEL of a navigated bundle: the subdirectories under ``path`` with what each holds, and
the concept documents that sit directly in it.
The rung between ``list_bundles`` (which bases exist) and ``read_file`` (what one document
says). Before it existed, ``read_bundle`` answered with EVERY concept document in the base
measured on K2 at 42 761 o200k tokens riding in 7 of 12 prompts, 90 % of the run because the
price of finding out what a base contains was set by how much it contains.
**The tree is derived from ``context_files`` NAMES, never from ``index.md`` files.** Every name
is already the full bundle-relative posix path, so the shape was never lost only the
rendering flattened it, which is why ``bundle_context`` and both nav-goldens are byte-identical
after this was added. Deriving from names also means a directory whose index was never linked is
still visible, and that the two renderings of one bundle cannot disagree about what a concept is.
``context_files``, NEVER ``files``: it is the property that drops the ``type: verdict`` layer
AND nested ``index.md`` at every level. A listing built from ``files`` would put prior verdicts
in front of the navigator around the gated ExpeL fold (målbilde §4) and a directory COUNT
built from ``files`` would advertise documents the navigator is not allowed to be shown.
**Every path is BUNDLE-RELATIVE, i.e. usable verbatim as the next call's argument.** A level-
relative name would have to be composed by the caller, and the caller is a model: a path that
never existed is worse than no path (``_index_excerpt``'s rule, one rung up).
``documents`` on a directory entry is the count of concept documents in its whole SUBTREE what
the subtree holds, not what one ``read_dir`` on it returns. It is the price signal a navigator
chooses against, and the tool description says which of the two it is rather than leaving the
reader to guess.
:raises BundlePathNotFound: no navigated concept document lives under ``path``.
"""
prefix = "" if path in ("", ".") else path.strip("/") + "/"
directories: dict[str, int] = {}
documents: list[dict[str, Any]] = []
for f in bundle.context_files:
if not f.name.startswith(prefix):
continue
rest = f.name[len(prefix) :]
head, sep, _ = rest.partition("/")
if sep:
directories[prefix + head] = directories.get(prefix + head, 0) + 1
else:
documents.append(
{
"name": f.name,
# ``or "document"`` mirrors ``bundle_context``'s own fallback for a file with no
# declared type, so the two renderings cannot disagree about it.
"type": f.type or "document",
"title": unquote_scalar(f.frontmatter.get("title", f.name)),
# What the next rung COSTS, in the unit the ceiling is measured in. A navigator
# that cannot see the price cannot choose against a budget.
"chars": len(f.body),
}
)
if prefix and not directories and not documents:
raise BundlePathNotFound(
f"knowledge base {bundle.dir!r} has no directory {path!r}; it holds no concept "
"document under that path"
)
return {
"path": path,
"directories": [
{"path": name, "documents": count} for name, count in sorted(directories.items())
],
"documents": documents,
}
def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str: def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str:
"""Render a navigated bundle as agent read-context via progressive disclosure: the ``index.md`` """Render a navigated bundle as agent read-context via progressive disclosure: the ``index.md``
summary, then each concept file as ``## {type}: {title}\\n{body}``. ``type: verdict`` files are summary, then each concept file as ``## {type}: {title}\\n{body}``. ``type: verdict`` files are

View file

@ -244,10 +244,10 @@ def test_explore_read_bundle_reconciles_the_base_it_opens(_spy: list[str]) -> No
"""(h) The tool is driven DIRECTLY: measured in okt 56, a scripted client returns TEXT and never """(h) The tool is driven DIRECTLY: measured in okt 56, a scripted client returns TEXT and never
emits a tool call, so a gate that only drove ``explore()`` would never enter this body.""" emits a tool call, so a gate that only drove ``explore()`` would never enter this body."""
tools = {t.name: t for t in explore.navigator_tools((str(_BYGG),))} tools = {t.name: t for t in explore.navigator_tools((str(_BYGG),))}
# See test_read_bundle_cost_loadbearing: the tool returns a LISTING since S2c, and ``!= ""`` # See test_read_bundle_cost_loadbearing: the tool returns a LISTING since S2c and ONE LEVEL of
# holds for every list ever built — this arm is about the reconciliation, so the call must # one since S7a-3, and ``!= ""`` holds for every container ever built — this arm is about the
# still be a call that DID something. # reconciliation, so the call must still be a call that DID something.
assert [e["name"] for e in tools["read_bundle"].func(bundle_id=_BYGG.name)] assert [e["name"] for e in tools["read_bundle"].func(bundle_id=_BYGG.name)["documents"]]
assert str(_BYGG) in _spy assert str(_BYGG) in _spy

View file

@ -683,9 +683,13 @@ async def test_an_exploration_leaves_the_knowledge_base_byte_identical(tmp_path:
# Every read tool, called on the same base, with model-shaped arguments. # Every read tool, called on the same base, with model-shaped arguments.
tools = {t.name: t for t in explore.navigator_tools((str(base),))} tools = {t.name: t for t in explore.navigator_tools((str(base),))}
assert tools["list_bundles"].func()[0]["id"] == base.name assert tools["list_bundles"].func()[0]["id"] == base.name
# A LISTING since S2c, so ``!= ""`` would be vacuously true for any list at all — assert the # A LISTING since S2c and ONE LEVEL of one since S7a-3, so ``!= ""`` would be vacuously true
# shape the tool now promises instead of the shape it used to. # for any container at all — assert the shape the tool now promises instead of the one it used
assert [e["name"] for e in tools["read_bundle"].func(bundle_id=base.name)] # to. ``read_dir`` is driven too: this arm's whole claim is that EVERY read tool leaves the base
# untouched, so a tool it does not call is a hole in it. ``path=""`` is the root level, which is
# the only path a flat base has.
assert [e["name"] for e in tools["read_bundle"].func(bundle_id=base.name)["documents"]]
assert tools["read_dir"].func(bundle_id=base.name, path="")["documents"]
assert tools["read_file"].func(bundle_id=base.name, path="index.md") != "" assert tools["read_file"].func(bundle_id=base.name, path="index.md") != ""
explore.quick_validate_tool((str(base),)).func( explore.quick_validate_tool((str(base),)).func(
bundle_id=base.name, proposal_json=json.dumps(_micro_projection()) bundle_id=base.name, proposal_json=json.dumps(_micro_projection())

View file

@ -0,0 +1,291 @@
"""S7a-3 pkt. 2 - the ladder gains its missing rung: ``read_bundle`` -> ``read_dir`` -> ``read_file``.
**The measurement** (``docs/2026-09-03-syretest-s7a2-k2.md``). MAJOR-3 (session 77) rebuilt
``read_bundle`` from "the whole navigated base" into "one entry per concept document", and on the
three example bases that was a 77-91 % cut. Then the first real corpus arrived: K2 navigates to
**629 concept documents behind 478 nested indexes**, and a listing of 629 documents costs
**42 761 o200k tokens** and rides in 7 of 12 prompts - **90 % of every prompt token in the run**.
The bound held asymptotically and still priced the whole corpus, because the rung was priced by the
size of the base rather than by what the navigator had asked to see.
Worse, the 478 nested indexes were BUILT by the navigation, CONSUMED by it, and then **flattened
away**: the agent saw 629 sibling documents and was never told the corpus had a shape.
**A premise of the order, corrected by measurement.** The order says ``context_files`` must stop
discarding the hierarchy. It never held it back: every ``BundleFile.name`` is already the full
bundle-relative posix path (``del-ii-bilag-7-prisskjema/prissammenstilling-sheet-1.md``), so the
tree was always recoverable from the names alone. What flattened it was the RENDERING. That matters
because it is why ``okf.bundle_context`` and both nav-goldens are byte-identical after this change -
the order requires exactly that, and it comes for free rather than by care.
So: ``okf.directory_listing`` is ONE renderer, and BOTH tools are it at a different path.
``read_bundle`` is the root level (subdirectories with a document count, plus the documents that sit
at the top), ``read_dir(id, path)`` is one level down, ``read_file(id, path)`` is unchanged. Every
byte is still exactly one call away; what changed is that a navigator pays for the level it asked
for.
**Directories are derived from PATHS, never from ``index.md`` files.** A nested index is navigation,
not content (``context_files`` drops it at every level, and must keep doing so or the verdict layer
comes with it). Deriving the tree from names means a directory reached by a link that skipped its
index is still visible - and it means the two renderings of one bundle cannot disagree about what
the concepts are.
**DEVIATION from the order, stated and measured.** The order words the ceiling as "``read_bundle``
over the K2 bundle < 1 500 chars". K2 lives outside the repository (``~/corpora/``) and cannot be a
test dependency - the MAJOR-3 gate had the same constraint and solved it the same way, by bounding
the largest base that IS shipped. And the number does not hold: K2's root level carries **39
identifiable entries** (28 directories + 11 top-level documents) and measures **3 954 chars ~ 1 366
o200k tokens**. Fitting 1 500 would mean dropping titles or dropping entries, which is the
vacuous-gate class this repo has been bitten by twelve times. The measured drop is from 42 761 to
1 366 tokens (-97 %); the K2 numbers live in the report, and the gate here bounds what it can
actually see, plus the PROPERTY that produced the drop (arm (b)).
Arms: (a) a FLAT base lists exactly what it listed before * (b) cost tracks entries at ONE LEVEL,
not documents in the base - with the flat control that proves the hierarchy did it * (c) ``read_dir``
over the biggest directory is bounded too * (d) anti-vacuity: every document is still reachable, and
the counts add up * (e) the verdict layer stays out of both halves * (f) the ladder is intact *
(g) an unknown path is refused BY NAME, never rendered as an empty directory * (h) the navigator's
instruction and the tool descriptions describe the ladder they now have.
"""
from __future__ import annotations
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
_EXAMPLES = Path(__file__).resolve().parents[1] / "shared" / "examples"
#: The largest FLAT base shipped - the one the MAJOR-3 gate bounds.
_TUNNEL = _EXAMPLES / "tunnel-hauglia"
#: The only NESTED base shipped, and it is tiny: three levels, six concepts. It proves the shape is
#: read correctly; it cannot prove the shape pays for itself, which is what arm (b) is for.
_NESTED = _EXAMPLES / "nav-golden-hierarchy" / "bundle"
#: Characters ONE listing may cost - test-owned, for the catalogue gate's reason: a test that
#: imported the implementation's budget would move with it, and raising the budget is precisely the
#: regression this file exists to catch. Same number as the MAJOR-3 gate, deliberately: this rung's
#: price must not creep just because it now has a rung below it.
_CEILING_CHARS = 1_500
def _tools(bundle_dir: Path) -> dict[str, Any]:
return {t.name: t for t in navigator_tools((str(bundle_dir),))}
def _read_bundle(bundle_dir: Path) -> dict[str, Any]:
return _tools(bundle_dir)["read_bundle"].func(bundle_id=bundle_dir.name)
def _read_dir(bundle_dir: Path, path: str) -> dict[str, Any]:
return _tools(bundle_dir)["read_dir"].func(bundle_id=bundle_dir.name, path=path)
def _blob(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False)
def _entry(f: okf.BundleFile, name: str) -> dict[str, Any]:
"""The four-field document entry MAJOR-3 introduced, written out here rather than imported: an
assert against the implementation's own helper could not tell the two shapes apart."""
return {
"name": name,
"type": f.type or "document",
"title": okf.unquote_scalar(f.frontmatter.get("title", f.name)),
"chars": len(f.body),
}
def _write_tree(root: Path, name: str, *, dirs: int, per_dir: int, top: int = 2) -> Path:
"""A base shaped like a real ingested corpus: a root index linking to per-directory indexes,
each linking to its own documents. Bodies are small on purpose - this fixture is about COUNT."""
base = root / name
base.mkdir(parents=True)
root_lines = [f"# {name}", ""]
for t in range(top):
root_lines.append(f"- [Toppdokument {t}](topp-{t}.md)")
(base / f"topp-{t}.md").write_text(
f"---\ntype: concept\ntitle: Toppdokument {t}\n---\n\nkort tekst.\n", encoding="utf-8"
)
for d in range(dirs):
sub = base / f"kategori-{d:02d}"
sub.mkdir()
root_lines.append(f"- [kategori-{d:02d} (index)](kategori-{d:02d}/index.md)")
sub_lines = [f"# kategori-{d:02d}", ""]
for i in range(per_dir):
sub_lines.append(f"- [Dokument {i}](dok-{i:02d}.md)")
(sub / f"dok-{i:02d}.md").write_text(
f"---\ntype: concept\ntitle: Dokument {d:02d}-{i:02d}\n---\n\nkort tekst.\n",
encoding="utf-8",
)
(sub / "index.md").write_text(
"---\ntype: index\n---\n\n" + "\n".join(sub_lines) + "\n", encoding="utf-8"
)
(base / "index.md").write_text(
"---\ntype: index\n---\n\n" + "\n".join(root_lines) + "\n", encoding="utf-8"
)
return base
# --- (a) a flat base is untouched -----------------------------------------------------------------
def test_a_flat_base_lists_exactly_what_it_listed_before() -> None:
"""(a) The order names this one explicitly. Every base shipped in this repo except one is flat,
and the whole MAJOR-3 measurement was taken over them: if the new rung changed what a flat base
answers, this change would be a rewrite of that result rather than a level above it."""
bundle = okf.navigate_bundle(str(_TUNNEL))
listing = _read_bundle(_TUNNEL)
assert listing["documents"] == [_entry(f, f.name) for f in bundle.context_files]
assert listing["directories"] == [], "a flat base has no subdirectories to report"
assert len(_blob(listing)) <= _CEILING_CHARS
# --- (b)/(c) the price is the LEVEL, not the base -------------------------------------------------
def test_the_root_listing_costs_the_level_not_the_base(tmp_path: Path) -> None:
"""(b) The headline, and the control that proves the hierarchy is what bounded it.
240 documents behind 20 directories: the root level is 22 entries and fits, while the FLAT
listing of the same base - what ``read_bundle`` returned until today - is many times the
ceiling. Without the flat control, a green bound here could just as well mean the fixture was
small (Verifiseringsloven face 4: a gate that can only pass proves nothing).
"""
base = _write_tree(tmp_path, "stort-korpus", dirs=20, per_dir=12)
bundle = okf.navigate_bundle(str(base))
listing = _read_bundle(base)
flat = [_entry(f, f.name) for f in bundle.context_files]
assert len(bundle.context_files) == 242
assert len(_blob(listing)) <= _CEILING_CHARS, (
f"the root level costs {len(_blob(listing))} chars over the ceiling {_CEILING_CHARS}"
)
assert len(_blob(flat)) > 5 * _CEILING_CHARS, (
"the CONTROL is inert: the flat listing must be the thing that does not fit, or the bound "
"above is measuring a small fixture rather than the hierarchy"
)
def test_read_dir_over_the_biggest_directory_is_bounded_too(tmp_path: Path) -> None:
"""(c) A rung that is only cheap at the top is not a ladder. The order asks for this one by
name: ``read_dir`` over the largest directory must be bounded as well."""
base = _write_tree(tmp_path, "stort-korpus", dirs=20, per_dir=12)
listing = _read_dir(base, "kategori-00")
assert len(listing["documents"]) == 12 and listing["directories"] == []
assert len(_blob(listing)) <= _CEILING_CHARS
# --- (d)/(e) bounded is not the same as empty -----------------------------------------------------
def test_every_document_is_still_reachable_and_the_counts_add_up(tmp_path: Path) -> None:
"""(d) The anti-vacuity arm. Returning ``{"directories": [], "documents": []}`` passes every
bound above perfectly and hands the navigator nothing. Two things are asserted: each directory
entry PRICES its subtree (so a navigator can choose against a budget), and the whole base is
accounted for - every concept document is in exactly one level."""
base = _write_tree(tmp_path, "stort-korpus", dirs=20, per_dir=12)
bundle = okf.navigate_bundle(str(base))
root = _read_bundle(base)
counted = len(root["documents"]) + sum(int(d["documents"]) for d in root["directories"])
assert counted == len(bundle.context_files) == 242
assert {str(d["path"]) for d in root["directories"]} == {f"kategori-{d:02d}" for d in range(20)}
assert all(int(d["documents"]) == 12 for d in root["directories"])
for entry in root["documents"]:
assert set(entry) == {"name", "type", "title", "chars"}
assert str(entry["title"]).strip() and int(entry["chars"]) > 0
def test_the_verdict_layer_is_excluded_from_both_halves(tmp_path: Path) -> None:
"""(e) MAJOR-3's rule, extended to the new half: a listing built from ``bundle.files`` would
route prior verdicts in front of the navigator AROUND the gated ExpeL fold (maalbilde §4) - and
a DIRECTORY COUNT built from ``files`` would tell the navigator a subtree holds documents it is
not allowed to be shown."""
base = _write_tree(tmp_path, "med-dommer", dirs=2, per_dir=2, top=0)
sub = base / "kategori-00"
(sub / "dom-0.md").write_text(
"---\ntype: verdict\ntitle: Dom\n---\n\nEksperten godkjente.\n", encoding="utf-8"
)
index = sub / "index.md"
index.write_text(index.read_text(encoding="utf-8") + "- [dom](dom-0.md)\n", encoding="utf-8")
root = _read_bundle(base)
inner = _read_dir(base, "kategori-00")
assert len(okf.navigate_bundle(str(base)).verdicts) == 1, "the fixture must HAVE one to leak"
assert [d["documents"] for d in root["directories"] if d["path"] == "kategori-00"] == [2]
assert not [e for e in inner["documents"] if e["type"] == "verdict"]
assert len(inner["documents"]) == 2
# --- (f)/(g) the ladder, and its fail-closed edge -------------------------------------------------
def test_the_whole_document_is_still_one_call_away() -> None:
"""(f) The bound is a disclosure LEVEL, not data loss - MAJOR-3's arm (e), re-asserted over the
new shape because a listing that no longer names documents the way ``read_file`` takes them
would have broken the ladder while every cost arm stayed green."""
listing = _read_bundle(_TUNNEL)
biggest = max(listing["documents"], key=lambda e: int(e["chars"]))
whole = _tools(_TUNNEL)["read_file"].func(bundle_id=_TUNNEL.name, path=str(biggest["name"]))
assert len(whole) > _CEILING_CHARS
assert int(biggest["chars"]) <= len(whole)
def test_a_nested_document_is_reachable_through_the_rung_below() -> None:
"""(f, nested) The three rungs composed on the only nested base shipped: the root names a
directory, ``read_dir`` names a document in it, ``read_file`` returns that document whole."""
root = _read_bundle(_NESTED)
first_dir = str(root["directories"][0]["path"])
inner = _read_dir(_NESTED, first_dir)
name = str(inner["documents"][0]["name"])
whole = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=name)
assert root["documents"] and root["directories"], "the fixture must have BOTH halves"
assert name.startswith(f"{first_dir}/"), (
"every path a listing hands out must be usable AS IT IS: a level-relative name would have "
"to be composed by a model, and a path that never existed is worse than no path"
)
assert whole.strip(), "the path the listing gave must resolve to the document itself"
def test_an_unknown_directory_is_refused_by_name(tmp_path: Path) -> None:
"""(g) Fail-closed, and it is the vacuity trap in its own right: an unknown path rendered as an
empty listing is indistinguishable from a directory that exists and holds nothing. Validation,
never invention (``write_concept_file``'s rule)."""
base = _write_tree(tmp_path, "korpus", dirs=2, per_dir=2)
with pytest.raises(ValueError, match="kategori-99"):
_read_dir(base, "kategori-99")
# --- (h) the description must not lie about the body ----------------------------------------------
def test_the_navigator_is_told_about_the_rung_it_now_has() -> None:
"""(h) The Fase-3 class applied to a tool: a description that lies about the body IS the
model's instruction. MAJOR-3 had to move this same prose when ``read_bundle`` stopped returning
the navigated context; the ladder is three rungs now, and all three must be named."""
instruction = explore._INSTRUCTIONS[explore.NAVIGATOR_ROLE]
tools = {t.name: t for t in navigator_tools((str(_NESTED),))}
assert set(tools) == {"list_bundles", "read_bundle", "read_dir", "read_file"}
for rung in ("list_bundles", "read_bundle", "read_dir", "read_file"):
assert rung in instruction, f"the navigator is never told that {rung} exists"
assert "read_dir" in tools["read_bundle"].description, (
"read_bundle now answers with directories; a description that does not name the tool that "
"opens one leaves the navigator holding a path it cannot use"
)

View file

@ -39,6 +39,14 @@ o200k tokens** (2.89 chars/token for this Norwegian markdown), so 1 500 characte
growth does not force a rewrite. The order's own criterion is verified directly, once, by the growth does not force a rewrite. The order's own criterion is verified directly, once, by the
instrument in the measurement doc. instrument in the measurement doc.
**AMENDED 2026-09-03 (S7a-3 pkt. 2), and the amendment is one level, not a rewrite.** The listing
this file bounds is now the base's TOP LEVEL rather than every document in it: on K2 the flat form
still cost 42 761 o200k tokens across 629 documents, because O(documents in the base) is only cheap
while the base is small. ``read_dir`` is the rung that was missing. Every arm below still holds over
the flat example bases -- a flat base has no subdirectories, so its document list is unchanged, byte
for byte -- and the hierarchy's own gate is ``tests/test_hierarchical_navigation_loadbearing.py``.
``_documents()`` exists because ``len()`` of the payload is now the number of KEYS.
What the arms pin, and what each one refuses: What the arms pin, and what each one refuses:
(a) the bound itself, over the REAL base the order names refuses the unbounded form. A synthetic (a) the bound itself, over the REAL base the order names refuses the unbounded form. A synthetic
@ -82,10 +90,18 @@ def _tools(bundle_dir: Path) -> dict[str, Any]:
return {t.name: t for t in navigator_tools((str(bundle_dir),))} return {t.name: t for t in navigator_tools((str(bundle_dir),))}
def _listing(bundle_dir: Path) -> list[dict[str, Any]]: def _listing(bundle_dir: Path) -> dict[str, Any]:
"""The WHOLE payload one ``read_bundle`` costs — since S7a-3 that is one LEVEL of the base
(subdirectories plus the documents at this one), not every document in it."""
return _tools(bundle_dir)["read_bundle"].func(bundle_id=bundle_dir.name) return _tools(bundle_dir)["read_bundle"].func(bundle_id=bundle_dir.name)
def _documents(bundle_dir: Path) -> list[dict[str, Any]]:
"""The document half alone. A separate helper because ``len()`` of the payload is the number of
KEYS three, always and an arm counting entries through it would be vacuously green."""
return list(_listing(bundle_dir)["documents"])
def _blob(payload: object) -> str: def _blob(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False) return json.dumps(payload, ensure_ascii=False)
@ -129,7 +145,7 @@ def test_read_bundle_cost_does_not_track_document_size(tmp_path: Path) -> None:
small = _write_base(tmp_path, "small", body_chars=500) small = _write_base(tmp_path, "small", body_chars=500)
large = _write_base(tmp_path, "large", body_chars=5_000) large = _write_base(tmp_path, "large", body_chars=5_000)
small_entries, large_entries = _listing(small), _listing(large) small_entries, large_entries = _documents(small), _documents(large)
# Same document COUNT, same number of entries — and the only field that grew is the honest, # Same document COUNT, same number of entries — and the only field that grew is the honest,
# logarithmic ``chars`` digit, so the payloads differ by a handful of characters at most. # logarithmic ``chars`` digit, so the payloads differ by a handful of characters at most.
@ -149,7 +165,7 @@ def test_the_listing_still_identifies_every_document(tmp_path: Path) -> None:
context files rather than to a number written here. context files rather than to a number written here.
""" """
bundle = okf.navigate_bundle(str(_TUNNEL)) bundle = okf.navigate_bundle(str(_TUNNEL))
entries = _listing(_TUNNEL) entries = _documents(_TUNNEL)
assert len(entries) == len(bundle.context_files) > 0, ( assert len(entries) == len(bundle.context_files) > 0, (
"a listing that omits documents is a base the navigator cannot fully see" "a listing that omits documents is a base the navigator cannot fully see"
@ -172,7 +188,7 @@ def test_the_verdict_layer_is_still_excluded(tmp_path: Path) -> None:
""" """
base = _write_base(tmp_path, "med-dommer", body_chars=200, concepts=2, verdicts=2) base = _write_base(tmp_path, "med-dommer", body_chars=200, concepts=2, verdicts=2)
entries = _listing(base) entries = _documents(base)
assert len(entries) == 2, f"the verdict layer must not be listed as context: {entries!r}" assert len(entries) == 2, f"the verdict layer must not be listed as context: {entries!r}"
assert not [e for e in entries if e["type"] == "verdict"] assert not [e for e in entries if e["type"] == "verdict"]
@ -184,7 +200,7 @@ def test_the_verdict_layer_is_still_excluded(tmp_path: Path) -> None:
def test_the_whole_document_is_still_one_call_away() -> None: def test_the_whole_document_is_still_one_call_away() -> None:
"""(e) The bound is a disclosure LEVEL, not data loss.""" """(e) The bound is a disclosure LEVEL, not data loss."""
entries = _listing(_TUNNEL) entries = _documents(_TUNNEL)
biggest = max(entries, key=lambda e: int(e["chars"])) biggest = max(entries, key=lambda e: int(e["chars"]))
whole = _tools(_TUNNEL)["read_file"].func(bundle_id=_TUNNEL.name, path=str(biggest["name"])) whole = _tools(_TUNNEL)["read_file"].func(bundle_id=_TUNNEL.name, path=str(biggest["name"]))