feat(explore): katalogkallet koster O(baser), ikke O(korpus) (ORDRE 20260825T213645Z)

list_bundles returnerte hele rot-indeksens body for HVER konfigurert base samtidig,
pluss ett JSON-objekt per ufulgt kryss-lenke. Begge vokser med korpuset, saa prisen
paa aa finne ut HVILKE baser som finnes ble satt av hvor mye de INNEHOLDER - progressiv
disclosure snudd paa hodet.

Maalt med o200k_base, instrumentet foerst validert mot commons' egne fasittall:
  tre flate Vegnormal-baser   112 116 -> 362 tokens   (-99,7 %)
  171 grenbaser               124 942 -> 21 448       (-82,8 %)
Grenformen (vegnormal-okf 8145c23) lukket bundle-siden og gjorde katalogsiden verre,
noeyaktig som det repoet forutsa.

Et premiss ble felt FOER noe ble bygget paa det: "indeksbodyen forteller hva basen
handler om" er usant for maskin-importerte baser - grenbasenes index.md er en ren
lenkeliste uten frontmatter og prosa, saa feltet var dyrt OG innholdsloest der.

Fast vindu (200 tegn), aldri en andel av basen. Avkorting annonseres som FELT
(index_truncated), og en base som passer blir ikke merket avkortet. En ufulgt lenke
overlever som ANTALL; per-lenke-detaljen blir liggende der den er handlingsbar.
Hele indeksen er fortsatt ett read_file(id, "index.md") unna.

Taket (500 tegn/base) bor i TESTEN, ikke i explore.py.

Load-bearing MAALT: tests/test_catalogue_cost_loadbearing.py, 7 armer, ni mutasjoner
alle roede mot HELE suiten + groenn kontroll 1066/5 og golden demo-transcript.stdout
byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Ogsaa: MINOR-1 i syretest-rapporten rettet - flatheten er Doer C sin
(llm-ingestion-okf importer.py, §6-index-blokka), ikke vegnormals emitterform.
Verifisert mot kilden, ikke mot meldingen.

Maaling: docs/2026-08-26-katalogkostnaden.md
This commit is contained in:
Kjell Tore Guttormsen 2026-08-26 14:45:16 +02:00
commit a1f8522bdf
6 changed files with 421 additions and 11 deletions

View file

@ -0,0 +1,183 @@
"""The catalogue call costs O(bases), never O(corpus) — and what it drops, it SAYS it dropped.
Measured 2026-08-25 (session 60, ``docs/2026-08-25-syretest-vei-ab.md``) and re-measured 26.08 with
the same instrument (``tiktoken`` ``o200k_base``, run through ``uv run --with tiktoken``, validated
first against the three commons example bundles whose numbers commons itself publishes):
list_bundles() over the three flat Vegnormal bases -> 201 196 chars / 112 116 tokens
list_bundles() over the 171 branch bases -> 234 611 chars / 124 942 tokens
The branch form (``vegnormal-okf`` ``8145c23``) closed the *bundle* side ``read_bundle`` fell 82-92
percent and made the *catalogue* side WORSE, exactly as that repo predicted: one call now costs
more than a 128k window, before the manager has read a single document.
The cause is in this repo. ``list_bundles`` returned ``Bundle.index_summary`` the WHOLE root index
body for EVERY configured base at once, plus one JSON object per unfollowed cross-link. Both grow
with the corpus, so the price of *finding out which bases exist* was set by how much those bases
contain. That is the opposite of progressive disclosure (målbilde §2/§4): the catalogue is the
cheapest rung of the ladder, and it was the most expensive.
**A MEASURED premise, felled before anything was built on it:** "the index body tells a manager what
the base is about" is FALSE for machine-imported bases. The branch bases' ``index.md`` carries no
frontmatter and no prose it is a pure link list (measured: ``B-n200-2024-gren-1-1-importert``,
959 bytes, first byte is ``-``). So the old field was not merely expensive, it was expensive AND
uninformative there; a truncated prefix loses nothing a manager was using.
**The ceiling lives in this file, not in ``explore.py``.** 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. 500 characters per base is the number, chosen with headroom over the measured entry so that
ordinary field growth does not force a rewrite, and small enough that all 171 branch bases fit in
well under a tenth of the window they used to need.
What the arms pin, and what each one refuses:
(a) the bound itself, over many large bases refuses the unbounded form;
(b) cost does NOT track corpus size the same base with ten times the index costs the SAME, which
is the property "O(bases), not O(corpus)" stated directly rather than inferred from (a);
(c) the catalogue still IDENTIFIES what it lists without this, "return only the ids" passes (a)
perfectly and delivers a manager nothing to choose between (the repo's vacuous-gate class);
(d) truncation is ANNOUNCED, never silent, and the excerpt is a VERBATIM prefix validation, never
repair (``write_concept_file``'s rule). A base whose index FITS is not marked truncated and gets
its whole body: omission, never a lie in either direction;
(e) the ladder is intact ``read_file(id, "index.md")`` still returns the COMPLETE index, so the
bound is a disclosure level, not data loss;
(f) an unreachable link survives as a COUNT the fact stays visible (session 51's "a skip is
tolerated but no longer silent"), while the unbounded per-link detail does not ride along in a
call whose whole job is to be cheap. The detail is still carried where it is actionable, on
``RunResult.skipped_links`` / ``DryRunReport.skipped_links``;
(g) the CONTROL the ceiling is proved to discriminate. One base's raw index body alone exceeds the
budget for the entire catalogue, so a green (a) means the bound fired, not that the fixture was
small (Verifiseringsloven face 4: a gate that can only pass proves nothing).
"""
from __future__ import annotations
import json
from pathlib import Path
from portfolio_optimiser.explore import navigator_tools
#: Characters per base the catalogue may cost. Test-owned on purpose — see the module docstring.
_CEILING_CHARS_PER_BASE = 500
#: Big enough that the old form blew the ceiling by two orders of magnitude (arm (g) measures it).
_MANY_CONCEPTS = 300
def _write_base(root: Path, name: str, concepts: int, *, dangling: int = 0) -> str:
base = root / name
base.mkdir(parents=True)
lines = [f"# {name}", "", "Denne basen dekker et avgrenset fagområde.", ""]
for i in range(concepts):
lines.append(f"- [konsept-{i:04d}](konsept-{i:04d}.md)")
(base / f"konsept-{i:04d}.md").write_text(
f"---\ntype: concept\n---\n\n# Konsept {i}\n\nInnhold.\n", encoding="utf-8"
)
for i in range(dangling):
lines.append(f"- [borte-{i:04d}](borte-{i:04d}.md)")
(base / "index.md").write_text(
"---\ntype: index\n---\n\n" + "\n".join(lines) + "\n", encoding="utf-8"
)
return str(base)
def _catalogue(dirs: list[str]) -> list[dict[str, object]]:
tools = {t.name: t for t in navigator_tools(tuple(dirs))}
return tools["list_bundles"].func()
def _blob(entries: object) -> str:
return json.dumps(entries, ensure_ascii=False)
def test_catalogue_cost_is_bounded_per_base(tmp_path: Path) -> None:
"""(a) Many large bases, one call: the payload stays under a per-base ceiling."""
dirs = [_write_base(tmp_path, f"base-{n}", _MANY_CONCEPTS) for n in range(5)]
blob = _blob(_catalogue(dirs))
assert len(blob) <= len(dirs) * _CEILING_CHARS_PER_BASE, (
f"catalogue cost {len(blob)} chars over {len(dirs)} bases exceeds the ceiling "
f"{len(dirs) * _CEILING_CHARS_PER_BASE}"
)
def test_catalogue_cost_does_not_track_corpus_size(tmp_path: Path) -> None:
"""(b) Ten times the index, the same price. O(bases), not O(corpus)."""
small = _write_base(tmp_path, "small", 30)
large = _write_base(tmp_path, "large", 300)
entries = {str(e["id"]): e for e in _catalogue([small, large])}
small_entry, large_entry = entries["small"], entries["large"]
# The excerpt is a fixed window, so ten times the index yields the same number of characters.
# (The counts beside it grow by a DIGIT, which is honest and logarithmic — this arm is about
# the field that used to grow linearly, and asserting on the whole blob would measure that
# digit instead of the property.)
assert len(str(large_entry["index_excerpt"])) == len(str(small_entry["index_excerpt"]))
assert len(_blob(large_entry)) <= _CEILING_CHARS_PER_BASE
def test_catalogue_still_identifies_every_base(tmp_path: Path) -> None:
"""(c) The anti-vacuity arm: bounded is not the same as empty."""
dirs = [_write_base(tmp_path, f"base-{n}", 50) for n in range(3)]
entries = _catalogue(dirs)
assert sorted(str(e["id"]) for e in entries) == ["base-0", "base-1", "base-2"]
for entry in entries:
excerpt = str(entry["index_excerpt"])
assert excerpt.strip(), "a catalogue that says nothing about a base cannot be chosen from"
assert str(entry["id"]) in excerpt
# How big the base is, is part of choosing one: with the index body no longer riding along,
# this count is the only thing left that says what read_bundle would cost.
assert entry["documents"] == 50
def test_truncation_is_announced_and_the_excerpt_is_verbatim(tmp_path: Path) -> None:
"""(d) Both directions: a cut index SAYS it was cut; a whole one is not marked, and is whole."""
from portfolio_optimiser import okf
large = _write_base(tmp_path, "large", 300)
tiny = _write_base(tmp_path, "tiny", 1)
entries = {str(e["id"]): e for e in _catalogue([large, tiny])}
assert entries["large"]["index_truncated"] is True
body = okf.navigate_bundle(large).index_summary
assert body.startswith(str(entries["large"]["index_excerpt"]))
assert len(str(entries["large"]["index_excerpt"])) < len(body)
assert entries["tiny"]["index_truncated"] is False
assert str(entries["tiny"]["index_excerpt"]) == okf.navigate_bundle(tiny).index_summary
def test_the_full_index_is_still_one_call_away(tmp_path: Path) -> None:
"""(e) The bound is a disclosure LEVEL, not data loss."""
large = _write_base(tmp_path, "large", 300)
tools = {t.name: t for t in navigator_tools((large,))}
whole = tools["read_file"].func(bundle_id="large", path="index.md")
assert whole.count("- [konsept-") == 300
def test_unreachable_links_survive_as_a_count(tmp_path: Path) -> None:
"""(f) The fact stays; the unbounded per-link detail does not ride along."""
base = _write_base(tmp_path, "holes", 20, dangling=40)
entry = _catalogue([base])[0]
assert entry["unreachable_links"] == 40
assert len(_blob([entry])) <= _CEILING_CHARS_PER_BASE
def test_control_the_unbounded_form_would_blow_the_ceiling(tmp_path: Path) -> None:
"""(g) The ceiling discriminates — proved, not assumed."""
from portfolio_optimiser import okf
large = _write_base(tmp_path, "large", _MANY_CONCEPTS)
body = okf.navigate_bundle(large).index_summary
assert len(body) > 5 * _CEILING_CHARS_PER_BASE