feat(s2c): debatten navigerer basen i stedet for aa faa den utlevert [skip-docs]

MAJOR-3/S7a-3 gjorde utforskningen billig og lot pipelinen staa. Maalt paa K2
(630 konsepter, S7bs eget instrument, kjent-positiv-kontrollen reprodusert
eksakt FOER bruk): okf.bundle_context er 648 962 o200k-tokens og rir i TRE
kopier = 1 947 342 = 99,1 % av en kjoerings prompt-tokens.

Et premiss i maaledokumentet ble presisert foerst: de tre kopiene er tre
DEBATT-turer (proposer x2, checker x1), mens genererings-prompten er 156
tokens, fordi gen_context = debate_output or context. Det avgjorde formen -
generering trengte ingen egen soem, for aa binde `context` binder
siste-utvei-fallbacken ved konstruksjon.

run_project sender naa en PEKER (fast tekst + erklaert bundle_id + antall
konseptdokumenter i scope + stigen, O(1) i korpuset) og gir debatten de SAMME
fire verktoeyene utforskningen bruker - explore.navigator_tools gjenbrukt,
aldri en andre kopi av policyen.

Etter: 753 tokens like-for-like (samme manus, samme fire prompter, -99,96 %)
og 8 942 med en debatt som faktisk gaar stigen (-99,5 %), mot operatoerens
terskel 195 000 = 4,6 % av taket. Validert besparelse og validatorens dom er
UENDRET (850 000 NOK av 3 852 500, 2 av 5 felt paa stage 4 og 5, samme
dom-noekkel), og utforskningens 18 355 er uendret til tokenet.

§4.1a maatte flytte, ikke forsvinne: dimensjonsfilteret bodde i renderingen og
bor naa i VERKTOEYENE, paa begge trinn - en listing som skjuler et fremmed
dokument mens read_file serverer det paa sti er et filter i navnet alene.
okf.in_dimension er eneste predikat.

Sporet er kaller-eid (ExplorationToolRecorder -> RunResult.debate_tool_calls ->
{run_id}-debate.json fra en finally) og skrives ogsaa TOMT: en debatt som
navigerer ingenting ER S2c-regresjonen, saa den maa kunne leses.

Load-bearing MAALT: aatte mutasjoner roede mot HELE suiten, groenn kontroll
1306/5 (fra 1295/5), golden demo-transcript.stdout BYTE-UENDRET
(shasum -a 1 av innholdet = ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
M7 falsifiserte seg selv, ikke gaten - staar som maalt.

Maaling: docs/2026-09-04-s2c-debatt-k2.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-04 18:04:21 +02:00
commit da5f10f140
13 changed files with 1044 additions and 98 deletions

View file

@ -214,6 +214,17 @@ class ExplorationError(RuntimeError):
"""The exploration cannot be honoured as configured, or produced something unreadable."""
class DimensionScopeRefused(ValueError):
"""A navigator asked for a document belonging to ANOTHER dimension than the run is scoped to.
A ``ValueError``, the ``BundlePathNotFound``/``BundleIdMismatch`` precedent: the caller is a
model choosing a path, so the refusal must land on the CLI's refusal tuple and hosting's 400
arm rather than the crash channel. It is deliberately NOT an ``ExplorationError``
(a ``RuntimeError``): this is a refused read inside a run that is otherwise fine, not an
exploration that cannot be honoured as configured.
"""
@dataclass(frozen=True)
class LedgerEntry:
"""One progress-ledger round, reduced to the five fields the manager steers on (C.2).
@ -375,6 +386,20 @@ class ExplorationTrace:
tokens_spent: int = 0
def tool_call_payload(calls: Sequence[ToolCall]) -> list[dict[str, Any]]:
"""The ONE rendering of a tool trace into plain data, in CALL ORDER.
Two surfaces now write one: ``trace_payload`` for ``{run_id}-exploration.json`` and
``run_project`` for ``{run_id}-debate.json`` (S2c). Two copies of "what a recorded call looks
like" would drift into two answers about the same fact, which is the kø-(p) defect — and here
the drift would land in the artefacts an operator reads to find out what a paid run opened.
Plain mappings only, so the RAW output layer stays MAF-free (``outbox.py`` may not import
this module).
"""
return [{"name": call.name, "bundle_id": call.bundle_id, "path": call.path} for call in calls]
def trace_payload(
trace: ExplorationTrace, *, stop: str | None, completed: bool, mandate: Mandate | None
) -> dict[str, Any]:
@ -442,10 +467,7 @@ def trace_payload(
}
for call in trace.quick_validations
],
"tool_calls": [
{"name": call.name, "bundle_id": call.bundle_id, "path": call.path}
for call in trace.tool_calls
],
"tool_calls": tool_call_payload(trace.tool_calls),
}
@ -859,7 +881,9 @@ def _index_excerpt(body: str) -> tuple[str, bool]:
return (body[:cut] if cut > 0 else body[:_CATALOGUE_EXCERPT_CHARS]), True
def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
def navigator_tools(
bundle_dirs: Sequence[str], *, dimension: str | None = None
) -> list[FunctionTool]:
"""The navigator's three tools: survey the catalogue, open one base, read one document.
Progressive disclosure, not stuffing (målbilde §2/§4): ``list_bundles`` never returns content,
@ -898,6 +922,13 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
The listing is built from ``Bundle.context_files``, which EXCLUDES the ``type: verdict`` layer
by construction prior verdicts reach a hypothesis only through the gated ExpeL fold inside
``run_project``, never by being read as context here.
**``dimension`` scopes BOTH rungs, and both halves are the promise** (§4.1a, carried over in
S2c when the DEBATE started navigating instead of being handed ``bundle_context``). The filter
used to live in the rendering; with navigation it has to live in the tools, and a listing that
hides a foreign-dimension document while ``read_file`` still serves it by path is a filter in
name only a model-chosen path is untrusted input, so the gate belongs where the bytes leave.
``None`` (the exploration's own call) admits everything, byte-identical to before.
"""
index = _bundle_index(bundle_dirs)
@ -921,7 +952,9 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
"id": bundle_id,
"index_excerpt": excerpt,
"index_truncated": truncated,
"documents": len(bundle.context_files),
"documents": sum(
1 for f in bundle.context_files if okf.in_dimension(f, dimension)
),
"verdict_count": len(bundle.verdicts),
# Tolerant on CONTENT, fail-fast on the PATH: an operator's bad directory is
# refused by navigate_bundle above, while a navigable base that simply has no
@ -953,7 +986,7 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
# ONE renderer for both rungs (kø-(p)): this tool and ``read_dir`` differ only in WHICH
# level they ask for, and two copies of a listing rule would drift into two answers about
# one bundle. ``okf`` owns it, so the context seam stays framework-neutral.
return okf.directory_listing(bundle)
return okf.directory_listing(bundle, dimension=dimension)
@tool(
name="read_dir",
@ -968,7 +1001,7 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
bundle_dir = _resolve_bundle(index, bundle_id)
bundle = okf.navigate_bundle(bundle_dir)
okf.assert_declared_ids_agree(bundle)
return okf.directory_listing(bundle, path)
return okf.directory_listing(bundle, path, dimension=dimension)
@tool(
name="read_file",
@ -979,7 +1012,30 @@ def navigator_tools(bundle_dirs: Sequence[str]) -> list[FunctionTool]:
# 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
# navigation walk uses rather than a second, laxer check.
return Path(safe_resolve(bundle_dir, path)).read_text(encoding="utf-8")
resolved = Path(safe_resolve(bundle_dir, path))
if dimension is not None:
# The SECOND half of the scope (§4.1a). A listing that hides a document while this rung
# still serves it by path is a filter in name only, and the caller here is a model that
# can name a path no listing gave it. Only a NAVIGATED concept file is judged: the walk
# is what knows a file's declared dimension, and a path outside it is already refused —
# or, for ``index.md``, is navigation rather than scoped knowledge.
bundle = okf.navigate_bundle(bundle_dir)
foreign = next(
(
f
for f in bundle.context_files
if Path(safe_resolve(bundle_dir, f.name)) == resolved
and not okf.in_dimension(f, dimension)
),
None,
)
if foreign is not None:
raise DimensionScopeRefused(
f"document {path!r} in knowledge base {bundle_id!r} declares dimension "
f"{foreign.frontmatter.get('dimension')!r}; this run is scoped to "
f"{dimension!r} and reads only knowledge in scope"
)
return resolved.read_text(encoding="utf-8")
return [list_bundles, read_bundle, read_dir, read_file]