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
|
|
@ -13,7 +13,14 @@ one boundary rule:
|
|||
needs go via commons, never edited locally. The library ships the §11
|
||||
golden fixtures (byte-exact) for the three door-A source types
|
||||
(`ingest-golden-{file,sql,http}/`, shipped in `9dd86b1`).
|
||||
- **Door B — bundle inbox:** converts dropped files to OKF concepts. All
|
||||
- **Door B — bundle inbox:** converts dropped files to OKF concepts. The drop
|
||||
directory is walked RECURSIVELY, sorted by relative path, and a concept's
|
||||
`source_file` is that relative path (`/`-separated) while its NAME still
|
||||
comes from the basename — so a nested duplicate hits the §3 collision
|
||||
refusal rather than vanishing. Dot-directories and a bundle nested inside
|
||||
the inbox are skipped with a code, never silently, because recursion makes
|
||||
the door's own output reachable as its own input (operator 2026-09-06; the
|
||||
flat listing was not a boundary, it was an absence with no denominator). All
|
||||
file-type→text extraction lives HERE (the guard is text-only). v1 core:
|
||||
`md`, `txt`, `csv`, `json`, `html` (stdlib). `pdf`/`docx`/`xlsx` only via
|
||||
the optional `[extract]` extra; without it those types are rejected
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ bundle:
|
|||
`pdf` and the five office formats (`docx`, `xlsx`, `pptx`, `odt`, `rtf`)
|
||||
require the optional `[extract]` extra and are rejected fail-fast without
|
||||
it. Extracted text passes the security gate before anything is persisted.
|
||||
The drop directory is walked **recursively**, in sorted relative-path order:
|
||||
a file at any depth is ingested and records its path relative to the inbox
|
||||
root as its `source_file`, while dot-directories and a bundle directory
|
||||
sitting inside the inbox are skipped with a reported code.
|
||||
|
||||
<!-- extract-formats: .md, .txt, .csv, .json, .html, .htm, .pdf, .docx, .xlsx, .pptx, .odt, .rtf -->
|
||||
3. **External bundle import.** Import and merge of third-party OKF bundles:
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from .inbox import (
|
|||
GateDecision,
|
||||
InboxResult,
|
||||
PersistedFile,
|
||||
SkippedPath,
|
||||
process_inbox,
|
||||
)
|
||||
from .importer import (
|
||||
|
|
@ -101,6 +102,7 @@ __all__ = [
|
|||
"MergedConcept",
|
||||
"NetworkGateError",
|
||||
"PersistedFile",
|
||||
"SkippedPath",
|
||||
"RefusedConcept",
|
||||
"RenderError",
|
||||
"SourceError",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ supplies the verdict and this module only obeys it.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import unicodedata
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
|
|
@ -275,6 +276,26 @@ class FailedFile:
|
|||
error: IngestError
|
||||
|
||||
|
||||
#: Why the walk refused to descend into a directory. A CODE rather than a log
|
||||
#: line: a skipped directory is an outcome the caller has to be able to count,
|
||||
#: and "we found nothing under here" is a measurement, not a fact about the
|
||||
#: inbox.
|
||||
SKIPPED_DOT_DIRECTORY = "skipped_dot_directory"
|
||||
SKIPPED_BUNDLE_DIRECTORY = "skipped_bundle_directory"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedPath:
|
||||
"""A directory the recursive walk did not descend into, and why.
|
||||
|
||||
`path` is relative to the inbox root, `/`-separated, exactly like a
|
||||
concept's `source_file`.
|
||||
"""
|
||||
|
||||
path: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboxResult:
|
||||
"""Every dropped file's outcome, in sorted filename order.
|
||||
|
|
@ -294,6 +315,72 @@ class InboxResult:
|
|||
# consumer's number means. Without segmentation the two are equal, which is
|
||||
# what makes this additive rather than a second thing to keep in step.
|
||||
concepts: tuple[PersistedFile, ...] = ()
|
||||
# Directories the walk refused to enter, each with its code. Additive and
|
||||
# last, so every existing positional construction and every existing
|
||||
# consumer's four buckets keep their meaning: a skipped directory holds no
|
||||
# dropped FILE outcome, it explains a set of files that were never dropped.
|
||||
skipped: tuple[SkippedPath, ...] = ()
|
||||
|
||||
|
||||
def relative_source(path: Path, inbox: Path) -> str:
|
||||
"""A dropped file's name as the provenance layer records it.
|
||||
|
||||
Relative to the inbox root and `/`-separated, so the recorded name is the
|
||||
same on every platform and two documents sharing a basename in different
|
||||
folders stay distinguishable. For a flat inbox this is the bare filename,
|
||||
which is why every existing bundle's bytes are unchanged.
|
||||
"""
|
||||
return path.relative_to(inbox).as_posix()
|
||||
|
||||
|
||||
def walk_inbox(
|
||||
inbox: Path, *, exclude: Path | None = None
|
||||
) -> tuple[tuple[Path, ...], tuple[SkippedPath, ...]]:
|
||||
"""Every dropped file at any depth, sorted by relative path, plus the skips.
|
||||
|
||||
ONE implementation, shared with `tools/okf_corpus_run.py`: the corpus
|
||||
measurement counts the denominator N, and a walk that disagreed with the
|
||||
door's would report a count over a different set of files than the one that
|
||||
was ingested.
|
||||
|
||||
Sorted on the whole relative path rather than the basename -- the order has
|
||||
to be a function of the TREE, or two inboxes holding the same documents
|
||||
would walk them differently and their indexes would diverge.
|
||||
|
||||
Two directories are refused, both with a code. A dot-directory is
|
||||
machinery, not documents. `exclude` is the bundle: with a flat listing a
|
||||
bundle nested inside the inbox was invisible by accident, and recursion
|
||||
removes the accident -- without the skip the door would extract its own
|
||||
concepts and ingest them as documents on the next run.
|
||||
"""
|
||||
excluded = exclude.resolve() if exclude is not None else None
|
||||
dropped: list[Path] = []
|
||||
skipped: list[SkippedPath] = []
|
||||
for parent_name, dirnames, filenames in os.walk(inbox):
|
||||
parent = Path(parent_name)
|
||||
kept: list[str] = []
|
||||
for name in sorted(dirnames):
|
||||
child = parent / name
|
||||
if name.startswith("."):
|
||||
skipped.append(
|
||||
SkippedPath(path=relative_source(child, inbox), code=SKIPPED_DOT_DIRECTORY)
|
||||
)
|
||||
elif excluded is not None and child.resolve() == excluded:
|
||||
skipped.append(
|
||||
SkippedPath(path=relative_source(child, inbox), code=SKIPPED_BUNDLE_DIRECTORY)
|
||||
)
|
||||
else:
|
||||
kept.append(name)
|
||||
# In place: this is how `os.walk` is told not to descend, and a skipped
|
||||
# directory must not be entered at all rather than entered and filtered.
|
||||
dirnames[:] = kept
|
||||
for name in filenames:
|
||||
child = parent / name
|
||||
if child.is_file():
|
||||
dropped.append(child)
|
||||
dropped.sort(key=lambda item: relative_source(item, inbox))
|
||||
skipped.sort(key=lambda item: item.path)
|
||||
return (tuple(dropped), tuple(skipped))
|
||||
|
||||
|
||||
def _is_inbox_owned(path: Path) -> bool:
|
||||
|
|
@ -454,6 +541,7 @@ def _render_segments(
|
|||
gate: Gate,
|
||||
profile: BundleProfile,
|
||||
bundle_id: str,
|
||||
source_file: str,
|
||||
) -> BlockedFile | None:
|
||||
"""Render every segment, or refuse the WHOLE document.
|
||||
|
||||
|
|
@ -467,7 +555,7 @@ def _render_segments(
|
|||
A refusal is therefore reported once, for the document, rather than once
|
||||
per segment: the operator's unit of review is the document they dropped.
|
||||
"""
|
||||
extractor_id = Path(path.name).suffix.lower().lstrip(".") or "none"
|
||||
extractor_id = path.suffix.lower().lstrip(".") or "none"
|
||||
assert_plan_applies(
|
||||
plan,
|
||||
source_sha256=hashlib.sha256(source_bytes).hexdigest(),
|
||||
|
|
@ -492,7 +580,7 @@ def _render_segments(
|
|||
]
|
||||
if refused:
|
||||
return BlockedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_file,
|
||||
disposition=refused[0].disposition,
|
||||
reasons=tuple(reason for decision in refused for reason in decision.reasons),
|
||||
)
|
||||
|
|
@ -500,7 +588,7 @@ def _render_segments(
|
|||
for entry, decision in decisions:
|
||||
structure: DocumentStructure | None = None
|
||||
if profile.index.facets is not None:
|
||||
structure = derive_document_structure(decision.sanitized_text, source_file=path.name)
|
||||
structure = derive_document_structure(decision.sanitized_text, source_file=source_file)
|
||||
_validate_facets(structure, profile)
|
||||
outputs.append(
|
||||
(
|
||||
|
|
@ -512,7 +600,7 @@ def _render_segments(
|
|||
# segment's own first line: the plan is the record of the
|
||||
# judgement, and a heading inside a slice is not it.
|
||||
title=entry.title,
|
||||
source_file=path.name,
|
||||
source_file=source_file,
|
||||
source_bytes=source_bytes,
|
||||
ingested_at=entry.ingested_at,
|
||||
profile=profile,
|
||||
|
|
@ -623,10 +711,16 @@ def process_inbox(
|
|||
inbox = Path(inbox_dir)
|
||||
if not inbox.is_dir():
|
||||
raise SourceError(f"inbox directory does not exist: {inbox}", code="source_root_missing")
|
||||
bundle = Path(bundle_dir)
|
||||
|
||||
# Top-level only, sorted: the operator's nested structure is theirs, and a
|
||||
# deterministic order is what makes a re-run comparable.
|
||||
dropped = sorted((path for path in inbox.iterdir() if path.is_file()), key=lambda p: p.name)
|
||||
# RECURSIVE, sorted by relative path. The operator's nested structure is
|
||||
# theirs to arrange, but it is not theirs to lose: a file under a folder
|
||||
# used to be neither ingested nor refused, so a bundle built from a nested
|
||||
# drop was silently short of what was dropped.
|
||||
dropped, skipped = walk_inbox(inbox, exclude=bundle)
|
||||
|
||||
def source_name(path: Path) -> str:
|
||||
return relative_source(path, inbox)
|
||||
|
||||
persisted: list[PersistedFile] = []
|
||||
concepts: list[PersistedFile] = []
|
||||
|
|
@ -660,9 +754,10 @@ def process_inbox(
|
|||
except OSError as exc:
|
||||
failed.append(
|
||||
FailedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
error=SourceError(
|
||||
f"cannot read dropped file {path.name}: {exc}", code="source_file_missing"
|
||||
f"cannot read dropped file {source_name(path)}: {exc}",
|
||||
code="source_file_missing",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -673,11 +768,16 @@ def process_inbox(
|
|||
matched_hashes.add(covering.source_sha256)
|
||||
targets: tuple[str, ...]
|
||||
if covering is None:
|
||||
# From the BASENAME, not the relative path: the concept name
|
||||
# is the bundle's, and folding a folder into it would rename
|
||||
# every concept the moment an operator tidied their inbox. Two
|
||||
# folders holding the same basename therefore collide, and the
|
||||
# §3 gate below refuses both rather than picking a winner.
|
||||
targets = (inbox_filename(inbox_slug(path.name), profile=profile),)
|
||||
else:
|
||||
targets = tuple(_check_segment_path(item.path) for item in covering.entries)
|
||||
except IngestError as exc:
|
||||
failed.append(FailedFile(source_file=path.name, error=exc))
|
||||
failed.append(FailedFile(source_file=source_name(path), error=exc))
|
||||
continue
|
||||
named.append((path, targets, source_bytes, covering is not None))
|
||||
for target in targets:
|
||||
|
|
@ -707,17 +807,17 @@ def process_inbox(
|
|||
# five colliding paths is one thing the operator has to fix, and five
|
||||
# identical entries would report the same rename five times.
|
||||
for path in sorted(
|
||||
{owner for name in contested for owner in slug_owners[name]}, key=lambda item: item.name
|
||||
{owner for name in contested for owner in slug_owners[name]}, key=source_name
|
||||
):
|
||||
claimed = sorted(name for name in contested if path in slug_owners[name])
|
||||
others = sorted(
|
||||
{other.name for name in claimed for other in slug_owners[name] if other != path}
|
||||
{source_name(other) for name in claimed for other in slug_owners[name] if other != path}
|
||||
)
|
||||
failed.append(
|
||||
FailedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
error=MaterializationError(
|
||||
f"{path.name!r} and {', '.join(repr(other) for other in others)}"
|
||||
f"{source_name(path)!r} and {', '.join(repr(other) for other in others)}"
|
||||
f" both reduce to {', '.join(repr(name) for name in claimed)}"
|
||||
" — rename one; refusing to pick a winner",
|
||||
code="inbox_slug_collision",
|
||||
|
|
@ -728,7 +828,6 @@ def process_inbox(
|
|||
# Phase 2: the §3 ownership scan, evaluated against the bundle as it was
|
||||
# BEFORE this run — a file written below must never be mistaken for
|
||||
# pre-existing curated content by a later file's check.
|
||||
bundle = Path(bundle_dir)
|
||||
owned_by_source: dict[str, set[str]] = {}
|
||||
if profile.segmentation is not None:
|
||||
# RECURSIVE, and only here. A nested concept is invisible to a flat
|
||||
|
|
@ -763,7 +862,7 @@ def process_inbox(
|
|||
if unstamped:
|
||||
failed.append(
|
||||
FailedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
error=MaterializationError(
|
||||
f"generated filename {unstamped[0]!r} collides with an existing file "
|
||||
"that does not carry the inbox marker — refusing to overwrite curated "
|
||||
|
|
@ -776,7 +875,9 @@ def process_inbox(
|
|||
outputs: list[tuple[str, str, tuple[str, ...]]] = []
|
||||
try:
|
||||
text = extract_text(
|
||||
path.name, source_bytes, renderer=_resolve_renderer(profile, path.name)
|
||||
source_name(path),
|
||||
source_bytes,
|
||||
renderer=_resolve_renderer(profile, path.name),
|
||||
)
|
||||
covering = _plan_covering(plans, source_bytes)
|
||||
if covering is not None:
|
||||
|
|
@ -789,6 +890,7 @@ def process_inbox(
|
|||
gate=gate,
|
||||
profile=profile,
|
||||
bundle_id=(root_frontmatter_values or {})[_bundle_id_key(profile)],
|
||||
source_file=source_name(path),
|
||||
)
|
||||
if blocked is not None:
|
||||
if blocked.disposition == _DISPOSITION_QUARANTINE:
|
||||
|
|
@ -800,7 +902,7 @@ def process_inbox(
|
|||
decision = gate(text)
|
||||
if decision.disposition != _DISPOSITION_PERSIST:
|
||||
blocked = BlockedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
disposition=decision.disposition,
|
||||
reasons=decision.reasons,
|
||||
)
|
||||
|
|
@ -819,7 +921,7 @@ def process_inbox(
|
|||
# deriving from bytes the gate rejected would put unscreened
|
||||
# content in the frontmatter and the index.
|
||||
structure = derive_document_structure(
|
||||
decision.sanitized_text, source_file=path.name
|
||||
decision.sanitized_text, source_file=source_name(path)
|
||||
)
|
||||
title = structure.title
|
||||
_validate_facets(structure, profile)
|
||||
|
|
@ -830,7 +932,7 @@ def process_inbox(
|
|||
decision.sanitized_text,
|
||||
okf_type=okf_type,
|
||||
title=title,
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
source_bytes=source_bytes,
|
||||
ingested_at=ingested_at,
|
||||
profile=profile,
|
||||
|
|
@ -842,15 +944,16 @@ def process_inbox(
|
|||
except OSError as exc:
|
||||
failed.append(
|
||||
FailedFile(
|
||||
source_file=path.name,
|
||||
source_file=source_name(path),
|
||||
error=SourceError(
|
||||
f"cannot read dropped file {path.name}: {exc}", code="source_file_missing"
|
||||
f"cannot read dropped file {source_name(path)}: {exc}",
|
||||
code="source_file_missing",
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
except IngestError as exc:
|
||||
failed.append(FailedFile(source_file=path.name, error=exc))
|
||||
failed.append(FailedFile(source_file=source_name(path), error=exc))
|
||||
continue
|
||||
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -859,7 +962,9 @@ def process_inbox(
|
|||
# creates one. Without this the very first hierarchical write fails.
|
||||
(bundle / target_name).parent.mkdir(parents=True, exist_ok=True)
|
||||
written = write_bytes(bundle, target_name, content)
|
||||
concepts.append(PersistedFile(source_file=path.name, path=written, reasons=reasons))
|
||||
concepts.append(
|
||||
PersistedFile(source_file=source_name(path), path=written, reasons=reasons)
|
||||
)
|
||||
# One entry per SOURCE FILE, whatever the document expanded into. That
|
||||
# is what `persisted` has always meant, so an existing consumer's count
|
||||
# does not change under a profile that segments.
|
||||
|
|
@ -870,7 +975,7 @@ def process_inbox(
|
|||
# document owned before and does not now is retired HERE, after the
|
||||
# writes, so a failure above leaves the previous round intact.
|
||||
_retire_stale_segments(
|
||||
bundle, owned_by_source.get(path.name, set()) - set(targets), profile
|
||||
bundle, owned_by_source.get(source_name(path), set()) - set(targets), profile
|
||||
)
|
||||
|
||||
# §6 index — the last disk mutation, and only when something was written.
|
||||
|
|
@ -907,6 +1012,7 @@ def process_inbox(
|
|||
rejected=tuple(rejected),
|
||||
failed=tuple(sorted(failed, key=lambda entry: entry.source_file)),
|
||||
concepts=tuple(concepts),
|
||||
skipped=skipped,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|||
|
||||
from llm_ingestion_okf.errors import IngestError # noqa: E402
|
||||
from llm_ingestion_okf.extract import extract_text # noqa: E402
|
||||
from llm_ingestion_okf.inbox import GateDecision, InboxResult, process_inbox # noqa: E402
|
||||
from llm_ingestion_okf.inbox import ( # noqa: E402
|
||||
GateDecision,
|
||||
InboxResult,
|
||||
process_inbox,
|
||||
relative_source,
|
||||
walk_inbox,
|
||||
)
|
||||
from llm_ingestion_okf.profiles import ( # noqa: E402
|
||||
SEGMENTED_OKF_V0_2,
|
||||
STRUCTURED_V1,
|
||||
|
|
@ -289,7 +295,11 @@ def measure(
|
|||
Keyword-only with defaults, so the flat call that produced the published
|
||||
K1/K2 numbers stays source-compatible and byte-identical.
|
||||
"""
|
||||
dropped = tuple(sorted(path.name for path in corpus.iterdir() if path.is_file()))
|
||||
# ONE walk rule, imported rather than restated: the denominator has to be
|
||||
# counted over exactly the set of files the door ingests, or the
|
||||
# conservation identity would hold over a different N than the run did.
|
||||
walked, _ = walk_inbox(corpus, exclude=bundle)
|
||||
dropped = tuple(relative_source(path, corpus) for path in walked)
|
||||
started = time.monotonic()
|
||||
result = process_inbox(
|
||||
corpus,
|
||||
|
|
|
|||
|
|
@ -612,13 +612,22 @@ def run(
|
|||
# Reduced HERE, before anything is read: a prefix that survives to the
|
||||
# entries as an empty component would produce exactly the unscoped paths
|
||||
# the caller asked to avoid, and would do it silently.
|
||||
scope = reduce_to_id_grammar(path_prefix) if path_prefix else ""
|
||||
if path_prefix and not scope:
|
||||
#
|
||||
# PER COMPONENT, because the prefix carries a DIRECTORY now that Door B
|
||||
# walks the inbox recursively and records a relative `source_file`.
|
||||
# Reducing the whole string would fold `/` into a `-` and flatten
|
||||
# `sub/sub2` into the single component `sub-sub2` -- a bundle shaped unlike
|
||||
# the inbox it came from, and unlike what the caller wrote.
|
||||
components = (
|
||||
[reduce_to_id_grammar(part) for part in path_prefix.split("/")] if path_prefix else []
|
||||
)
|
||||
if path_prefix and not all(components):
|
||||
raise ProposerError(
|
||||
f"--path-prefix {path_prefix!r} reduces to nothing under the id grammar "
|
||||
"([a-z0-9][a-z0-9-]*); refusing to write unscoped paths under a scope "
|
||||
"that was asked for"
|
||||
f"--path-prefix {path_prefix!r} has a component that reduces to nothing under "
|
||||
"the id grammar ([a-z0-9][a-z0-9-]*); refusing to write unscoped paths under a "
|
||||
"scope that was asked for"
|
||||
)
|
||||
scope = "/".join(components)
|
||||
if not source.is_file():
|
||||
raise ProposerError(f"source is not a file: {source}")
|
||||
try:
|
||||
|
|
@ -677,7 +686,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||
"--path-prefix",
|
||||
default="",
|
||||
help=(
|
||||
"scope every entry's path under this directory. Required for a corpus: "
|
||||
"scope every entry's path under this directory, `/`-separated for a "
|
||||
"nested one (each component is reduced on its own). Required for a corpus: "
|
||||
"section numbering is document-local, so two documents propose the same "
|
||||
"path and Door B refuses both. An argument rather than something this "
|
||||
"tool derives -- it sees one document and cannot know what else is in "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue