fix(corpus): an unreadable path in the corpus was reported as an absent one - #46
Conversation
34a4109 to
e80fa49
Compare
e80fa49 to
375ca7f
Compare
Gate round 1 — findings confirmed and fixed, fix reworked twiceAdversarial review ( Before opening, I inverted the fix. The original was skip-and-log. Tracing the blast radius against CONFIRMED (correctness), from the review — the guard was bypassed for every stat-level failure. CONFIRMED (correctness) — the nested case under-reported and named an unfixable path. CONFIRMED (test-gap) — 3 surviving mutants, all now killed: stat outside the guard; an CONFIRMED (overstatement) — the message said "or exclude them", but no exclusion knob exists at or before this stage; CONFIRMED (robustness) — the listing was unbounded: 20k unreadable files measured 2.66 MB of stderr. Capped at 50 plus a count. Refuted attacks (fix survives): Not demonstrated, and worth a human's eye: whether hard-blocking can break a corpus where unreadable paths are normal. The reviewer swept a real Google Drive FUSE mirror (4001 dirs, One gap stated rather than papered over: dropping the pre-existing Verdict: CLEAN after fixes. Full CI-equivalent suite green locally, 13 mutants killed, all CI checks green. Proceeding to gate round 2 given how much the fix changed. |
…nt one Absence from files.jsonl is not neutral: the id drops out of inventory.json and clean's classify_pending emits reason="deleted", driving remove_page + delete_facts (state.py:125, main.py:148). Disk presence is never consulted, and there is no proportional mass-deletion guard. So any path enumerate silently omits is a document it silently destroys. Four ways that happened: - An unreadable FILE let `_md5` raise straight out of the loop, so the stage died on the FIRST one and an operator repairing several learned about them one per full walk-and-hash of the corpus. - An unreadable DIRECTORY was silent: `os.walk` defaults to `onerror=None`, which swallows a failed listdir and yields nothing, so an org-unit folder dropped every document beneath it with no error. - `os.path.isfile` ran BEFORE any guard and swallows OSError internally, returning False, so every stat-level failure was skipped silently at exit 0 — EACCES via a non-traversable parent, EIO, ESTALE. A directory with mode 0o400 is the sharp case: the `r` bit lets os.walk list the names so `onerror` never fires, and every file under it vanished. - An ANCESTOR renamed mid-walk made every not-yet-stat'd file under it raise ENOENT while the documents stayed alive under the new name, which the walk never visits (that name was not in the listing os.walk already took). One rename, a whole folder reported deleted. The stat now happens inside the guard (with an explicit S_ISREG check inheriting isfile's other job: `_md5` would otherwise block forever on a fifo, and raise a spurious fatal IsADirectoryError for a directory that os.walk could not classify), `onerror` feeds the same report, and the stage raises once naming every offending path. Directories are listed first because one directory entry can stand for thousands of lost documents and the 50-path cap must not truncate it away in favour of 50 files — that would resurrect the one-per-re-walk pathology through the cap. ENOENT is not fatal only when the parent os.walk handed us survived, which is what distinguishes "this file vanished" from "an ancestor moved". The carve-out exists because taxonomy.json's system-noise verdict covers .crdownload/.download/.tmp: the project states in-flight sync artifacts live in corpora, and vanishing is what they do, so failing there would let a half-finished download block the pipeline. A vanished DIRECTORY stays fatal, deliberately: for a file we can prove the file itself went away, but "removed" and "renamed" are indistinguishable for a directory, and guessing wrong deletes every document beneath it. Between blocking the pipeline and deleting data, blocking is the recoverable one; regression tests pin that direction in both branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
375ca7f to
1dda33d
Compare
Gate round 2 — the ENOENT carve-out was refuted; fixed. Not merging: 2 rounds used, this needs your call.Round 2 attacked the newest decision (the ENOENT carve-out) and broke it with an executed reproduction. Findings are fixed and pushed, but per the loop's own rule — two gate rounds is the limit, and this round found confirmed correctness defects — I am leaving this open for you rather than merging it. CONFIRMED (correctness, highest) — the carve-out silently deleted live documents. My justification, "ENOENT is the one case where absence is simply true," conflated this path is gone with this document is gone. Rename an ancestor directory mid-walk and every not-yet-stat'd file under it raises ENOENT while the documents sit alive under the new name — which the walk never visits, because that name wasn't in the listing With a single-unit corpus the stage returns Fixed with the discriminator the review identified: ENOENT is absence only while CONFIRMED (overstatement) — the cap could hide the one entry that mattered. The prefix was walk order, not blast radius, so 50 unreadable files in an early unit truncated away a later unreadable directory hiding 5000 documents — the one-per-re-walk pathology reappearing through my own cap. Directories now lead the report. Header wording fixed: it asserted paths "exist but could not be read", which is false for a vanished directory. Now "could not be enumerated", with "or re-run once the corpus has stopped changing". Partially accepted, and I'll say why I didn't follow it: round 2 argued a vanished directory should get the same non-fatal treatment for symmetry with Both test gaps closed (an absolute-path dir entry satisfied a substring assertion; the cap's Round-1 fixes all survived independent re-attack, with two useful confirmations: Mutation set: 13 mutants, zero survivors, including every mutant round 2 found surviving. Why this wants a humanThe fix was reworked three times and each round found the previous version wrong — first in direction (skip → fail), then in reach (the
All 10 CI checks were green on the previous commit; the current one is pushed and re-running. |
Found by an autonomous
bughuntsweep.Bug
Absence from
files.jsonlis not neutral. The id drops out ofinventory.json, andclean'sclassify_pendingthen emitsreason: "deleted"(state.py:123— membership only, disk presence is never consulted), which drivesremove_page+delete_facts(main.py:150). So any pathenumerate-filessilently omits is a document it silently destroys. Three ways that happened:_md5raise straight out of the loop, killing the stage on the first one — after the whole corpus was already walked and hashed. Repairing several meant one discovery per full re-walk. The corpus is mounted read-only by contract (paths.py), sochmodmay not even be available.os.walkdefaults toonerror=None, which swallows a failedlistdirand yields nothing, so an org-unit folder dropped every document beneath it with no error anywhere.os.path.isfileran before any guard, and it swallowsOSErrorinternally and returnsFalse. Every stat-level failure was therefore skipped silently at exit 0 — EACCES via a non-traversable parent, EIO, ESTALE, and the real listdir→stat race. A directory with mode0o400is the sharp case: therbit letsos.walklist the names soonerrornever fires, and every file under it vanished.Verified end to end against
examples/demo-corpus—chmod 000on one file droppedlocal-8cb904b68e2bfrom the inventory and produced adeletedverdict for it while the source sat untouched on disk. For a directory, three documents at once.Fix
The
statmoves inside the guard (with an explicitS_ISREGcheck inheritingisfile's other job — opening a fifo blocks the stage forever),onerrorfeeds the same list, and the stage raises once naming every offending path — capped at 50, since an unreadable mount root otherwise produced a 2.66 MB stderr message. Type isPathError, whichcli.pyalready renders asERROR: …with exit 2; no CLI change.Failing rather than skipping is the load-bearing decision. An earlier version of this fix skipped-and-logged; that is precisely the silent-deletion path above, so a transient permission blip or EIO would have destroyed a page and every figure extracted from it.
ENOENT is deliberately not fatal — the one case where absence is simply true: the file was listed and is now gone.
taxonomy.json's system-noise verdict covers.crdownload/.download/.tmp, i.e. the project states in-flight sync artifacts live in corpora, and vanishing is what they do; failing there would let a half-finished download block the pipeline. Every other errno means exists but unreadable, where reporting absence would be a lie.Between blocking the pipeline and deleting data, blocking is the recoverable one. A regression test pins that direction so a future "it shouldn't stop for one file" change has to confront the deletion it would cause.
Test
+8 in
pipeline/corpus/tests/test_enumerate_inventory.py(permission-based ones skipped under root): all unreadable files reported in one pass; two unreadable directories in one pass; a stat-level failure guarded (flat0o400, no monkeypatching); a file that vanished mid-walk treated as absent, via a real sibling race; non-regular files still skipped;OSErrorbreadth across EIO/ESTALE and narrowness vs a hashing bug; the cap and the report's ordering; and the test that documents why failing is the safe direction.Full CI-equivalent suite green locally (7 packages, ruff, golden evals 25/25 with both
curation:metrics unchanged); corpus 57 tests, +8.Mutation-tested with hash-verified restores:
main(raise on first file)os.path.isfilebefore the try (stat unguarded)onerrorre-raises (abort on first bad dir)onerrorswallows instead of recordsonerrorentirelyPermissionErrorexcept Exceptioninstead ofOSErrorS_ISREGguardsorted(names)/One known gap, stated rather than papered over: dropping the pre-existing
dirs.sort()survives, because pinning cross-directory order needs control ofreaddirorder that a portable test cannot assume. The within-directory half is pinned.🤖 Generated with Claude Code