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 at190086fWITHOUT 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)`), which95eb271added 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:
parent
190086fc3d
commit
aa87eb8818
11 changed files with 489 additions and 39 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
205
tests/test_inbox_recursion.py
Normal file
205
tests/test_inbox_recursion.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue