feat(inbox): walk the drop directory recursively

Door B listed `inbox.iterdir()` and kept only top-level files. A file in a
subdirectory was neither ingested nor refused: it appeared in none of the
result's buckets, so a nested drop produced a bundle that was silently short
of what was dropped and no count said so. That broke the K1b identity for any
inbox with folders in it. Operator decision 2026-09-06.

- `walk_inbox` is the ONE walk rule, shared with `tools/okf_corpus_run.py`:
  the denominator N is now counted over exactly the set of files the door
  ingests, rather than over a second listing that happened to agree.
- Sorted on the whole relative path, not the basename, so the order is a
  function of the tree; that is what keeps rebuild-from-scratch byte-equal to
  an incremental update.
- A concept's `source_file` is the path relative to the inbox root,
  `/`-separated. The concept NAME still comes from the basename, so two
  folders holding one basename hit the existing §3 collision refusal instead
  of one silently claiming the other's concept.
- Dot-directories and a bundle directory inside the inbox are skipped with a
  CODE, in a new `InboxResult.skipped`. Recursion makes the door's own output
  reachable as its own input; a silent skip would be the same
  absence-without-a-denominator defect one level down.
- `--path-prefix` reduces per component and rejoins with `/`, so the caller
  driving a nested corpus can carry the relative directory. Reducing the whole
  string folded the separator into a `-` and flattened `sub/sub2`.

`tests/test_inbox_flow.py::test_subdirectories_are_not_walked` asserted the
opposite and is superseded in place, with the reason written down.

Measured on the K2 corpus (flat, N=43): 39/43 merged, 4 coded, K1b holds. The
bundle digest is
`1472e98aec8643c5beee540f4c42b5e437bd26e7c61d69a91bcff799f06a6d13` over 1108
files -- byte-identical to a run of the same corpus at 190086f WITHOUT this
change (`diff -r` exit 0), so recursion costs a flat inbox nothing. It differs
from the stored 2026-09-03 artifact by one line in `index.md`
(`- [Corpus run history](log.md)`), which 95eb271 added 15 hours after that
bundle was built.

Suite 1113 passed, `ruff` clean, `mypy --strict src/ tools/` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-07 04:11:00 +02:00
commit aa87eb8818
11 changed files with 489 additions and 39 deletions

View file

@ -52,6 +52,43 @@ def test_the_denominator_is_the_directory_not_a_literal(tmp_path: Path) -> None:
assert report.n == len(list(root.iterdir()))
def test_the_denominator_counts_files_in_subdirectories_too(tmp_path: Path) -> None:
"""N is the corpus, and the corpus is the whole tree.
The measurement and the door must walk by the SAME rule -- `walk_inbox` is
the one implementation -- or the report would state a denominator over a
different set of files than the one that was ingested, and the
conservation identity would hold over the wrong N.
"""
root = corpus(tmp_path, {"a.md": SUBSTANTIVE})
(root / "sub").mkdir()
(root / "sub" / "b.md").write_text(SUBSTANTIVE, encoding="utf-8", newline="")
report = okf_corpus_run.measure(root, tmp_path / "bundle", ingested_at=INGESTED_AT)
assert report.n == 2
assert report.persisted == 2
# `unaccounted` is `dropped - merged - coded`: it can only be empty if the
# measurement's names and the door's names are the SAME strings, which is
# what pins the two walks to one rule rather than to two that agree today.
assert report.unaccounted == ()
# Re-extracted through `corpus / source_file`, so the relative name has to
# resolve back to the file it came from.
assert report.substantive == 2
def test_the_denominator_skips_a_bundle_written_inside_the_corpus(tmp_path: Path) -> None:
root = corpus(tmp_path, {"a.md": SUBSTANTIVE})
bundle = root / "bundle"
okf_corpus_run.measure(root, bundle, ingested_at=INGESTED_AT)
second = okf_corpus_run.measure(root, bundle, ingested_at=INGESTED_AT)
assert second.n == 1
assert second.persisted == 1
assert second.unaccounted == ()
def test_the_conservation_identity_holds_and_the_run_exits_zero(tmp_path: Path) -> None:
root = corpus(
tmp_path,

View file

@ -82,3 +82,30 @@ def test_the_readme_still_states_what_stays_out() -> None:
text = README.read_text(encoding="utf-8")
assert ".doc`" in text or "Word 97" in text
assert ".doc" not in set(_PANDOC_FORMATS)
def test_the_readme_recursion_claim_matches_the_door() -> None:
"""The README says the drop directory is walked recursively. A sentence is
not a mechanism, so both halves are asserted here: the claim is in the
prose, and the door actually does it. Either one alone can go stale --
prose that outlived the code is the failure this whole module exists for.
"""
import tempfile
text = README.read_text(encoding="utf-8")
assert "walked **recursively**" in text
from llm_ingestion_okf.inbox import GateDecision, process_inbox
with tempfile.TemporaryDirectory() as workspace:
inbox = Path(workspace) / "inbox" / "sub"
inbox.mkdir(parents=True)
(inbox / "deep.md").write_text("Body\n", encoding="utf-8")
result = process_inbox(
Path(workspace) / "inbox",
Path(workspace) / "bundle",
"2026-09-07T08:00:00Z",
okf_type="reference",
gate=lambda body: GateDecision(sanitized_text=body, disposition="warn", reasons=()),
)
assert [item.source_file for item in result.persisted] == ["sub/deep.md"]

View file

@ -421,14 +421,21 @@ def test_an_empty_inbox_writes_nothing(tmp_path: Path) -> None:
assert not bundle.exists() or list(bundle.iterdir()) == []
def test_subdirectories_are_not_walked(tmp_path: Path) -> None:
"""Top-level only, like every other inbox in this ecosystem: a nested tree
is the operator's structure, not ours to flatten into one namespace.
def test_subdirectories_are_walked(tmp_path: Path) -> None:
"""Superseded, deliberately, by the operator's decision of 2026-09-06.
This test used to assert the opposite -- top-level only, "the operator's
nested structure is theirs". The rule it pinned was not a boundary, it was
a silent loss: a file under a folder appeared in NONE of the result's
buckets, so the count said nothing was there rather than that nothing was
looked at. The nested structure is still the operator's, and it survives
in the recorded `source_file`; it is the disappearance that is gone. The
rule's own suite is `tests/test_inbox_recursion.py`.
"""
drop(tmp_path / "inbox" / "nested", "deep.md", "Body\n")
drop(tmp_path / "inbox", "top.md", "Body\n")
result, bundle = run(tmp_path, StubGuard())
assert [entry.source_file for entry in result.persisted] == ["top.md"]
assert not (bundle / "inbox-deep.md").exists()
assert [entry.source_file for entry in result.persisted] == ["nested/deep.md", "top.md"]
assert (bundle / "inbox-deep.md").is_file()

View file

@ -0,0 +1,205 @@
"""Door B walks the inbox RECURSIVELY (order 20260906T220349Z-6526414037).
Until this suite the door listed `inbox.iterdir()` and kept only top-level
files. A file in a subdirectory was neither ingested nor refused: it did not
appear in any of the result's buckets, so a bundle built from a nested drop
was silently short and no count said so. That is the absence-without-a-
denominator failure, and it broke the K1b identity (bundle == what was
dropped) for every operator whose inbox has folders in it.
What is pinned here:
- a file at any depth is ingested, and its `source_file` is the path RELATIVE
to the inbox root, `/`-separated, so two documents that share a basename in
different folders are still distinguishable in the provenance layer;
- the walk order is the sorted relative path, which is what keeps a
rebuild-from-scratch byte-identical to an incremental update (the K6 gate);
- a dot-directory and a bundle directory sitting INSIDE the inbox are skipped
with a CODE, never silently the door's own output must not be re-ingested
as input, and an operator has to be able to see that it was not;
- the generated concept name still comes from the BASENAME, so two nested
files reducing to one name hit the existing §3 collision refusal rather
than one silently claiming the other's concept.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from llm_ingestion_okf.inbox import (
SKIPPED_BUNDLE_DIRECTORY,
SKIPPED_DOT_DIRECTORY,
GateDecision,
process_inbox,
)
from llm_ingestion_okf.profiles import STRUCTURED_V1
INGESTED_AT = "2026-09-07T08:00:00Z"
def _gate(text: str) -> GateDecision:
return GateDecision(sanitized_text=text, disposition="warn", reasons=())
def _run(inbox: Path, bundle: Path, **kwargs: object) -> object:
return process_inbox(
inbox,
bundle,
INGESTED_AT,
okf_type="reference",
gate=_gate,
**kwargs, # type: ignore[arg-type]
)
def _drop(inbox: Path, relative: str, body: str) -> None:
path = inbox / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def test_a_file_in_a_subdirectory_is_ingested_and_carries_its_relative_path(
tmp_path: Path,
) -> None:
inbox = tmp_path / "inbox"
inbox.mkdir()
_drop(inbox, "top.md", "Top level.\n")
_drop(inbox, "sub/one.md", "One deep.\n")
_drop(inbox, "sub/sub2/two.md", "Two deep.\n")
result = _run(inbox, tmp_path / "bundle")
assert [item.source_file for item in result.persisted] == [ # type: ignore[attr-defined]
"sub/one.md",
"sub/sub2/two.md",
"top.md",
]
assert result.failed == () # type: ignore[attr-defined]
assert result.rejected == () # type: ignore[attr-defined]
concept = (tmp_path / "bundle" / "inbox-two.md").read_text(encoding="utf-8")
assert "source_file: sub/sub2/two.md" in concept
def test_the_walk_order_is_the_sorted_relative_path(tmp_path: Path) -> None:
"""Sorted on the WHOLE relative path, not the basename.
Sorting on the basename would order `b/a.md` before `a/z.md`, which makes
the order depend on names rather than on the tree two inboxes holding
the same files would walk them differently and the index would differ.
"""
inbox = tmp_path / "inbox"
inbox.mkdir()
_drop(inbox, "b/aaa.md", "B then aaa.\n")
_drop(inbox, "a/zzz.md", "A then zzz.\n")
result = _run(inbox, tmp_path / "bundle")
assert [item.source_file for item in result.persisted] == [ # type: ignore[attr-defined]
"a/zzz.md",
"b/aaa.md",
]
def test_a_dot_directory_is_skipped_with_a_code(tmp_path: Path) -> None:
inbox = tmp_path / "inbox"
inbox.mkdir()
_drop(inbox, "kept.md", "Kept.\n")
_drop(inbox, ".git/config.md", "Not a document.\n")
_drop(inbox, ".git/objects/deep.md", "Not a document either.\n")
result = _run(inbox, tmp_path / "bundle")
assert [item.source_file for item in result.persisted] == ["kept.md"] # type: ignore[attr-defined]
assert [(item.path, item.code) for item in result.skipped] == [ # type: ignore[attr-defined]
(".git", SKIPPED_DOT_DIRECTORY)
]
def test_a_bundle_directory_inside_the_inbox_is_skipped_with_a_code(tmp_path: Path) -> None:
"""The door's own output is not its own input.
With a flat listing a nested bundle was invisible by accident. Recursion
removes that accident: without this skip the second run would extract the
concepts written by the first, ingest them as documents, and grow the
bundle on every run.
"""
inbox = tmp_path / "inbox"
inbox.mkdir()
bundle = inbox / "bundle"
_drop(inbox, "doc.md", "A document.\n")
first = _run(inbox, bundle)
assert [item.source_file for item in first.persisted] == ["doc.md"] # type: ignore[attr-defined]
assert first.skipped == () # type: ignore[attr-defined]
second = _run(inbox, bundle)
assert [item.source_file for item in second.persisted] == ["doc.md"] # type: ignore[attr-defined]
assert [(item.path, item.code) for item in second.skipped] == [ # type: ignore[attr-defined]
("bundle", SKIPPED_BUNDLE_DIRECTORY)
]
def test_two_nested_files_sharing_a_basename_are_refused_not_silently_merged(
tmp_path: Path,
) -> None:
"""The concept name still comes from the basename, so this is a collision.
Stated as a test rather than left implicit: recursion makes it reachable
for the first time, and the answer is the §3 refusal the door already
gives never a winner picked by walk order.
"""
inbox = tmp_path / "inbox"
inbox.mkdir()
_drop(inbox, "a/same.md", "First.\n")
_drop(inbox, "b/same.md", "Second.\n")
result = _run(inbox, tmp_path / "bundle")
assert result.persisted == () # type: ignore[attr-defined]
assert [item.source_file for item in result.failed] == ["a/same.md", "b/same.md"] # type: ignore[attr-defined]
assert {item.error.code for item in result.failed} == {"inbox_slug_collision"} # type: ignore[attr-defined]
def _tree_hash(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
digest.update(path.relative_to(root).as_posix().encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def test_a_nested_rebuild_from_scratch_equals_the_incremental_bundle(tmp_path: Path) -> None:
"""The K6 identity, over an inbox with subdirectories.
The faceted index is a projection of the WHOLE bundle, so it is exactly
where a walk order that depended on arrival would show up. Building one
bundle in two rounds and another in one must produce the same bytes.
"""
inbox = tmp_path / "inbox"
inbox.mkdir()
_drop(inbox, "sub/one.md", "# Ett\n\nFirst document.\n")
_drop(inbox, "sub/sub2/two.md", "# To\n\nSecond document.\n")
incremental = tmp_path / "incremental"
_run(inbox, incremental, profile=STRUCTURED_V1)
_drop(inbox, "three.md", "# Tre\n\nThird document.\n")
_run(inbox, incremental, profile=STRUCTURED_V1)
scratch = tmp_path / "scratch"
_run(inbox, scratch, profile=STRUCTURED_V1)
# The control FIRST: two empty trees hash the same, so an equality that
# ran before recursion existed would have been a green over nothing.
assert sorted(path.name for path in scratch.rglob("*.md")) == [
"inbox-one.md",
"inbox-three.md",
"inbox-two.md",
"index.md",
]
assert _tree_hash(incremental) == _tree_hash(scratch)

View file

@ -304,6 +304,41 @@ def test_a_path_prefix_scopes_every_entry_under_one_directory(tmp_path: Path) ->
assert parse_segmentation_plan(payload).entries
def test_a_multi_component_prefix_carries_the_relative_directory(tmp_path: Path) -> None:
"""Door B now walks the inbox recursively and records a `source_file`
relative to its root, so the caller driving a nested corpus has a
DIRECTORY, not a name, to scope by. Each component is reduced on its own
and rejoined with `/`: reducing the whole string would fold the separator
into a `-` and flatten `sub/sub2` into one component named `sub-sub2`,
which is a different bundle shape than the inbox it came from.
"""
out = tmp_path / "plan.json"
assert (
okf_propose_segments.main(
[str(write(tmp_path)), "--out", str(out), "--path-prefix", "Bilag 3/Del II"]
)
== 0
)
payload = json.loads(out.read_text(encoding="utf-8"))
assert payload["entries"]
for entry in payload["entries"]:
assert entry["path"].startswith("bilag-3/del-ii/")
assert parse_segmentation_plan(payload).entries
def test_a_prefix_with_one_empty_component_is_refused(tmp_path: Path) -> None:
"""`a//b` and `a/###/b` are the multi-component form of the same defect the
single-component gate already refuses: an empty component would collapse
the path silently rather than scope it.
"""
for prefix in ("sub//two", "sub/###/two", "/sub", "sub/"):
code = okf_propose_segments.main(
[str(write(tmp_path)), "--out", str(tmp_path / "p.json"), "--path-prefix", prefix]
)
assert code == 2, prefix
assert not (tmp_path / "p.json").exists(), prefix
def test_without_a_prefix_the_artifact_is_byte_identical(tmp_path: Path) -> None:
"""Additive. Every plan already produced stays exactly what it was."""
first = tmp_path / "a.json"