Retrospectives¶
ℹ️ Version numbers below reflect the convention in use when this was written. Unbuilt work is now named, not numbered (STATUS.md). This record is left as it was.
One section per increment of the project's build plans (plans/), written during that increment's
retrospective review (the workflow is in CLAUDE.md). Only findings worth keeping
land here: a real defect the review caught, or a fact that would be expensive to rediscover. Fixes
themselves live in the commits; this file records what was learned.
Every heading and claim here carries YYYYMMDD HH:MM (local, 24h) — several increments can
land in one day, and a bare date loses their order.
Severity follows the design review's scale: HIGH — wrong behaviour or false confidence; MEDIUM — would block or mislead; LOW — worth remembering, not urgent.
The seven pre-implementation design review passes are at the foot of this file: Design review passes 1–7.
Start here — by what you are about to touch¶
Added 20260801 01:11. Forty-odd sections in date order is an archive, not something anyone reads
before starting work — so this table is the way in. It is keyed on what you are about to do, not
on when the lesson was learned, and it is deliberately short: only the classes that have recurred.
A rule that hardened into a standing instruction lives in
CLAUDE.md or BUILDING.md;
this file is where the evidence for it is.
| About to… | Read | Because |
|---|---|---|
| write a test | I2, L3–L4, L5, L5b | The recurring defect of this whole project: an assertion satisfied by something other than the property it names. A test that could not fail; a field with no assertion is a field that can be a constant; a tidy fixture defeats a mutation test; test the discriminating case, not the two sides separately; a fixture can be right for the wrong reason and hide the defect it was written for |
| claim a test is mutation-verified | L5b, L3–L4 | "Mutation-verified" is a per-assertion claim, never a per-commit one. A failing test proves the mutant is caught, never that it is caught for the stated reason — under -x, or when the failure lands on an earlier assertion, the one encoding the claim never runs |
| touch a sidecar or any YAML | I5, L5b | Writes must be rename-atomic. An explicit empty value was silently deleted on round-trip. Swapping a YAML library is not a swap; existing[:] = keep wipes ruamel's comment metadata outright; a comment before a sequence entry belongs to the entry above it; a warning is not an error, and a library that downgrades one is changing behaviour |
| add a gate or a check | I7a, L3–L4, the eval-harness section | A gate that has never been shown to fail is a claim, not a check. A gate that never reads the artifact it guards is checking a copy. The free-path gate was defeated by its own harness. The exit criterion was the thing nobody ran |
| write an error message | L3–L4, I8 | An error message is part of the interface; a remedy inside one is a claim, and it was false. A fix applied to one surface is half a fix |
| change retrieval | I9, the eval-harness section, I6 | Three defects under one green suite. Overlap could push a chunk past max_tokens; heading text landed in no chunk at all |
| edit a doc, a plan or an exclusion list | L5b, I9, the shared-file section | An exclusion list is a set of claims, and claims rot. A fix instruction can carry its own defects. Four silent str.replace no-ops in one session — an edit that does not match is an edit that did not happen. A clean auto-merge is not a correct merge |
| cut a release | the post-v0.1 housekeeping section, the two release-sweep sections (20260811) | A CHANGELOG entry and a __version__ are only claims: 0.1.0 had both for two days with no tag, no release and nothing published. Verify with git tag -l, gh release list, and the index itself. A release sweep is table-shaped and misses prose — grep for the superseded version number — and ordering is a property of the sequence: read the sequence, not the neighbourhood |
| trust CI | the cross-platform scanned-fixture section | main was CI-red for three pushes and nobody noticed. Green proves the tests ran, never that they can detect the defect |
| run a mutation pass | G1, L6 § Mutation testing, T3, T7, E5 | Commit before mutating — git checkout restores to the last commit and has silently reverted uncommitted fixes six times. A harness must prove it can kill before its silence means anything: assert the anchor matched exactly once, clear __pycache__ between mutants, run without -x, restore from a copy. A killed run poisons everything after it, and a surviving mutant first asks is this reachable at all? |
| write a measurement tool, or publish a number | the reachable-ceiling probe, measure_sync_cpu.py, E2, E6 |
A tool that absorbs input it cannot measure reports a plausible number — strictly worse than a crash. Validate everything the measurement consumes, not the bugs already reported; the artifact names every input its numbers are a function of; one test runs the tool the way it is really invoked, because the shipped one measured the launcher and not the work. A probe is code and earns the same adversarial pass — two of five constants were measured wrong on E6's first run |
| lean on a fake, a seam or a fixture | E6, I7b (passes 6–7), the [light] backend error, Running it found what reading it could not |
A seam built for testability defines a region no test reaches, and that region needs its own gate: the deep client's schemas 400d on every live call for four releases of green fixtures. "Each part works" is not "the parts are connected". For any new knob, turn it on a real KB before landing — only running proves the parameter arrives |
| apply a review fix | T3, T4, L6, 2d round two | A fix applied under review inherits the review's confidence and none of its scrutiny — every review loop here has found its worst defects inside the previous pass's fixes. "Pinned by test X" is a claim about a failing test: revert the fix and watch it go red, or do not write the word. Re-run the whole battery after every fix — a fix can silently disarm a test written for something else |
The pattern across all of them, and the reason this file is worth keeping: the worst finding of a review pass is usually inside the previous pass's fix. That has held from I5 through L6's thirteen rounds, and it is why a fix is re-reviewed rather than assumed.
I1 — Package skeleton, errors, CLI dispatch (20260725 13:40)¶
MEDIUM — PinakesError could not be pickled, so an error crossing a process boundary raised
TypeError instead of reporting itself. Exception.__reduce__ replays self.args through
type(self), but every subclass here takes its own constructor arguments (NotImplementedYetError
takes a command name and an increment), so rebuilding blew up on the missing remedy keyword.
Confirmed by probe before fixing, not reasoned about. Fixed with an explicit __reduce__ routing
through a module-level helper. Lesson: an exception class with a non-(message,) constructor is
unpicklable by default — the failure only surfaces under xdist/multiprocessing, i.e. exactly when
something else has already gone wrong.
MEDIUM — the subcommand dispatch target sat on the public namespace attribute run. Any future
command declaring --run would have silently overwritten the function main() then calls. Moved to
a reserved _runner dest with a test asserting no public namespace attribute is ever callable.
Lesson: set_defaults shares one namespace with every option; anything the framework itself
dispatches on must be underscore-reserved.
LOW (reference) — measured ty 0.0.63 against pyright strict on a 6-defect probe. pyright
caught 6/6; ty caught 1/6 (the str | None → len error, with better diagnostics); ruff caught the
unused import. ty currently has no strict mode: it accepts unannotated defs and Any leakage, which
is precisely what pyright strict is in this project for. Decision (user): keep pyright as the gate,
add uv run ty check as a fast pre-check. Worth re-measuring when ty leaves beta — the gap is a
missing feature, not a design difference.
I2 — ULID identity and pnk:// URIs (20260725 14:05)¶
MEDIUM — a test that could not fail. test_an_unresolved_uri_cannot_be_formatted asserted
not hasattr(parsed, "__str__") or "pnk://" not in str(parsed). Every object has __str__, so the
first clause is always false and the second only checked a dataclass repr — the test would have
passed even if ParsedUri had grown a full URI renderer. Replaced with a precise structural
assertion ("__str__" not in ParsedUri.__dict__, present on PnkUri) that names the static
guarantee as primary. Lesson: a green test asserting a tautology is worse than no test — it buys
false confidence, and hasattr(x, "__dunder__") is almost always one.
MEDIUM — a docstring claimed more than had been verified. ids.py said python-ulid rejects the
ambiguous Crockford letters I, L, O, U; only I and U had actually been probed. All four
are now probed and the claim is stamped with the time of that probe. Lesson: when writing "verified
X", the set being claimed must be the set that was run — a partially-probed claim reads identically
to a fully-probed one.
MEDIUM — two except Exception blocks wrapped calls whose only expected failure was
InvalidIdError, so a TypeError from a future refactor would have been re-raised as "this is not
a valid KB ULID". Narrowed, and ruff's BLE ruleset enabled so the class of mistake cannot be
written again.
LOW — internal helpers were public. parse_kb_id_for_uri/parse_doc_id_for_uri took an odd
(raw, segment) pair and had no business in the module's API; renamed to _kb_segment/_doc_segment.
LOW — the scheme is matched case-sensitively while the self sentinel is not. Deliberate (URIs
are machine-written, self is hand-typed) but undocumented; now stated in the module docstring and
covered by a test.
I3 — Manifest parsing and KB root discovery (20260725 14:25)¶
MEDIUM — an explicit empty value silently became the default. timezone = "" in [budget] read
back as "UTC", because the accessor ended in or "UTC". Same shape accepted name = "" and
model = "" outright. All three confirmed by probe. Empty strings are now rejected with a named
key, and the default only applies when the key is absent. Lesson: value or default conflates
"missing" with "empty", and for user configuration those mean opposite things — one is silence, the
other is a mistake worth reporting.
MEDIUM — narrowing by assert. Two call sites used assert value is not None to convince the
type checker, and a _require fallback built a Path out of a table name for an error that could
never fire. Python strips asserts under -O, so the "guarantee" was a comment with syntax. Replaced
with three explicit accessors — string (required, returns str), optional_string, string_or —
which give the type checker what it needs without a runtime claim. Lesson: when a type checker
needs an assertion, the API shape is usually wrong; fixing the signature beats asserting.
MEDIUM (docs) — the required/optional split existed only in code. [chunking], [retrieval],
[rerank] and [budget] are optional with documented defaults while [kb], [sources] and
[embedding] are mandatory — a user-facing contract that docs/DESIGN.md §2.1 never stated. Added
there in the same change, per the repo's docs rule.
LOW — a test caught a real error-message defect. Validation errors read
[<root>.retrieval]; the table path is meant to name what the user would type. The failing test was
fixed in the source, not the assertion.
I4 — SQLite storage, FTS5 triggers, vector loading (20260725 14:45)¶
MEDIUM — load_vectors peaked at roughly twice the array it returned. It collected every
embedding into a list and vstacked it: measured 669 MB for 200k×384, where the result is 307 MB.
At 1M chunks that is ~3.4 GB against the ~1.5 GB §3.1 states, so the design's own memory claim was
wrong for its only shipping tier. Now counts first and fills a preallocated array, with a test
asserting peak < 1.6× the result. Lesson: "load it all into one contiguous array" has two
implementations that differ by a factor of two, and only one of them matches what the design
promises.
MEDIUM — a non-database file produced a raw sqlite3.DatabaseError. PRAGMA journal_mode is
the first statement to touch the file, so it failed before any schema check could run, and the user
got file is not a database with no remedy. Opening is now wrapped. Lesson: the friendly check
ran second; the pragma that configures the connection is what actually opens the file.
MEDIUM — chunk insertion restarted ordinals at 0 on every call, so a second call for the same
document hit the UNIQUE (doc_id, ordinal) constraint. The operation is really a wholesale replace
— re-chunking must not leave old chunks searchable — so it now deletes first and is named for that.
Both this and the one above were caught by tests written in the same increment.
MEDIUM — pickling collapsed every error subclass to PinakesError. I1's __reduce__ fix
rebuilt through the base class, so StoreError came back as PinakesError and any except
StoreError on the far side would miss it. Now rebuilds the original class via __new__, keeping
message and remedy. Lesson: a fix that makes an object survive a round trip is not the same as one
that makes it survive intact — check identity, not just contents.
LOW — DOCUMENT_STATES/LINK_ORIGINS duplicated the DDL's CHECK constraints with nothing
tying them together. A test now fails if they drift.
I5 — Sidecars (20260725 14:55)¶
HIGH — sidecar writes were not atomic. path.write_text truncates before it writes, so a crash
or full disk mid-write leaves a truncated sidecar — and the one thing a sidecar carries that cannot
be recomputed is the document's permanent ULID. Losing it breaks every inbound pnk:// link, and no
later command can repair it, because nothing else knows what the id was. Now writes to a sibling
temporary and os.replaces over the target. Lesson: "the truth is in files" makes every file write
a durability question; the ones holding unrecoverable identity deserve rename-based atomicity.
MEDIUM — an explicit empty value was silently deleted on round-trip. tags: [] and
provenance: {} vanished, because write tested truthiness. This is exactly the lesson I3 recorded
one increment earlier — absent and empty are different statements — and I repeated it in a module
whose entire contract is "do not lose what the user wrote". The Sidecar now records which known
keys the file carried. Lesson: a recorded lesson only helps if it is re-read while writing the next
thing that could break the same way; the pattern to watch for is any if value: guarding output.
MEDIUM — a hedged test assertion. assert made.title == "my research notes.md" or made.title ==
"my research notes" accepted either answer because I had not checked which one Path.stem gives.
Two plausible values means the test asserts nothing about the one that is correct. Pinned to the
real value. Lesson: an or in an equality assertion is the tautology smell from I2 wearing a
different hat.
LOW — KNOWN_KEYS could drift from what write emits, which would be silent data loss for a
key the module claims to understand. A round-trip test now asserts every known key comes back.
I6 — Structural chunking (20260725 15:10)¶
HIGH — overlap could push a chunk past max_tokens. The carried-over tail was prepended
unconditionally, so overlap = 9 with max_tokens = 10 produced 12-token chunks (probed). Those
are exactly the chunks the model truncates at encode time, silently — the failure §4.6 exists to
prevent, reintroduced by the feature meant to improve context. The carry is now dropped when it
would breach the limit, and a parametrised test sweeps the whole (max_tokens, overlap) matrix.
Lesson: the earlier test used overlap=5, max_tokens=15 and passed; one comfortable ratio proves
nothing about the boundary. Sweep the matrix when two parameters interact.
HIGH (design, found by a test) — heading text landed in no chunk at all. Headings were consumed
as pure structure, so a word appearing only in a heading was unsearchable: the FTS index sees chunk
text and nothing else. heading_path looked like it covered this, but it is a separate column that
v0.1 never searches. Headings are now part of the first chunk beneath them, and docs/DESIGN.md
§4.6 says so. Lesson: "the information is still recorded somewhere" is not the same as "the
information is still retrievable"; check which column the query path actually reads.
MEDIUM — sentence splitting silently gave up on text without punctuation. A long run with no
.!?;: produced one oversize piece that was emitted whole. Now falls back to words, then to
characters for a genuinely unbroken run (a hash, a base64 blob).
LOW — piece offsets came from a running total that finditer's empty end-of-string match could
desynchronise from the source. Now taken from match.start(), so spans are exact by construction
rather than by accounting.
LOW (test-design) — a stand-in counter can be wrong in the direction that hides the bug. The word-counter says a 400-character unbroken run is one token; every real tokenizer disagrees. The character-cut path needed a token-dense counter to be exercised at all.
I7 — Embedding backends and reranker (20260725 15:35)¶
MEDIUM — a fake that could never disagree. The test backend was registered as
FakeBackend(section.dim), so it reported whatever width the manifest claimed — making the
dim-mismatch check, the one guard against storing incomparable vectors, impossible to test. Pinned
to a fixed width. Lesson: second time in three increments that a stand-in was wrong in exactly the
direction that hides the bug (I6's word-counter was the first). A fake that derives its answer from
the input under test asserts nothing.
MEDIUM — an assertion guessed at a real model's behaviour. count_tokens("retrieval augmented
generation") > 3 was written before any model had been run; the real BPE count is exactly 3, and
the test failed the moment weights were cached. Rewritten to assert the relationship (longer text
→ more tokens) rather than a number I had invented. Lesson: when a test crosses into a real
dependency, assert properties, not remembered values.
Verified against real weights (not inferred): fastembed's BAAI/bge-small-en-v1.5 gives
dim=384, max_seq_length=512 derived from the tokenizer's truncation config, normalised float32
vectors (self-cosine 1.0), and it wrote to ~/.cache/huggingface/hub rather than
$TMPDIR/fastembed_cache. BAAI/bge-reranker-base ranked a relevant passage above an irrelevant
one, -0.28 vs -7.85.
LOW (reference, matters for I14) — reranker scores are raw logits, not probabilities. They came
back around -0.28 and -7.85, not in [0, 1]. The illustrative thresholds in §2.1
(low_below = 0.31, high_above = 0.62) read like normalised scores; calibration must either fit
against the logit scale or squash it first. Recorded now so I14 does not quietly fit thresholds to
the wrong scale.
I8a — Sync pairing core (20260725 15:50)¶
MEDIUM — a model-test guard checked the wrong half (found in I7, surfaced here). The
model-marked tests skipped when weights were absent, but not when the backend was absent. Model
weights live in a shared machine-wide cache, so a worktree without the light extra installed still
saw them, ran the test, and failed with BackendMissingError instead of skipping. Only noticed
because a second worktree had a different install set. Now both halves are checked. Lesson: a skip
condition is a claim about the environment, and machine-wide state (a shared cache) is not evidence
about the local one.
Design note, not a defect — DuplicateIdsError raises rather than returning an action.
plans/20260725_1317-v0.1.md lists it among pair()'s return values. Raising is better: the condition is fatal
for the whole run, and an action every caller must remember to inspect is one a caller will
eventually forget. Recorded because it is a deliberate divergence from the reviewed plan.
What the exhaustive table bought. Writing one test per §6.4 row, then the compound cases the
table cannot express, is what forced the two decisions the design left implicit: a sidecar
disagreeing with the index wins (docs/ is truth, the index is derived), and a whole-picture rule
must be order-independent — asserted directly by pairing the same snapshot walked forwards and
backwards.
I8b — pnk sync, locking, and the rebuild swap (20260725 16:20)¶
HIGH — the rebuild swap left the old database's -wal/-shm behind. Design pass 2 fixed the
missing checkpoint; this is the other half nobody had noticed. SQLite names the companions after the
path, not the inode, so after os.replace they sit beside the new index claiming to be its
write-ahead log — the exact corruption the checkpoint was added to prevent, reintroduced by the
rename that followed it. They are now removed after the swap. Lesson: an atomic rename is atomic
for one file; a WAL database is three files with correlated names, and correctness arguments about
"the file" quietly skip the other two.
MEDIUM — a read-only SQLite connection creates -wal and -shm itself. This masked the bug
above: the test read the index before asserting the companions were gone, and the read created them.
Worth knowing beyond this test — §4.7 says the MCP server opens the index read-only "so it cannot
write", which remains true of the data, but the server does create files in .pinakes/. Any
future check that treats "no companions present" as evidence of a clean shutdown is wrong.
MEDIUM — a leaked connection inside a test helper. The helper was a generator, and a caller
using next() left it suspended, so its finally: close() never ran. The symptom appeared in an
unrelated assertion about rebuild. Now returns a list and closes immediately. Lesson: a generator
with cleanup in finally only cleans up if it is exhausted; for a fixture-shaped helper, return the
list.
LOW — ty caught a loose test shim pyright had been told to ignore. The monkeypatched
__import__ took *args: object; pyright was silenced with an inline ignore, ty flagged the real
mismatch. Typing the shim to __import__'s actual signature satisfied both and deleted the
suppression. First time the "fast pre-check" found something the gate had been told to skip —
noted, since I1's decision assumed ty would only ever be faster, not different.
I9 — Retrieval pipeline (20260725 16:55)¶
MEDIUM — vector search padded its candidate list with zero-similarity passages. argsort returns
every row, so a query sharing no direction at all with a passage still ranked it, and with nothing
better available those passages reached the user. Real models rarely produce an exact zero, so this
would have hidden until a sparse or domain-shifted corpus hit it. Non-positive cosines are now
dropped. Lesson: "return the top N by similarity" is only sane while similarity means something;
N is a cap, not a quota to fill.
MEDIUM (design gap, decided here) — the design said the filter set included "date", and no date
column existed. Documents carry mtime; a sidecar's created is optional, and filtering on an
optional field silently excludes every document that lacks it — worse than having no filter. The
filter is now mtime, and §4.1 says so. Lesson: when the design names a filter dimension, check
which column actually holds it for every row, not just the well-formed ones.
LOW — sqlite3.Row hands back Any, which erased types through the whole hydration path. Rows
are now narrowed once into a small frozen dataclass instead of being cast field by field at each use
— pyright strict was the thing that made this visible.
Worth recording: FTS5 escaping is not optional. AND, OR, NEAR, *, " and a bare
apostrophe are all parser syntax; a user typing it's would otherwise crash the query. Quoting each
word as a literal and joining with OR keeps recall — an implicit AND drops a passage for one
missing word, which is exactly the recall the vector half is there to provide.
I10 — pnk init, the notes template, pnk search (20260725 17:15)¶
MEDIUM — warnings were being printed and ignored. Turning filterwarnings = ["error"] on
immediately produced two real problems that had been sitting in the summary: importlib.abc.
Traversable is deprecated and removed in Python 3.14, so this project would have broken on the
next Python it claims to support; and several tests leaked SQLite handles, surfacing as
ResourceWarning raised from wherever the garbage collector happened to run — never the test that
leaked. Lesson: a warning nobody has to act on is a warning nobody reads. Making them errors cost
one afternoon of cleanup and bought a Python upgrade.
MEDIUM (process) — I committed with a failing gate. The last edit before committing introduced a
reportUnusedFunction on an autouse fixture; I had run pyright before that edit, not after. Fixed
in the retrospective commit. Lesson: the gate belongs immediately before git commit, not
"recently" — and "recently" is exactly what it felt like at the time.
LOW — the test fixture violated a manifest invariant I had written myself. Setting
max_tokens = 60 while leaving overlap = 64 was rejected by I3's cross-key validation. Pleasant
confirmation that the check earns its place: the first thing it caught was its own author.
Design note — the template ships [retrieval.confidence] commented out. plans/20260725_1317-v0.1.md had
already decided this, and building it made the reason concrete: pnk init cannot know anything
about the corpus the user is about to add, so any threshold it wrote would be a number with no
provenance. confidence: unknown until they fit their own is the only honest default.
I11 — pnk doctor (20260725 17:35)¶
MEDIUM — ty found a second real defect hiding behind a pyright: ignore. I had typed a dict as
dict[Path, object] and silenced the resulting argument-type complaint rather than fixing the
annotation. That is the same shape as I8b's finding, and it is now a pattern: an inline
suppression is where a type error goes to be forgotten. Every pyright: ignore in src/ has been
removed as of this increment; the two that existed were both hiding something real, not appeasing a
checker that was wrong.
Worth stating — doctor is where several design promises stop being rhetorical. §3.1's linear
scan ceiling, §6.2's link-coverage ceiling, §4.2's unknown confidence, §6.4's orphan reporting and
§6.5's lock are all "we will tell you rather than pretend" commitments. None of them is honest
unless something actually prints them, and until this increment none of them did. The test that says
every non-OK check must carry a remedy is the one enforcing the spirit of it: a report that says
"problem" without saying "do this" is just anxiety.
Design note — an uncalibrated KB is a WARN, not a FAIL. Reporting confidence: unknown is the
honest behaviour the design chose, so a KB doing exactly that is not broken; it is uninformative,
and the warning says so with a pointer to §4.2 and §7.
I12 — pnk install-hooks (20260725 17:50)¶
Confirmed end to end, with a real commit: the three-hook split does what design pass 6 claimed.
docs/note.md and docs/note.md.pnk.yaml land in the same commit, and git status is clean
afterwards. That was the whole argument for splitting the hooks, and it is now a test rather than a
paragraph.
LOW — the pre-commit half needs no embedding backend at all, which only became obvious when a
subprocess pnk ran without the test's fake registered and the sidecar half still worked. That is
the right shape: minting an id is cheap and belongs before the commit; embedding is slow and belongs
after it. Worth recording because it means a KB whose backend is not installed can still commit
documents with correct, permanent ids — the failure is deferred to indexing, exactly where §4.5 says
a core-only install should feel it.
LOW (test hygiene) — a "tree is clean" assertion failed on files the fixture never committed. The hooks were fine; the setup was. An assertion about cleanliness is only meaningful from a clean starting point.
HIGH (process) — I committed with red gates for the second time, and now understand why. The
pattern uv run pyright 2>&1 | tail -1 && git commit … reports the exit status of tail, which is
always 0. Both checkers were failing and the commit went through looking green. This is not
carelessness that can be fixed by resolving to be careful: the shell was reporting success. Added
check.sh, which runs every gate under set -e, and pointed CLAUDE.md at it. Lesson: if a
safety check is a pipeline, the thing you are checking is the last command in the pipe. Make the
gate a script that exits non-zero, and the mistake becomes unavailable.
I13 — pnk serve (MCP) (20260725 18:15)¶
The boundary is testable, so it is tested. §4.7's claim is that an agent cannot reach outside
the KBs the server was pointed at. Three tests hold it: pinakes_get refuses a path, a traversal
string and an unknown ULID identically; a KB that exists on disk but was not passed on the command
line is unreachable and the error says arguments select by name or ULID never by path; and a
document deleted since it was indexed cannot be fetched.
MEDIUM — stat()-based staleness detection works, and the test proves the thing the design
argued about. After a --rebuild swap the server returns the new documents. The design's
reasoning (an open handle pins the old inode, so meta.build_id read through it would report the
old build forever) is now backed by a test that would fail if someone "simplified" it back.
LOW — pyright strict flags decorator-registered functions as unused. @mcp.tool() returns
something pyright cannot tie back to the name, so all three tools looked dead. Rather than
suppressing it, they are now registered in an explicit loop — which also makes the set of exposed
tools one readable line instead of three annotations. A suppression would have been the third one
this session that turned out to be hiding something; making the code say what it means was cheaper.
LOW — another leaked connection, caught by warnings-as-errors from I10. A Server built inline
inside a pytest.raises block was never closed. This is the third leak that setting only found;
before I10 it would have been invisible.
I14 — Demo KB, golden set, eval harness, calibration (20260725 19:00)¶
HIGH — the two error rates were a flattering zero, and it took looking at real numbers to see
it. The first eval run reported false_abstain: 0.0 and false_confidence: 0.0. Both were
vacuous: the demo KB had no fitted thresholds, so confidence was always unknown, so neither
error could ever be counted. A CI gate on false-confidence would have passed forever, and passed
loudest exactly when calibration was missing. Added confidence_coverage to the metrics, and made a
drop in it a regression in its own right. Lesson: a perfect score on an error rate is a claim that
deserves the same suspicion as a failing one — check the denominator before believing the ratio.
HIGH — the first threshold formula made low unreachable. low_below = min(answerable) came
out at -9.885 on real logits, a floor almost nothing falls below, so the system could essentially
never abstain and false-abstain was zero by construction. Both thresholds are now fitted from the
unanswerable distribution — the only outcomes known absolutely — with low_below its median and
high_above a high percentile. Only visible because the fit was run against real reranker scores
rather than a fake's tidy 0-to-1 range.
The measured cost of the confidence heuristic, stated plainly: false-confidence is 0.25. One
no-answer question in four still gets a confident answer, because the score distributions genuinely
overlap (answerable -9.9..7.9, unanswerable -8.3..-2.7). §4.2 promised this would be measured rather
than assumed; it is now in docs/DESIGN.md's risk table with its date and models. Two caveats are
recorded with it: eight no-answer questions is a small sample, and the thresholds are fitted on the
same set they are scored against, so it is a floor rather than a measurement.
MEDIUM — the test fixture copied .pinakes/ and ran the 64-dimensional fake against a
384-dimensional index. I4's stored-vector width check refused it, which is exactly right and
briefly baffling. Generated state is now excluded from the copy. A good sign for the guard: the
first thing it caught was a developer, not a user.
LOW — ruff caught assert ... or True in my own test. The tautology lesson from I2, third
appearance, this time found by a linter rather than by reading. SIM222 earns its place.
I15 — CI, packaging, 0.1.0 (20260725 19:30)¶
MEDIUM — the version lived in two places. pyproject.toml and __init__.py each carried it,
which is one place to forget on every release. Hatch now reads it from the module, and the release
workflow refuses a tag that disagrees with it — a mismatched tag is the kind of thing nobody notices
until an install pulls the wrong thing.
The wheel smoke test earned its place immediately. pnk init reads its template through
importlib.resources, so a packaging mistake is invisible in the source tree and total after
install. Running pnk init from the built wheel — and asserting the manifest, the golden-set stub
and the .gitignore all appear — is the only check that would have caught it.
Publishing is left as a human step, on purpose. The release workflow runs on a v* tag and
nothing else, and PyPI trusted publishing has to be configured in the PyPI UI first. Neither the tag
nor the publish happens automatically from a merge: an irreversible, outward-facing action should
need someone to mean it.
§8's v0.1 sentence, walked item by item: 17/17 present. The plan asked for that walk explicitly at this increment rather than trusting the accumulated sense that everything got done.
Post-v0.1 release housekeeping (20260727 15:35)¶
Not an increment — a session that merged the graph research, closed out the v0.1 plan, and cut the releases that had never been cut. Recorded because four of its findings are the kind that cost more to rediscover than to write down, and one is a mistake made in this session by the person writing this.
The 0.1.0 release existed in every artifact except the ones that matter. __version__ said
0.1.0, the CHANGELOG had a dated [0.1.0] section, and its footer linked
releases/tag/v0.1.0 — while git tag -l was empty, no GitHub release existed, and PyPI returned
404. The release workflow fires only on a v* tag, so nothing had ever been built or published.
Lesson: a version number is a claim, and a claim in a CHANGELOG is the easiest kind to believe.
Verify a release the way a stranger would — git tag -l, gh release list, the package index —
never by reading the file that asserts it.
A docs-only merge turned main red. ruff format --check . formats Python fenced blocks
inside Markdown, so an igraph snippet in a research doc failed the Format gate. The instinct that
a documentation change cannot break the build is wrong in this repo, and the gate is the only
arbiter. Now stated in CLAUDE.md and in the README's Development section.
Merging from inside the feature worktree silently does nothing, and the tag lands off-main.
Running git merge --ff-only <branch> while cwd is that branch's own worktree merges the branch
into itself — "Already up to date" — and the subsequent git push origin main reports "Everything
up-to-date" because the local main ref never moved. The git tag that followed pointed at a
commit reachable only from the branch, so v0.1.2 existed, was pushed, and was not an ancestor of
main. Both commands succeeded; nothing failed loudly. Lesson: merge from the primary
checkout, and before creating a release assert the lineage —
git merge-base --is-ancestor vX.Y.Z main.
The README was the only surface that lied. An audit against the running CLI found four claims
contradicting the code: pnk ask --deep described as existing (it is a v0.4 plan), a budget ledger
described as tracking spend (nothing writes one), install lines pointing at a PyPI package that
404s, and the headline KB diagram built on a .pdf — the one file type v0.1 cannot ingest.
cli.py and the CHANGELOG were scrupulous in the same places, both saying "planned for v0.4".
Lesson: prose drifts toward the design and away from the build, because the design is what the
author is thinking about. The check that works is running the commands the README shows. The
documented [light] install had the same shape: co-equal in the README, broken at the first sync
because pnk init always stamps sentence-transformers.
A promise in a section with no increment number belongs to nobody. plans/20260725_1317-v0.1.md asked for a
CI grep gate keeping paid-API clients out of src/ under "Verification of the whole". No increment
owned it, so it never shipped — while the invariant it guards is the one CLAUDE.md calls
non-negotiable. Now enforced, and verified in both directions: it passes on the current source and
catches a planted import openai. Lesson: every promised check carries an increment number and a
path, or it is a wish.
Planning v0.2 (20260727 17:00)¶
A review pass is a change, and a change needs its own review. Adversarial pass 2 over
plans/20260727_1543-v0.2.md returned 5 HIGH — and four of the five were created by pass 1's own fixes, not
survivals of pass 1's findings. Pass 1 correctly rejected a per-page cost estimate as an
order-of-magnitude under-reservation against whole-document requests; the shape it introduced
instead was quadratic in input and stopped fitting the model's context window at ~166 pages, so a
100-page document reserved ~$375 and the feature failed closed with a refusal no user could satisfy.
Lesson: never implement from the revision that a review produced. Two passes are the floor for a
document of this size, and each pass reviews the previous pass's fixes, not only the original.
A threshold that only exists in tests/ protects nobody. Both fitted floors — the one that
triggers paid re-extraction and the one that stops a paid run on an already-healthy PDF — were
committed to tests/pdf-corpus/baseline.json, which no wheel installs, while three runtime
consumers on a user's own KB depended on them. The fail-closed rule was also stated for one floor
and not the other, so the guard against paying to re-extract a healthy PDF was silently disabled
everywhere. Lesson: a value a user's runtime reads is package data, and every fail-closed rule is
stated and tested once per consumer, not once per document.
An append-only ledger needs a way to say "this never happened". Every paid call wrote a
reservation before the call and a reconciliation after, with an unreconciled reservation counted at
its reserved amount. A call that raised — timeout, 429, 5xx — therefore left spend that nothing
could ever close, in a file CLAUDE.md forbids editing: a handful of transient failures would lock
a user out of a monthly budget permanently. Lesson: a two-state protocol over an append-only log
needs a third state. Reserve → reconcile or void.
Timestamps were composed instead of read. Four "verified on 20260727 17:34" claims were written
at 17:00 — 34 minutes in the future. Session context carries a date but never a time, so any HH:MM
not read from the clock is invented, and an invented one lands in the future about half the time. A
timestamp exists to say how fresh a verified claim is, so a fabricated one is a false evidence claim
rather than a formatting slip. Lesson: run date "+%Y%m%d %H:%M" and paste the result; one call
covers a batch of edits. Now a rule in CLAUDE.md.
Planning v0.2, pass 3 (20260727 20:23)¶
Three review passes, and each one's largest source of new HIGHs was the previous pass's fixes. Pass 2 found that four of its five HIGHs were created by pass 1's repairs; pass 3 found the same of five of its twelve. Reviewing a plan is a change to the plan, and a change needs its own review. Lesson: budget for the review of the review. A plan is not ready because the last pass was clean — it is ready when a pass over the current text is clean.
What broke the cycle was method, not effort. Five of pass 3's twelve HIGHs were found only by
checking the plan against src/ and against arithmetic — never by reading the plan, however
adversarially. --rebuild discards the index and reads its "before" snapshot from the empty new
database (sync.py:231-241), so a guarantee recorded only in index.db was invisible to the one
command that most needed it. A trace test asserted "exactly one chunk contains this offset" while
chunk.py:239-269 prepends the carried overlap and takes start from the carried piece, so
chunk n+1 begins inside chunk n whenever a block splits. A filter test named tags as a column;
it is json_each over a NOT NULL DEFAULT '{}' field, so the assertion was true by schema for
every row and would have passed on a corpus with no tags at all. A raster gate promised "a moved
word must fail" against a threshold its own arithmetic puts about four lines of text away. And the
docs sweep enumerated README additions while four sentences already in the README are falsified
by the release — the same defect, and the same count, as the audit at 0.1.2.
Lesson: a consistency pass cannot find a claim that is internally consistent and externally false.
Run three narrow passes instead of one wide one — code-reality (resolve every claim about src/
against the file), arithmetic (recompute every stated number), and promise-ledger (walk every
enumerated bound, flag, floor, gate and amendment asking "which increment makes this true, and
which test proves it"). Each found HIGHs the wide pass missed.
A threshold you cannot fit yet is not a threshold — ship the metric, defer the loop. Two passes each tried to fit the completeness audit's floor, and each picked a pair that was not the pair it is applied to, because the applied pair needs paid model output that does not exist at fitting time. The second attempt would have fitted over a population including a fixture the reader contract requires to raise, landing the floor near zero and leaving the audit inert on every installed copy. Lesson: when a threshold's correct fitting data cannot exist yet, that is the finding. Ship the measurement, report it, and let the release that produces real data fit the number.
"Bypasses module X" is a claim about an import graph, and it was false. The paid extractor was
documented as bypassing layout.py — while calling normalise(), which lives in it. The version
constant covering that module was then deliberately excluded from the paid fingerprint, so a
whitespace or ligature change would have missed every free cache entry and silently hit every
paid one, with the coherence check unable to see it. Lesson: when two consumers share one stage,
version that stage separately rather than arguing about which of them "really" runs it.
Planning v0.2, pass 4 — three narrow passes (20260727 20:57)¶
Splitting one wide review into three narrow ones is what finally found the structural defects.
Passes 1–3 were each a thorough adversarial reading, and each one's largest source of new HIGHs was
the previous pass's fixes. Pass 4 ran instead as a code-reality pass (resolve every claim about
src/ against the file), an arithmetic pass (recompute every stated number), and a
promise-ledger pass (walk every enumerated flag, floor, gate, constant and amendment row, asking
only "which increment makes this true, and which test proves it"). Together they returned 19 HIGH —
and almost none of them were things a fourth general reading would have caught.
Lesson: a reviewer reading for coherence finds incoherence. Defects that are internally consistent
and externally false need a pass that leaves the document: to the source, to a calculator, or to a
checklist. Run those as separate passes, because each is a different kind of attention.
A review pass's own fixes are the highest-risk text in the document. Four of pass 2's five HIGHs
came from pass 1's fixes; five of pass 3's twelve came from pass 2's; and two of pass 4's came from
inside pass 3's fixes — a pnk budget ordering bug fixed by assigning the edit to an increment
that lands earlier, and a missing-amendment-row sweep that added a row for one copy of a sentence
and missed two others. The arithmetic pass caught the sharpest case: pass 3 replaced a raster
tolerance that could not detect a moved word with a different tolerance that also could not detect a
moved word. Lesson: never ship the revision a review produced. Re-review the fixes specifically,
and prefer a method that cannot be fooled by the same reasoning that produced them.
"The code already does X" is a claim, and this project keeps getting it wrong. Verified against
src/: write_sidecar runs only for newly minted documents, so a plan built on "sync writes the
sidecar" had no write path for the case it cared about; pnk init --ci was designed in DESIGN and
never built, while an increment was written to modify it; CI installs --extra light and runs the
model tests in the check job, so a new matrix leg would triple them rather than skip them; and
--index-only, which the hooks run, is contractually forbidden from writing into docs/ at all.
Lesson: every plan sentence about existing behaviour carries a file:line, or it is a guess.
A threshold needs enough data to have chosen it. The running-head threshold T was documented
as fitted over a stratum of 3-page fixtures — where per-document recurrence can only be 1/3, 2/3 or
1, so every T in (1/3, 2/3] reproduces the corpus identically. The value was real, the fit was
not. Lesson: state a fitted threshold's resolution alongside its value. If the corpus cannot
distinguish 0.4 from 0.6, "fitted" is a claim the data does not support — enlarge the fixture or
call it a chosen constant.
I1 — extras, the extractor seam (20260727 22:28)¶
An explicit, textual exit criterion is not met just because the surrounding tests feel thorough.
The plan's own words were "the filterwarnings probe and both marker predicates get named tests" —
the probe got tests; pdf_runnable()/paid_runnable() did not, and nothing caught it until the
review grepped for their names and found only their own definitions. Twenty-odd other tests passing
made the increment feel covered. Lesson: when a plan states a test-coverage exit criterion in so
many words, grep for the named thing before calling the increment done — a feeling of thoroughness
is not the same claim as a named test existing.
A synthetic probe path that doesn't mirror the real call site's resolution semantics passes on the
easy case and is silently wrong on the common one. doctor._could_match_pdf checked whether
sources.include could ever match a PDF by testing patterns against a probe path prefixed with the
root's own name ("docs/__pdf_probe__.pdf") — but walk_sources applies each pattern via
root.glob(pattern), where root is already resolved, so a pattern is relative to it, never to
the KB root. The bug was invisible in-repo because the one test written for it used **/*.pdf,
which happens to match regardless of the extra prefix; a bare *.pdf — an equally ordinary
manifest — silently reported OK: not installed (no .pdf in include) on a KB that would, in fact,
fail its very next pnk sync. Lesson: a synthetic probe is only as good as its fidelity to the real
resolution path, and one test shape that happens to tolerate an error is not evidence the error
doesn't exist — test the shape closest to the literal documented example, not only the most generic
one.
The docs-in-the-same-commit rule missed two files because neither is named a "user-facing
surface." DESIGN.md and CLAUDE.md were amended correctly in the same commit that made CI a
three-leg matrix; README.md's and the Makefile's own make install comments, both reading "as CI
does," were not — the exact class of drift a 0.1.2 audit already caught once for this project
(above). Neither file is a flag, a manifest key, or a --help string, so neither felt like it was in
scope. Lesson: "describes CI/build behaviour in prose" is as much a user-facing surface as a CLI
flag — grep README.md and the Makefile for the thing that changed, not only cli.py and
DESIGN.md.
I2 — the synthetic PDF corpus (20260727 23:43)¶
HIGH — a gate rendered at a resolution that made its own threshold unreachable. The scanned
stratum's tolerance is "more than 300 pixels differing by more than 32 levels", derived for a page
rastered at 150 dpi. The comparison called page.render(scale=1.0) — pdfium's default of 1 px per
point, i.e. 72 dpi — which downsamples the stored 150 dpi image ~2× before diffing. That
shrinks the page from 2,105,025 px to 485,316 and a moved word's delta from several hundred pixels
to well under a hundred: the gate would have passed exactly the change it exists to catch, while its
docstring claimed a 2× margin. The docstring also named A4 (1240×1754) for a corpus that is US
Letter throughout, so both factors in the derivation were wrong and they partly cancelled, which is
why the number still looked plausible. Lesson: a tolerance is meaningless without the resolution it
is measured at — state both, and assert the comparison runs at the fixtures' own. When a derived
constant is checked, re-derive it from the code that consumes it, not from the prose that
introduced it.
HIGH — textwrap invented a hyphen the ground truth then rendered as a phantom space.
textwrap.wrap(..., break_long_words=False) leaves break_on_hyphens=True, so it split the
existing compound "spine-out" across two lines; the ground truth joins lines with a space, yielding
"spine- out" — a string no correct extractor could ever produce, in the one file whose entire job is
to be what a correct extractor produces. The same word appears correctly in 16 other places in the
corpus, so the corpus contradicted itself. Lesson: when a helper's default silently edits content,
the edit shows up wherever the content is re-joined by a different rule. Hyphenation is exercised
deliberately here by fixtures that place their own hyphens; it must never arrive by accident.
HIGH — the soft-hyphen fixture's ground truth dropped the first four words of its own page. The expected text began "cooperation agreement…" while the page reads "The clerk filed the coopera- tion agreement…". Written by hand while thinking about the joined word, not about the page. Every automated check still passed: the fixture had a ground truth, the counts matched, the bytes regenerated. Lesson: nothing in a corpus's own test suite can tell you a ground truth is wrong — only reading it beside the page can. Budget for that reading explicitly; "the tests are green" is not evidence about the one thing tests cannot check.
MEDIUM — two claims with no enforcement, in a file full of enforcement. "Pillow is dev-only,
never core, never an extra" was stated in the commit message, in conftest.py and in a test
docstring — and adding Pillow to [project.dependencies] left the whole suite green, while the
structurally identical claim for pypdfium2/anthropic is tested. Separately,
pdf_runnable()'s three-part predicate had a test walking false→false→false→false→true that never
turned the corpus clause off once both libraries were on, so deleting that clause entirely still
passed. Lesson: for an N-part predicate, assert each part is individually load-bearing by turning
it off from the all-true state — a monotonic walk up to true proves only the last flip mattered.
LOW — the same quantity was stated twice, differently, and neither figure was right. The commit
message said ~440 KB (that is du's block-rounded disk usage of the whole directory), the CHANGELOG
said ~370 KB (unreconstructable), and the budgeted quantity — the PDF bytes test_byte_budget sums
— is 266 KiB. Lesson: when a number appears in two documents, both are guesses unless one of them
was measured; measure once and paste the same figure.
I3a — extraction core, pure: chars to ordered, de-furnished text (20260728 00:52)¶
HIGH — column clustering compared each candidate to the wrong reference point, letting drift
accumulate past its own threshold. reading_order grouped blocks into columns by comparing each
new block's x0 to the last-placed member of the current column, not the column's start. Sorted
by x0, each step can individually stay under _COLUMN_GAP while the column's accepted range walks
steadily rightward — so a genuine third column, far enough from the first to be its own column, could
still merge into the second's cluster one small step at a time. Caught with a reproduction script
laying out three real columns and reading the wrong (merged) order back. Lesson: "cluster by gap"
needs a fixed anchor — the cluster's start, not its most recent member — or the gap check bounds a
single step while saying nothing about total drift.
HIGH — y-band clustering by round() put a hard wall at every half-integer. strip_running_heads
grouped running-head candidates into y-bands with round(block.y0). Two renderings of one genuine
running head at 750.4 and 750.6 pt — sub-point jitter, far smaller than any real layout difference —
round to 750 and 751: two distinct, non-recurring signatures, each individually under the suppression
threshold even though the line recurs on every page. Fixed with tolerance-based clustering (shared
anchors, abs(y0 - anchor) <= _RUNNING_HEAD_Y_TOLERANCE) matching _LINE_TOLERANCE's own approach
elsewhere in the same file. Lesson: round() is a clustering method with an invisible discontinuity
at every .5; anything claiming "the same, allowing for rendering jitter" needs a tolerance compare,
never a shared rounding function, or the false-boundary cases won't show up until real PDFs hit them.
HIGH — the import-purity test only recognised import X, not from X import Y. _imported_names
walked ast.ImportFrom nodes and recorded node.module only. from pinakes.extract import layout —
the exact style layout.py itself already uses for its own dependency on ExtractedText — resolves
to the module name pinakes.extract, plus the separately recorded name layout; the check for
"extract.layout" matched neither, so textpolicy.py could have imported layout.py this way and
test_textpolicy_is_pure_and_does_not_import_layout would have stayed green. Fixed by folding
ImportFrom.names into fully-qualified names (f"{module}.{alias.name}") alongside the bare module.
Lesson: an import-graph test written against ast.Import habits misses ast.ImportFrom entirely
unless it's built and then attacked with the exact style the file under test itself uses.
MEDIUM — a page's dominant font size was voted on by character, not by line, so a verbose heading
could out-vote the body size it was meant to be measured against. _mode_font_size originally took
one entry per character in blocks_from_chars; a short body line has few characters, a heading with
a long title has many, and counting per-character let a sufficiently wordy heading tip the "mode" size
to its own, inverting line_size > body_size for the very line it should have flagged as a heading.
Fixed to take one entry per line ([max(c.font_size for c in line) for line in lines]). Lesson:
"most common value" needs its unit stated explicitly — voting by the wrong unit of measurement
produces a plausible-looking answer that is wrong in exactly the cases with more text, which are also
the cases most likely to be headings.
MEDIUM — a symmetric rule was checked on one side only, twice, in different functions.
join_hyphenation skipped a block as a join source when it was suppressed, but not when it was
itself a heading — so a heading ending in a hyphen could be joined into as a source, even though
the same function already refuses to join into a heading as a continuation. Separately, assemble
silently produced a truncated document if a block's page_index ever fell outside range(len(pages))
— a caller bug that should be loud (I3b's future pdfium adapter is the only caller that will ever
construct page_index), rather than a quietly shorter ExtractedText with no error at all. Both fixed
in the same pass: the heading check now runs both ways, and assemble raises RuntimeError on an
unplaced block. Lesson: when a function enforces "never X across a boundary," check both directions
explicitly — a docstring that says "either side" is a claim, not a guarantee, until both sides have a
test — and prefer a loud failure over a silently smaller correct-shaped result whenever "silently
smaller" is a shape invariants alone can't distinguish from correct.
LOW — fixing the "no filesystem access" gap introduced a fragile substring check, caught before it
shipped. Extending the import-purity test to also assert no os/pathlib/io import used the same
marker in name substring style already used for PDF libraries ("pypdfium2" in name). Re-deriving
what that check would actually match against layout.py's real imports first — rather than trusting
that it passed — showed "io" in name matches typing.Optional and collections.abc.Iterable
(...t-i-o-n...), neither of which touches a filesystem; the check would have false-positived the
moment either was ever imported. Fixed to match on the module boundary (name == module or
name.startswith(f"{module}.")) before it was ever committed. Lesson: two-letter module names are not
safe substring needles — os/io collide with ordinary English inside almost any longer identifier
— so "does this file import X" must match on the dotted-name boundary, never bare containment.
I3b — the pypdfium2 adapter, extraction-quality metrics, and the two fitted floors (20260728 03:06)¶
HIGH — an empty page list is not an empty request; it is pypdfium2's spelling of "every page."
slice_pages(path, first, last) clamped last to the document's own last page but never validated
that first still fell before it. Whenever first > last after clamping — a reversed range, or a
first entirely beyond the document — range(first, last_clamped + 1) is empty, and an empty list
is falsy in Python: pypdfium2's own import_pages treats a falsy pages argument identically to
pages=None, its own spelling of "import every page." Verified directly against the real 12-page
baseline-12p.pdf: slice_pages(5, 2), slice_pages(100, 200) and slice_pages(20, 30) each
silently returned all 12 pages, no exception. slice_pages is stated as I7b's future paid-path
request unit; a future off-by-one computing a page window (the last window of a document whose
length isn't a multiple of the window size is the obvious candidate) would have silently sent the
entire document to a paid API instead of a small slice — a cost-control failure, not merely a
wrong answer, in a project whose one hard invariant is that the free path stays free and spending is
never accidental. Fixed with explicit validation (first >= 0, first <= last_clamped) before the
range is ever built, raising ValueError. Lesson: an empty collection is not automatically "no
items requested" to the function receiving it — some APIs (documented or not) treat empty/None
identically as "unfiltered," and a range-clamping function must validate the range is still
non-empty itself, not merely non-negative.
MEDIUM — "wide relative to the page" and "spans multiple columns" are not the same fact, and only
one of them is safe to test for. reading_order's spanning-block detection was measured against
exactly one fixture (two-column-b.pdf's caption, 79% of the page's content span, against a 42%
maximum for any genuine column line) and shipped as a fixed fraction, _SPANNING_WIDTH_FRACTION =
0.6. An independently-constructed asymmetric layout — a narrow sidebar beside a much wider main
column, a real and common shape, not a contrived one — put the main column's own lines at 77% of
the page's content span with nothing in it actually overlapping the sidebar at all; the
width-fraction test misread every line of the wider column as spanning and interleaved the two
columns line by line, silently, with no error. Fixed by replacing the global-width test with a
geometric one: a block is spanning only if its own x1 reaches at or past the next column's own
x0 — genuinely bridging into that column's territory, which the caption does (its x1 passes the
right column's x0) and the wide sidebar-adjacent column does not (there is nothing to its own
right to reach into). Both the original caption case and the new asymmetric-column case are now
committed regression tests. Lesson: a measurement taken from one fixture is a fact about that
fixture, not evidence the derived threshold generalises — check whether the underlying mechanism the
threshold approximates (here, "does this block's own geometry actually overlap another column's")
can be tested directly instead, before shipping the approximation.
MEDIUM — a fitting function that raises on a missing upper bound but silently guesses at a missing
lower bound is not applying one policy, it is applying two, only one of which is stated.
fit_running_head_threshold raised loudly when no true-positive recurrence was ever observed, but
silently fell back to max_true_negative = 0.0 when no true-negative was ever observed — and a
lower fallback threshold makes strip_running_heads more aggressive, so this fallback was
assuming the best case (no decoy content ever recurs) with no evidence for it, dormant only because
the current corpus happens to have 76 true negatives. Made symmetric: both empty cases now raise.
Refactored the pure midpoint arithmetic out of the corpus-walking function
(threshold_from_fractions, taking fraction lists directly) specifically so both raise paths are
covered by a direct unit test, not only reachable in principle through a synthetic corpus directory.
Lesson: check every "if empty, fall back to X" for whether X is a measured true value (0.0
non-whitespace characters is the true yield of a page with no native text layer — the sibling
floor's own fallback, left alone) or merely a plausible-sounding guess standing in for missing data —
only the former is safe to leave silent.
MEDIUM — every extraction-quality metric whitespace-flattens its input by design, which means a
duplicated-newline regression is invisible to the very gate meant to catch regressions. The
\r/\n-character fix (dropping embedded line-break characters that were duplicating assemble()'s
own inter-block separator) shipped with zero regression coverage anywhere in the suite: reverting it
and re-running every test file, plus the real make pdf-eval gate end to end, produced zero
failures, because score_document's own documented design whitespace-flattens both extraction and
ground truth before scoring any of the five metrics. Added a structural test asserting the raw,
unflattened extraction contains no "\n\n" — verified to actually fail against the reverted code
before being trusted. Lesson: "every metric flattens whitespace by design" is a correct, deliberate
choice for what those metrics should measure, and also a standing blind spot for anything whose only
symptom is whitespace — a fix in that category needs its own structural test, in a file that doesn't
flatten, or it ships permanently unguarded.
LOW, bundled — three findings from the same review, each small alone. slice_pages with a
negative first leaked a raw pdfium.PdfiumError instead of the module's own ExtractionError
(resolved by the same upfront validation as the HIGH finding above, which now catches it before
pdfium is ever reached). test_check_script.py's guard test asserted three substrings existed
somewhere in check.sh, which stayed green even after deliberately replacing the real make
pdf-eval call with a no-op while leaving the explanatory comment above it untouched — rewritten to
match the actual if/then/else/fi block and assert where each string falls (inside then,
absent from else), verified to fail against the gutted version before being trusted. Rate's
numerator/denominator were typed float though every call site produces an int (character,
word, and pair counts, and sums of the same) — corrected to int, the type they actually are, with
no behavioural effect found. Lesson, shared: "does this test still pass if I break the thing it
claims to guard" is a cheap, five-minute check worth running on every new test before trusting it —
two of these three would have shipped a false sense of coverage without it.
Cross-platform scanned-fixture rendering — main CI-red since I2, unnoticed for three pushes (20260728 08:13)¶
HIGH — ./check.sh passing locally was silently substituted for "CI is green," and nobody was
checking which one had actually been verified. I2's merge, I3a's merge, and I3b's merge each
pushed to main believing the suite was green, because each one was green — on macOS, the only
platform any of them had run on. gh run list (prompted by the user asking to check GitHub
Actions, not by any check of my own) showed all three runs had actually failed on
check (light pdf) / check (light pdf claude), with the identical signature every time:
test_scanned_regeneration_within_tolerance — scanned-clean: 8006 pixels differ by >32 levels.
Three consecutive merges landed on a red main and every one of them was reported to the user as
successfully shipped. Lesson: "the local gate is green" and "CI is green" are different claims —
one is evidence for the other, not a substitute for it, and the project's own standing rule (check
actual CI status before building on top of a branch) applies with equal force to checking it after
pushing to one.
Root cause, confirmed empirically rather than assumed. Every text fixture referenced /BaseFont
/Helvetica with no embedded font program (pdfwriter.py, since I2), relying on the PDF reader's
own substitution for a font it doesn't have. pypdfium2 ships platform-specific prebuilt binaries;
macOS has a real Helvetica installed, ubuntu-latest does not, so pdfium substitutes a different
font on each — metrically compatible (identical word-wrap, identical line breaks) but with
different glyph outlines. The scanned stratum rasterizes baseline-12p through pdfium at
fixture-generation time, baking whichever platform generated it into the committed PDF, so CI's
own regeneration (on a different platform) could never match. Confirmed, not theorized: a Docker
ubuntu:24.04 container reproduced CI's exact number (8,006 px) on the first attempt, and a diff
heatmap of the two renders showed every changed pixel sitting exactly on a glyph edge — same text,
same word positions, same line breaks, different anti-aliasing. Measuring all ten scanned pages
found cross-platform noise ranging 507-8,262 px depending on how much text and how much the
contrast reduction already suppressed it (scanned-low-contrast came in far lower than
scanned-clean, consistent with the tolerance test's own documented noise-floor behavior).
The obvious fix (raise the tolerance) was rejected on evidence, not instinct. The measured
noise ceiling (8,262 px) sits far closer to the test's stated detection target — a single moved
word, "a small fraction of a page" per the test's own docstring — than to the documented
whole-page-shift signal (33,451 px) the 300 px threshold was originally sized against. Raising
MAX_CHANGED_PIXELS above the noise ceiling would have unblocked CI immediately, but a real
single-word regression is plausibly smaller than 8,262 px, meaning a tolerance wide enough to
absorb cross-platform noise would very likely also have absorbed exactly the class of regression
this test exists to catch — silently, with no way to tell from a green gate. Presented three
options (raise the tolerance / scope the test to one platform / embed a real font) with honest
pros and cons rather than picking unilaterally, since it's a public-repo, shared-CI-affecting
change to a previously "verified" threshold. Fixed at the root instead: pdfwriter.py now embeds
a subsetted TrueType font (tests/pdf-corpus/fonts/LiberationSans-Subset.ttf, SIL OFL 1.1 —
Liberation Sans specifically for its Helvetica/Arial metric compatibility, so none of
generate.py's hand-placed coordinates needed to change) instead of a bare base-14 name, so every
platform rasterizes the same outlines regardless of what fonts it has installed. Re-running the
identical Docker reproduction after the fix measured 0 pixels changed across every scanned page —
not merely under tolerance, bit-for-bit identical. Lesson: when a tolerance-based test's noise
floor turns out to be closer to its detection target than expected, re-measure what the tolerance
would need to become and check that against the smallest real regression it's meant to catch,
before touching the number — a gate that still passes is not evidence it still catches anything.
MEDIUM, from independent review — the fix itself shipped with no regression test. An
adversarial re-verification (independently reproducing the pre-fix failure and the post-fix 0-pixel
result in its own Docker run, and re-deriving the subset font byte-for-byte from a genuine Debian
package to confirm tests/pdf-corpus/fonts/README.md's recipe is real) found that nothing in the
diff would have caught a future revert to bare base-14 fonts: the only guard was the pre-existing
tolerance test, whose whole failure mode is passing locally and only failing on a second platform
— exactly what let this bug live unnoticed for three merges. Fixed with a platform-independent test
(test_text_fixtures_embed_a_font_program) asserting every non-scanned fixture's committed bytes
contain /FontFile2 and never a bare /BaseFont /Helvetica — verified to actually fail against
89d4fb5's committed fixtures before being trusted, and deliberately written with no pypdfium2
dependency so it runs even on the [light]-only CI leg. The same pass found _font_widths'
first_char hardcoded at 0x20 with no symmetric downward extension (unlike last_char), which
would have indexed _ASCII_WIDTHS negatively for a hypothetical differences code below 0x20 —
unreachable today (grepped every Font(...) call site) but fixed to be symmetric regardless. Lesson:
"the existing test would have caught this eventually, on some other platform" is not the same claim
as "this increment shipped its own regression test" — a fix for a bug that was invisible on one
platform needs a check that doesn't depend on which platform runs it.
I4 — the extraction cache (20260728 10:28)¶
MEDIUM — a filename collision between a real cache entry and its own in-flight write. Every
scanning function (survey, total_stats, clear_all) globs *.json; _write's atomic-write
temp file was originally suffixed .json too (.tmp-<random>.json, meant to land beside the final
name before os.replace). Verified directly: pathlib.Path.glob("*.json") matches dot-files,
unlike shell globbing, so that temp name was scanned as a real entry by every one of them. The
window only opens on an uncatchable kill (SIGKILL, OOM, power loss — _write's own
except BaseException already cleans up anything else), but inside it a stray file could be
double-counted in pnk doctor's totals forever (unreachable via the keyed entry_path() lookup, so
a fresh real entry gets written alongside it, never replacing it) and, worse, misclassified as a
paid orphan if the abandoned write happened to carry an operation_id — read by the one thing
this module exists to protect. Fixed by suffixing the temp file .tmp, never .json, so it can
never match the glob regardless of the leading-dot question. Lesson: when several functions all
key off "does the filename match this pattern," a temp/staging file used by the same module needs
its own, deliberately non-matching pattern — matching the final name's extension by habit (.json
in, .json out) is exactly how a temp file becomes indistinguishable from a real one.
LOW-MEDIUM, no live trigger today — a JSON round-trip validated key structure but not value
types. _read's reconstruction of per_page_provenance did dict(page) for page in provenance
with no check that a page's values were actually strings, unlike page_spans three lines above
(int(span[0]), int(span[1])) — the same rigor wasn't applied to both fields reading from the
same untrusted JSON. Verified directly: a hand-written entry with {"confidence": None} was
accepted as a clean cache hit, silently degrading ExtractedText.per_page_provenance's declared
Mapping[str, str]. No live writer currently populates a non-empty per_page_provenance (pdfium.py
and the fake backend both rely on the dataclass default), so this was unreachable in practice —
but the cache exists precisely to survive untrusted/older/hand-edited files, and I5/I7b are exactly
the increments that will start writing real provenance. Fixed with _string_mapping, validating
every key and value before trusting the entry. Lesson: "no code currently writes the bad shape" is
a fact about today's callers, not a property of the format — a cache that reads its own JSON back
should validate every field it reconstructs the same way, not just the ones a current caller happens
to populate.
LOW — two verified-true claims shipped with no regression test. A cache-write failure
(chmod 0o500 on the cache directory, reproduced directly) correctly returns the extraction result
without raising, matching the contextlib.suppress(OSError) comment's claim — but nothing asserted
it. Two documents sharing one content_hash within a single KB correctly keep their shared cache
entry after one of them is deleted (eviction keys on the hash, which is still claimed by the
survivor) — only the cross-KB duplicate case had a test. Both were already correct; both now have
a test, one of which (the write-failure case) was verified to actually exercise the except OSError
branch by checking no cache file was created afterward, not merely that no exception propagated.
Lesson: an already-true claim without a test is one refactor away from becoming a false one with no
signal — "this works" and "this is tested" are different sentences even when both are honestly true
today.
Reviewed and found correct, not a defect: pnk sync --clear-cache (bare, no argument) deleting
paid cache entries along with everything else is the intended I4 behavior, not a gap — the plan's
own text ("removed only by an explicit --clear-cache=paid") describes a narrower, paid-preserving
variant that lands with I7c's ledger reader, which can price what it would be destroying; building
selective removal before that reader exists would mean guessing at a cost nothing can yet compute.
Confirmed live: an injected paid orphan was removed by pnk sync --clear-cache --yes along with
every other entry, consistent with clear_all's own docstring and docs/DESIGN.md's description.
I5 — PDF chunking, page provenance, and a backend-aware sync (20260728 13:59)¶
CRITICAL — decision 9's "never silently downgraded" guarantee held for a same-path sync and for
--rebuild, and silently did not for everything in between: a rename, or a document's first sync
on a machine that never extracted it. The initial design protected a paid extraction two ways:
pairing.py's own comparison when a document keeps its path, and --rebuild's copy-forward from
the old index. Neither covers Adopt/Rename outside a rebuild, which instead fell through to
_extract_for_index's only remaining signal — an extract/cache.py hit. A cache miss was then
read as "content changed", which is a false equivalence: a --clear-cache immediately before a
rename, or the first sync of a KB whose paid PDFs were extracted on a different machine (docs/
committed, .pinakes/ gitignored — the ordinary shape of a clone), miss the cache identically
without the file having changed at all. Confirmed live, both ways: (1) paid-index a PDF,
--clear-cache, rename it with its sidecar travelling, sync — landed a PaidExtractionRequiredError
falsely claiming changed content, and left the index describing the old, now-nonexistent path,
since the failed transaction rolled back before the rename could be recorded; (2) paid-index a PDF,
rm -rf .pinakes (index and cache, simulating a fresh clone), first sync — identical false
failure on a document that had never changed at all. An independent adversarial review (a fresh
subagent, unanchored on this increment's own design reasoning) found and reproduced both.
Fixed by moving the "has this changed" decision off the cache entirely: the sidecar's own
provenance.extraction gained a fourth field, content_hash — the file's hash at the moment of
that specific paid extraction, distinct from the general change-detection hash docs/DESIGN.md
§2.2 already refuses to store, since this one changes only when a fresh paid extraction runs.
Comparing it directly against the current file's hash answers "changed?" without consulting any
cache or index at all. Getting the actual text without paying again is a separate question, now
answered in a fixed order: this same sync's own connection (covers a rename — the row is still
there under its old path), the old index during a --rebuild (unchanged from the original design,
now keyed on doc_id rather than content_hash+path, since a rename's action only ever carries
its current path and the old index's row still has the old one), then extract/cache.py. Only
when none of the three has an answer does it fail — and now with a new, distinct error
(PaidExtractionUnavailableError) naming the file unchanged but its text simply not present on this
machine, never conflated with "content changed" (PaidExtractionRequiredError) again. The
cross-machine case (2) is not solved by this — there is genuinely no durable, shared home for a
paid extraction's text yet — but the failure is now honest about which situation it is, which
docs/DESIGN.md §9 records as an accepted, disclosed limitation rather than a silently discovered
one. Lesson: a mechanism justified as "the answer must not depend on the cache" (the --rebuild +
--clear-cache case this increment was explicitly designed around) needs that property checked
against every path that can reach the same decision, not just the one path the design started
from — pairing.py's own decision table has four ways into "paid, free-effective, unchanged", and
only one of them was ever traced all the way through.
HIGH — --rebuild turned "a paid document's content changed" into "the document vanishes from
the index", where a normal sync leaves it stale but searchable. pair()'s PaidExtractionRequired
action (decision 14) can only ever fire against a populated before, so it can never fire during a
rebuild at all (before is empty by construction) — meaning a changed-hash paid document reaching
--rebuild fell through to _extract_for_index's ordinary raise, caught by _apply's generic
exception handler, which never inserted a row for it in the first place. Confirmed live: paid-index
a PDF, change its bytes, pnk sync --rebuild — report.ok correctly False, but the document was
gone from the rebuilt index entirely (zero chunks), not merely flagged. A normal (non-rebuild) sync
hitting the identical case leaves the old row, chunks and embeddings untouched instead. Fixed by
extending the rebuild copy-forward to the changed-hash case too: the stale row is copied forward at
its old content_hash regardless of whether the current file still matches, and a failures entry
is recorded only when it does not — so a rebuild now reaches the same outcome a normal sync already
gives this exact case, rather than a harsher one purely as a side effect of which command happened
to run. Lesson: --rebuild is documented as "free, deterministic, cron-safe" — exactly the
description that invites treating it as interchangeable with a normal sync. Any new failure mode
this increment gives normal sync a considered answer for needs the identical question asked of
--rebuild explicitly, since its empty before makes "the same case" arrive by a structurally
different path that is easy to reason about in isolation and forget to reconcile.
MEDIUM — a fresh paid-provenance write left the very next sync one RefreshMetadata cycle away
from settling. _index_document decides sidecar_hash from the walk, before it rewrites the
sidecar with fresh provenance.extraction a few lines later in the same call — so the hash it
writes into documents.sidecar_hash was already stale the moment the write happened. Confirmed
live: three consecutive syncs of a freshly paid-extracted PDF produced embedded=1, then
refreshed=1 (unexpected — nothing the user did changed), then finally skipped=1. Fixed by
recomputing sidecar_hash from the file just written, whenever a write happened, before it reaches
the documents INSERT. Lesson: any function that both decides a hash-like value from an earlier
read and has the power to invalidate that exact read later in its own body needs the value
recomputed after the write, not carried from before it — "I already have this value" stops being
true the moment code between the read and its use can change what it should have been.
Caught live, during the adversarial review itself, not by this increment's own author: two
different documents can share one content_hash with only one of them ever paid-extracted (a
second document minted later for identical bytes gets its own ordinary free extraction). The
original rebuild copy-forward keyed its survivor lookup on content_hash alone, which would have
let the free twin's own rebuild incorrectly inherit the paid one's chunks, embeddings and backend
label. Found and fixed inline by the reviewing subagent (re-keying on (content_hash, path), later
superseded by the doc_id keying above once the rename/clone findings required a broader
redesign), with its own regression test. Lesson: a value that is "usually unique in practice" is
not a key — content_hash was never meant to identify one document, only to detect whether one
had changed; this table's actual primary key was sitting right there the whole time.
I6a — budget core, pure (20260728 17:52)¶
HIGH — every test of the timezone conversion that is this module's entire reason to exist would
have passed with the conversion deleted. window.py aggregates ledger records into day/month
totals in [budget] timezone, converting both now and each record's reserved_at before
comparing. Every test — including the midnight, month-end and DST-transition trio written
specifically to exercise attribution — constructed both values already in the target zone, where
.astimezone() is a no-op. The adversarial review mutated local_now = now.astimezone(timezone) to
local_now = now, and separately the per-record conversion, and both mutations passed all 35
tests. The real case is ordinary, not exotic: a ledger storing UTC timestamps (the obvious choice
for I6b) read under a non-UTC [budget] timezone — 23:30 UTC on the 15th is 00:30 on the 16th in
Berlin, so either mutation silently files the spend under the wrong day. Fixed by adding a test that
aggregates a UTC-stamped record against a Berlin window. Lesson: a test whose fixture is built in
the same units the code converts to cannot detect a missing conversion. Exercising a transformation
requires input where the transformation actually changes something — three tests that carefully
varied the clock while holding the timezone constant proved nothing about the timezone at all.
MEDIUM — an exception hierarchy copied from a sibling module inherited the wrong exception
types. prices.py was deliberately modelled on extract/floors.py, including its
except (TOMLDecodeError, KeyError, TypeError, ValueError) around parsing. But floors.py parses
with float(x), which raises ValueError on bad input, while prices.py parses with
Decimal(str(x)), which raises decimal.InvalidOperation — not a ValueError subclass
(InvalidOperation → DecimalException → ArithmeticError). A single-character price typo (a European
"5,00", an unfilled "TBD") therefore escaped as a bare InvalidOperation instead of the named
PricesMissingError the module's own docstring promises, and the test claiming to cover this only
ever exercised a TOML syntax error. Lesson: when mirroring a module's error handling, the except
clause travels with the parsing call it was written for. Changing float to Decimal changes which
exceptions are possible, and an inherited except tuple is a claim about the old code.
MEDIUM — validation absent at the one boundary where a wrong sign inverts the guarantee.
estimate_document accepted any pages/pages_estimated: pages=0 produced requests=0 and made
per_request_eur raise on a zero division, and a negative pages_estimated produced a negative
total_eur. Every other failure mode in the module — unknown model, missing prices, stale prices,
oversize request — had a named error, but the one that would make a budget guard understate spend
had none. Not reachable from any caller in this increment, since nothing calls it yet; guarded at
the source rather than trusting a caller that does not exist to be written correctly. Lesson: for
a component whose whole job is bounding a number, the input validation that matters most is
whichever one lets the number move in the safe-looking direction.
LOW, worth keeping — three assertions that were true but untested, in the same shape.
reserve_document's "every blocked window is named" was only ever tested with all three windows
breaching at once (so "always names all three" would also have passed); reserve's "first breach in
order wins" was only tested where a single window could breach (two of three caps were set to a
generous 100 in every case); and confirm_above_eur's strict > boundary was asserted only
incidentally. All three were verified correct by hand and none was a defect — but a regression in
any would have been invisible. Lesson: a passing test suite says nothing about the claims it never
puts under tension; the boundary cases worth writing are the ones where two plausible
implementations disagree.
Also fixed: Table.decimal()'s default path returned before its own minimum check, so a
below-minimum default would pass silently — integer()/number() avoid this for free by sharing
one code path between default and parsed value, which is why the bug was invisible by analogy.
ContextWindowExceededError's remedy told the user to lower a [chunking]-equivalent slice size
that does not exist as configuration (K is a fixed constant). Every fix in this increment was
confirmed to fail against the pre-fix code before landing.
I7a — the paid-path allowlist gate (20260728 19:25)¶
HIGH — the gate found two live paid-client imports on the free path, in code that had shipped.
doctor._extraction reported whether the configured extractor was available by calling
load_extractor(backend), and sync._missing_pdf_extra did the same to decide whether a skipped
.pdf still needed an extra. The registry's factory imports the client (extract/__init__.py's
_import), so on a KB whose [extraction] backend is claude-vision, both pnk doctor and
pnk sync pulled anthropic into a free-path process — doctor on every run. Neither was
reachable from any test, because every test KB is configured for pypdfium2. Nothing could
actually spend (the extractor is an I7b stub), so the cost was an import rather than a charge —
but the invariant CLAUDE.md calls non-negotiable was already false when the gate arrived to check
it. Lesson: "does the free path import a paid client" is a question about a running process,
not about the source text. A grep over src/ was green the whole time both leaks existed, because
neither leak is an import statement — it is an ordinary function call that reaches one.
HIGH — the gate's own paid KB was silently never paid. The free-path runner configures a second
KB for claude-vision, since that is the only configuration where the two probes above fire. It
did so with text.replace('backend = "pypdfium2"', ...) against a manifest template that has no
[extraction] section at all — so the replace matched nothing, the KB stayed on the free
backend, and the gate would have passed whether or not the leaks existed. Caught only because the
run printed pdf extractor: pypdfium2 importable for a KB that was supposed to say
claude-vision. Fixed by appending the section rather than replacing, by making every manifest
rewrite a _replace_once that raises when it matches nothing, and by loading the manifest back
through the real parser and asserting extraction.backend is what was asked for. Lesson:
str.replace returns the string unchanged when it matches nothing and reports it to no one — the
perfect way to build a test fixture that does not test what its name says. A fixture whose whole
purpose is to be in an unusual state must be read back through the parser and checked.
MEDIUM — two of the first six mutations survived, and both survivals were the finding.
Mutating is_backend_installed to return True passed the entire suite: its only two callers are
tested with it monkeypatched, so nothing exercised the function itself. And
test_a_directory_entry_fails_gate_1 asserted only a non-zero exit — which gate 2 produced anyway
by reporting the planted import, so the test stayed green with gate 1's directory branch deleted.
Fixed with three direct tests (including one whose probe module raises on import, so a
regression to importing errors rather than fails quietly) and by asserting gate 1's specific
message. Lesson: a function whose every caller stubs it out has no test, only agreement; and an
assertion on an exit code cannot tell which of two gates produced it.
MEDIUM — the runtime checker would have flagged google.protobuf as a paid client. Matching
sys.modules names by root (name.split(".")[0] in {"anthropic", …, "google"}) makes every
google.* module a hit, and protobuf arrives transitively with onnxruntime, grpc and much of the
ML ecosystem. Not triggered on this platform today — verified, zero google.* modules in a
free-path run — which is exactly what makes it dangerous: it would have fired first on some other
CI leg, on a change having nothing to do with money, and the obvious repair for a safety gate that
cries wolf is to weaken the gate. Now matched on a dotted-prefix boundary against
google.generativeai in full. Lesson: for a gate whose failure mode is "someone turns it off",
the false-positive direction is as load-bearing as the true-positive one — and a latent false
positive is worse than an active one, because it lands on an unrelated change.
LOW, worth keeping — gate 2 cannot see a dynamic import, and that is deliberate.
extract/__init__.py calls __import__("anthropic", …) inside the claude-vision factory, which
no import-statement grep will ever match, and it is not on the allowlist. Exempting the registry
would exempt the one file where an accidental static import is most likely; the dynamic call only
runs when a caller has explicitly selected a paid backend, which is an allowlisted entry point.
The limit is written into the gate's own docstring, and gate 4 is what covers the direction gate 2
cannot: no spelling of an import hides from sys.modules. Recorded so the next reader does not
"fix" gate 2 by widening the allowlist.
Also: pnk doctor no longer proves a paid backend's adapter constructs, only that its library is
locatable — the necessary price of not importing it, and worth remembering at I7b, when
_load_claude_vision stops being a stub. Every fix above was confirmed to fail against the pre-fix
code: 10 mutations planted, 10 detected, including both fixes made during this review.
I6b — budget I/O: the ledger, pnk budget, hooks that cannot spend (20260728 23:27)¶
HIGH — the accountant handed out a PaidCall object, undoing the one guarantee the ledger module
exists to provide. budget/ledger.py's whole argument is that a void may only be written when no
response was received, and that this is enforced by paid_call being a context manager rather than
a convention someone remembers. Accountant.open_call then wrote the reservation and returned the
object, putting both the void/unknown decision and the closing write back in the caller's hands —
and the caller it was written for is I7b's retry loop, the most branch-heavy code in the release.
Its own test left a permanent unknown outcome behind and asserted nothing about it. Now a context
manager delegating to ledger.paid_call. Lesson: an invariant enforced by a control-flow construct
is only enforced where that construct is actually used. A convenience wrapper one layer up is
exactly where it gets quietly opted out of, and "the module below guarantees it" stops being true
the moment a caller can hold the handle.
MEDIUM — a single bad character in the ledger could take pnk budget down entirely. Every euro
figure is cost_usd / usd_per_eur, computed in a property called long after parsing, from inside
the summing loop. A line with usd_per_eur of "0" therefore raised DivisionByZero out of a
read-only reporting command — defeating the malformed-line counting whose entire purpose is that no
one bad line can do that. The parse-time checks covered type (a JSON number is refused, so no
float gets in) but not domain. Rates are now validated positive at parse time, where a failure
is a counted malformed line. Lesson: validation placed at the parse boundary only protects what is
computed at the parse boundary. A derived value computed lazily elsewhere needs its inputs checked
where they enter, not where they are used.
MEDIUM — fsync on the file does not make the file's name durable. The reservation is written
before the call precisely so a crash during the call cannot lose it, and each write is fsynced. But
creating a file and fsyncing its contents leaves the directory entry unsynced, so the very first
reservation a KB ever writes — the one before its first paid call — could vanish on a crash while
every later one survived. The parent directory is now fsynced on creation only. Lesson: durability
claims have to name what is durable. "The write is fsynced" is a claim about bytes; "the record
survives a crash" additionally requires the file to still have a name.
MEDIUM — both hook checks read root/.git/hooks directly, so they were blind in a git worktree.
Inside a worktree or submodule .git is a file pointing elsewhere; hooks.hooks_dir has resolved
that since I12, and doctor never used it. Every hook read as absent, so pnk doctor reported "0 of
3 installed" and I6b's new machine-driven-spend check would have reported "no hooks installed, so no
automatic sync runs" on a KB whose hooks were installed and running. Worth more than its size here:
this project's own CLAUDE.md mandates a worktree for every change, so the layout the check is blind
on is the one it is developed in. Lesson: a helper that already handles a case is not the same as
using it — the second reader of .git reintroduced the bug the first one had solved.
LOW, worth keeping — one report, two clocks. pnk budget computes its windows in
[budget] timezone and printed the recent-operations list in the machine's local zone, unlabelled.
On a KB synced from two machines the same operation renders at two different times, and the day a
call is filed under would not match the timestamp beside it. Recorded because the fix is trivial and
the class of bug is not: a report that derives one number from configuration and another from
ambient state looks consistent on the machine it was written on.
LOW, second pass — two flags that read as the opposite of what they do. pnk budget listed the
five most recent operations and stopped, with no line saying how many it had not shown — the
silent-cap failure this plan's own ground rules name ("if a workflow bounds coverage, log() what
was dropped"). And --clear-cache's bare form parsed to the value free, which reads as "clear only
the free entries" when both spellings clear the whole cache; argparse validates const against
choices, so a private sentinel is not available and the bare form has to be a real, honest word.
It is now all, and the value names what is being authorised rather than what is removed.
LOW, third pass — the notice init --ci printed described a file it had not written.
FREE_BACKEND_NOTICE began "hooks run pnk sync --extract=pypdfium2 …" and was printed by two
callers: install-hooks, which writes hooks, and init --ci, which writes a workflow. So pnk init
--ci announced .github/workflows/pinakes.yml and then explained what the hooks do. Every test
asserted the flag and the phrase "can never spend", both of which were present and correct. Found
only by running the command the docs tell a user to run — the constant is now subject-less and each
caller supplies its own ("each hook …", "it …"). Lesson: a shared string with a subject baked in is
correct for exactly one caller. The tests all checked the part that was shared; the part that was
wrong was the part no assertion mentioned.
Also: doctor printed cost_eur — a Decimal division — with a bare f-string, putting all 28
significant digits into a health-check line; the --resolve record's operation field was
documented as a value it never takes. Every fix above was confirmed to fail against the pre-fix
code: 14 mutations planted over the implementation (14 detected), and 5 more reverting each review
fix in turn (5 detected), the last of which found that the formatting fix had no test at all until
one was written for it.
I7b — the paid Claude-vision extractor (20260729 00:24)¶
HIGH — the reconciliation recorded the reserved amount, which makes the whole
reservation/reconciliation protocol a no-op. _billed_call closed each successful call with
cost_usd=reserved_eur * usd_per_eur — the estimate again, not what the response said it cost. The
shape was perfect: a reservation, then a reconciliation superseding it, exactly as I6b's protocol
requires, with a ledger pair per call and every test about pairing passing. What it superseded the
reservation with was the reservation. Every window would have charged worst-case forever, pnk
budget would have reported an estimate as spend, and the reconciliation record's presence is
precisely what would have made it look settled. Fixed with actual_cost_usd, derived from the
response's own usage and the model's price. Lesson: I6b's tests could only ever check that a
reconciliation exists and supersedes; that it carries the right number is a claim only the
increment that produces the number can make. A protocol test and a value test look alike and are
not — and every mutation I planted over the retry logic passed straight through this, because the
bug was in the one line none of them touched.
HIGH — one bad PDF would have crashed a 1,000-document sync. TransportError and
RequestTooLargeError were plain Exceptions. sync isolates each document behind
except (PinakesError, OSError, ValueError), so an exhausted 429, a 500, or an oversized page
would have escaped that handler and taken the entire run down — the exact opposite of the
per-document isolation §6.4 promises and pnk sync's own "one broken PDF cannot block a
1,000-document corpus". Not caught by any test, because every test called extract_slice directly
and asserted pytest.raises(TransportError), which passes identically whichever base class it has.
Lesson: an exception's type is part of its contract with a caller several layers up, and a
test that catches the exception it just raised cannot see that contract at all.
MEDIUM — two mutation survivors, both the finding. A cap check hoisted out of the transport retry loop survived because every attempt inside that loop voids at zero: nothing the loop does moves the total, so the omission looks harmless. It is not — between a 429 and its backoff another process syncing the same KB can spend the headroom, and the retry would go out anyway. And the per-slice semantic budget survived because every test used a single slice, where "per slice" and "per document" are the same number; that was a defect I had found and fixed while writing the loop and then never put under tension. Lesson: a bound that is only ever exercised at N = 1 is not tested, it is agreed with.
MEDIUM — the module imported pypdfium2 at module scope, which §4.4 cannot afford. The
fingerprint path reaches this module on every query, on whatever install the user has, so a
top-level pdfium import made a coherence check on a claude-vision KB fail outright on a
core-only install. Caught by test_coherence_never_imports_a_paid_client — a test written in I2 for
a different reason, which happened to be the exact shape of this mistake. Recorded because it is
the second time this project has been saved by an import-graph test that nobody wrote for the case
that caught them.
LOW, worth keeping — the gate refused the commit, which is the gate working.
.paid-path-allowlist shipped empty at I7a specifically so that its first real entry would be
earned, and it was: the commit creating claude.py failed until the line was added. Its test then
turned out to assert only "0 exempt paths", which would have passed on any addition at all, so it
now pins the expected contents — widening an allowlist is how a gate like this one dies. Two dead
exception classes (TruncatedResponseError, RefusalError) were also removed: the stop_reason
branches replaced them and nothing ever raised either.
LOW, second pass — --estimate-only demanded an API key from a KB with nothing to estimate.
The transport was built before the walk, so a KB with no PDFs failed on a missing key instead of
reporting nothing. Built on the first PDF now. Also cleaned up in the same pass: an unreachable
except RequestTooLargeError around slice_pages (only build_request raises it, and by then the
call is committed — the size question belongs before anything is built), and a repeated
"claude-vision" literal where the module already imports the constant.
HIGH, fourth pass — the paid fingerprint omitted the model, so changing it reused another
model's text. The plan states the inputs as "(backend name, model id, prompt version, schema
version, request-shape version…)" and I wrote every one of those except the model. The cache key is
<content_hash>-<fingerprint>, so editing [extraction] model would have hit an entry a
different model wrote, with no miss, no warning and no stale marker — the §4.4 machinery intact
and looking at the wrong key. The plan names two tests for exactly this
(test_changing_the_model_misses_the_cache, test_changing_k_misses_the_cache) and I had written a
placeholder for the first that only asserted two unrelated fields were non-empty, which is how it
stayed invisible through three review passes and fifteen mutations. Fixing it meant threading
[extraction] model through the registry's FingerprintInputs contract — two real callers, sync
and the §4.4 coherence check — plus a test that the free backend's key is unperturbed by the new
parameter, or every existing free KB's index would have gone stale the day it was added. Lesson: a
test written to a name from the plan, but not to the claim behind the name, is worse than a missing
test — the plan's checklist reads as satisfied. The tell was there in the placeholder's own body:
it asserted things that could not fail.
MEDIUM, fifth pass — three of the plan's named tests had no implementation at all. Auditing
the plan's test list against the file (prompted by pass 4, since the same failure mode was in play)
found the multi-slice document, the oversize-slice split, and the no-floor-installed refusal all
missing — the first being where the slice-window arithmetic and the short final slice actually meet
a real PDF, and where an off-by-one either drops pages or sends the whole document to a paid API.
Writing them found nothing broken, which is the useful outcome to record: the code was right and
unwitnessed. Two of them also had to be sized from the fixture rather than from a constant — a
hard-coded byte threshold either never splits or recurses straight to the single-page failure, and
which one it does is a property of the corpus, not of the code. slice_windows and slice_bytes
lost their underscores in the process: they are the two things a reader most wants to check.
LOW, sixth pass — nothing drove pnk sync itself. Every test exercised a piece: the slice
loop, the ledger pairing, the cache's join key, the accountant's windows. The pieces were wired
together across four modules, and "each part works" is not the claim "the parts are connected" —
which is the seam an increment is most likely to get wrong. One end-to-end test now runs a real
pnk sync over a paid KB and checks the whole chain: the document indexed under claude-vision,
the ledger's call reconciled, and the cache entry carrying the operation_id/call_ids that §6.3
left null until this increment. It passed first time — but writing it introduced the pass's
one real defect, and only the [claude] leg saw it: the test swapped the registry entry and then
called unregister_extractor, which deletes. There is no undo for a name the package registers
at import, so two unrelated tests later in the session lost claude-vision entirely. Fixed with a
registered_entry accessor and a re-register in the finally. Lesson: a test that mutates
process-global state needs to restore the previous value, not remove its own — and a suite that
only ever runs on one CI leg will not show you which.
MEDIUM, seventh pass — the [light] leg's green was partly an artefact, and I had not really
run that leg at all. uv run --extra light does not prune extras a previous
--extra pdf --extra claude installed, so "all three CI legs pass" was one leg run three times. A
real uv sync --frozen --extra light — what CI actually does — then showed the paid suite failing
on its own while passing in a full run: tests/test_extract.py leaves a fake pypdfium2 in
sys.modules, and two --estimate-only tests were quietly relying on it. The underlying cause was
mine: _estimate_only imported page_count before the walk, so a KB with no PDFs demanded the
[pdf] extra to be told it had nothing to estimate — the same defect as pass 2's transport, one
line above where I had fixed it. Lesson: uv run --extra X is not uv sync --extra X; verifying a
matrix means reproducing what the matrix does, not asking for the same set three times. And a suite
that only ever runs after its neighbours cannot tell you what it depends on.
LOW, third pass — markdown emphasis reached a terminal. --estimate-only's help text carried
**A network call**, which argparse renders as literal asterisks: the emphasis was written for
CLI.md and pasted into a surface that has no renderer. Now checked against every command's
rendered --help output rather than argparse's internals — the artefact a user actually sees.
Backticks are deliberately allowed, since [extraction] backend reads fine in a terminal and is
the convention this CLI already used; flagging those too would have been a style crusade over
pre-existing text rather than a defect.
Also: stubs/anthropic.pyi joins stubs/pypdfium2.pyi, because the strict type gate runs on the
[light] leg where the package is absent. It records the one relationship easy to get wrong from
memory — APIConnectionError is a sibling of APIStatusError, and APITimeoutError a subclass
of the former — because checking them in the wrong order classifies every timeout as a plain
connection failure, which is the difference between recording €0 and admitting a possible charge.
18 mutations planted in total, 18 detected once the survivors got tests.
I7c — the completeness audit, staging, all-or-nothing (20260729 02:38)¶
MEDIUM — three of ten mutations survived, and all three were the same shape: a rule with no test
that it fires, only that it exists. Clearing staging after the complete entry is written had no
test that staging is ever cleared at all — and stale staging is not litter, because its key is
<content_hash>-<fingerprint>: a later run of the same document would find it, skip slices, and
serve text from a superseded extraction silently and for free. Stopping the corpus at the first cap
breach had no test that drove sync; the on_exceed tests build a SyncReport by hand, so they
can check what a stop means but never whether the loop stops. And keeping staging out of the
cache root had nothing asserting that survey, total_stats and clear_all cannot see it.
Lesson: the tests I wrote were about the rules I was thinking about while writing the code, which
is exactly the increment-shaped blind spot this project keeps rediscovering. Mutation found all
three in one pass because a mutation asks the question the test forgot: not "is the rule stated"
but "would anything notice if it stopped being true".
MEDIUM — the audit's own fixtures measured nothing. word_coverage tokenises on [a-zA-Z]+,
so forty words of f"{seed}{index}" collapse to one distinct word: every page scored 1.00, the
dropped-content test found no outlier, and the fixture could not have failed whatever the code did.
Caught only because that test failed for a different reason first. Lesson: a fixture built for a
metric has to survive that metric's own tokeniser — and "the test passes" would have hidden this
completely if the assertion had been slightly weaker.
MEDIUM, second pass — the audit made the very mistake it was written to avoid, one branch
later. A page above the yield floor whose text holds no significant words — a table of figures,
which is an ordinary PDF page, not an exotic one — gives word_coverage a denominator of zero. I
scored it 1.0: full preservation claimed for something never checked, and it drags the median
up, making the genuine outliers look less unusual. Six lines earlier the module argues at length
that a scanned page must be exempt rather than zero, for exactly the same reason in the other
direction. Now both are exempt. The fix then exposed a second worthless fixture:
_significant_words keeps only words of four characters or more, so prose("a")'s three-letter
words were never measurable either — that test had been passing purely on the 1.0 default it was
supposed to be testing around. prose now asserts its own output is measurable. Lesson: a default
that makes "unmeasurable" indistinguishable from "perfect" hides broken fixtures as effectively as
it hides broken code — and I wrote the argument against it into the docstring of the function that
did it.
LOW, second pass — one feature, two standards for the same question. _is_budget_refusal
identifies a refusal by exception type, with a comment saying an error string is prose and prose
gets reworded. Twelve lines later SyncReport.ok decided which failure was the budget's by
comparing that same prose. It now matches on the path, which is structural.
LOW — on_exceed had been parsed, validated and read by nothing since v0.1. A manifest key with
a choice() validator, a default, a template comment and a documented meaning, wired to no
behaviour at all. It now decides whether a budget stop is a failure. Recorded because validation is
what made it invisible: a key that round-trips through the parser looks implemented from every angle
except the one that matters.
LOW — the interruption I first scripted was not one the caller isolates. The test double raised
AssertionError when its script ran out, which sync does not catch, so it escaped the
per-document handler and failed the test rather than the document. Replaced with a timeout — a
real interruption, and one the caller actually handles. A test double's failure mode is part of the
test: if it fails in a way production cannot produce, the path under test never runs.
LOW, and the second time in one session — I composed a timestamp instead of reading one. This
entry's heading was written 03:04 moments after date had printed 02:38. CLAUDE.md's rule is
already explicit ("read the clock; never compose a timestamp"), and knowing the rule is evidently
not the same as running the command: the failure is that composing feels like recalling. Caught
both times only by diffing the two numbers on screen.
Also: another agent, working independently, found that I7b's own docs contradicted themselves —
STATUS.md said "claude-vision is a real extractor" in one row while the prose eight lines below
still explained that nothing can spend because it is a stub. I had updated the table and left the
paragraph justifying it. Seven review passes over I7b did not catch it. 15 mutations planted here,
15 detected once the survivors got tests.
The eval harness: three defects under one green suite (20260729 03:23)¶
Found while planning the links and graph releases, whose whole gate rests on this harness. All
three were live on main and all three passed every test.
HIGH — the multi-hop class measured nothing about hopping. Outcome.hops_followed was
computed for every scripted question and read by no metric — not recall_at_k, not by_kind,
nothing compare() looks at. Deleting the hop loop outright left by_kind["multi-hop"]
bit-identical. A multi-hop question was a single-shot search of its last hop's query wearing a
label. The one guard was assert any(outcome.hops_followed > 0 …) — an any() over five questions,
on a field that fed nothing.
HIGH — and that hid a defect in the golden set itself. Three of the five questions named their
last hop's document in expect; two named their first. So the scorer ran a query about
brittle-paper conservation and demanded the annual report. Nothing could catch the disagreement,
because hops fed no metric that could notice it. The fix makes expect exactly the union of the
hops' documents and asserts it for the committed set.
The numbers moved because the scorer was wrong, not because retrieval changed. recall@5
0.8788 → 0.9091, MRR 0.7737 → 0.8116, rerank precision 0.7273 → 0.7576, by_kind["multi-hop"]
0.80 → 1.00. Stricter scoring, higher score — because the two inverted questions had been asked
about the wrong document all along. A metric that improves when you make it stricter is telling
you it was measuring something else.
MEDIUM — compare() wrote by_kind into every baseline and never read it back. A change
lifting one class and dropping another by the same amount moves the aggregates by almost nothing;
CI was green through it. The question count had the same shape: written, never compared, so a
golden set that silently lost its hard questions would have scored better.
MEDIUM — the "cheap deterministic embedder" was not deterministic. HashingBackend hashed each
word with hash(), which Python randomises per process for str unless PYTHONHASHSEED is set —
and nothing sets it, nor can a conftest.py, since the value is read before the interpreter starts.
Which words collided in the 64-dimensional space changed run to run: one failure in 40 runs
before, zero in 60 after switching to zlib.crc32. It surfaced only because a newly written
test tripped over it once. A fake that cannot reproduce itself cannot tell a real regression from
its own noise (v0.1 rule 5).
The transferable lesson. All three survived because the tests asserted that the machinery ran,
never that it could detect. The mutation pass is what caught them: four mutants — hit ignoring
hops, the by_kind comparison, the question-count check, and the golden-set consistency assertion —
were introduced deliberately and all four killed a named test. Green proves the tests ran; only
breaking the code on purpose proves they can see.
Shared-file contention tooling (20260729 04:06)¶
HIGH — git status --porcelain's leading space is significant, and a helper doing .strip() on
the whole output silently ate it. The overlap gate's git() wrapper returned
proc.stdout.strip(), which is correct for merge-base and symbolic-ref and wrong for
status --porcelain: a modified file is reported as M CHANGELOG.md with the status in columns
0–1, so stripping the output removed the first line's leading space and the path parsed one
character short — HANGELOG.md. It matched nothing, and the gate reported "no overlap" with total
confidence. Exactly the one failure a contention gate cannot have.
Two things about how it was caught, both worth keeping:
- The tests drive real
gitagainst real temp repositories, not a mockedsubprocess. The gate is almost entirely a set of claims about git's behaviour — whatdiff A...Bmeans, which commitmerge-basepicks, howstatus --porcelainspells a rename — and a mock asserts the author's belief about each of those rather than the behaviour. A mocked test would have returned" M CHANGELOG.md"from a fake and passed with the bug present. - The mutation pass re-introduced this exact bug deliberately and confirmed the right test
fails.
git()is now documented as trailing-newlines-only, with the reason, because the next person to "tidy" it back to.strip()will find nothing obviously wrong.
MEDIUM — a clean auto-merge is not a correct merge, and only the loud half of that was being
managed. Three parallel branches edited CHANGELOG.md, docs/STATUS.md and docs/DESIGN.md
inside one hour on 20260729. CHANGELOG.md conflicted and was resolved by hand; the other two
merged silently, because the edits landed on different lines. Git merges edits that do not
overlap textually, never edits that agree — so two agents can leave one document contradicting
itself with every command reporting success, and no conflict resolution however careful would
surface it.
The response is deliberately in two layers, because one does not cover the other's cases:
changelog.d/andretro.d/remove the cause for the two documents every change must write to — separate files cannot conflict, so for those the class stops existing.tools/shared_file_overlap.pyreports what remains, which is the living documents (docs/STATUS.md,docs/DESIGN.md) that fragments do not suit, because they are edited in place rather than appended to.
MEDIUM — splicing produced two ### Added headings under one ## [Unreleased], and only
cutting a release revealed it. The tool inserted each rendered ### Category block under the
anchor without looking at what was already there, so a section that already carried an ### Added
from unmigrated prose ended up with two. Keep a Changelog expects one heading per category, and a
reader scanning for "what was added" stops at the first. Fixed by merging into an existing heading
when there is one, bounded to the anchor's own section so a shipped release's ### Added is never
written into.
Worth keeping for the reason it was missed: test_apply_leaves_existing_unreleased_prose_exactly_
where_it_was was written deliberately, and it passed — leaving existing prose alone is correct. The
case it did not imagine is that the existing prose has its own category headings. A test written
by the reasoning that wrote the code inherits its assumptions, which is the same increment-shaped
blind spot CLAUDE.md already names; here the escape was dogfooding, not mutation testing, because
the mutation pass only ever perturbs cases somebody already thought of.
Separately, and pre-existing: [Unreleased] had accumulated seven category headings by hand
over several days — two ### Added, three ### Changed, two ### Fixed. Consolidated when cutting
0.3.0, with a check that every non-heading line survived the regrouping.
LOW — the fragment tool takes --repo so its tests can drive the real artifact. Importing a
tools/ script from a test needs sys.path surgery that pyright and ty then cannot resolve —
ty failed the build on exactly that. Running it as a subprocess follows the precedent
tests/test_paid_path.py set for tools/paid_path_gate.py, and tests the same artifact check.sh
runs, argument parsing included.
I8 — Page citations on both surfaces, and pnk doctor's text yield (20260729 04:55)¶
HIGH — pinakes_get on a PDF crashed, and no test could have caught it. document() read the
source with read_text(encoding="utf-8") inside a try guarded by except OSError.
UnicodeDecodeError is a ValueError, so the guard never applied and the traceback escaped through
the MCP surface. It survived since v0.1 because no test ever called pinakes_get on a PDF — the
serve suite's KB is two markdown files, and every PDF test lives in a module that never builds a
server. A gap between two test modules is invisible to both.
HIGH — the plan's page_start == p assertion is wrong, and would have failed on correct code.
The I8 draft specifies that every chunk covering the traced offset "reports page_start == p". A
chunk that straddles a page break starts on the earlier page, so a word on the later one
legitimately sits inside a chunk whose page_start is smaller — which I5 explicitly allows and
which the citation renders as p1-2. The trace asserts page_start <= p <= page_end instead. The
draft had already corrected "exactly one chunk" to "at least one" for the same fixture; the page
assertion needed the same correction and did not get it.
MEDIUM — the stale_extraction row understated its own gap by half. DESIGN §4.7's pending
amendment said the marker "today reaches the CLI's Passage but stops there", so I8 would carry it
to the agent surface. It reached the CLI's Passage object and was then dropped by the CLI
renderer too — computed in search.py, surfaced nowhere. A field that exists in a dataclass reads,
at review time, like a field that is displayed.
MEDIUM — the free per-page yield lived inside the only module allowed to import anthropic.
survey_free_yield measures what pypdfium2 got out of a page; nothing about it is paid. But it
sat in extract/claude.py, so pnk doctor — a free command — could not consume it without
importing the paid path to ask a free question, against CLAUDE.md's own "never probe a backend by
loading it". Moved to extract/pageyield.py. The alternative, a second per-page loop in doctor.py,
would have been a second definition of a measurement that decides whether to spend money.
A dead statistic in a shipped template. The notes template's [budget] comment told every new
KB that "no shipped code path spends money" — written when that was true, still shipping three
releases later. docs/GUIDE.md said the paid extractor was "built but in no release yet". Both are
the same failure as the four README claims found at 0.1.2: prose drifts toward the design, because
the design is what you are thinking about while writing it. Neither was in the increment's scope;
both were found by reading the files the increment touched for other reasons.
Mutation testing found the test whose name was stronger than its assertion. Twelve of thirteen
mutations were detected. The survivor deleted pnk doctor's unmeasured-document tally, and
test_a_swept_cache_entry_is_counted_as_unmeasured_rather_than_as_a_pass stayed green — because
that test sweeps the whole cache and reads a branch that counts documents rather than the tally.
The mixed case, where some documents measure and others do not, is the one the tally exists for, and
it had no test. Its name claimed the general property; its body tested the degenerate one.
The review pass over I8's own diff¶
Three defects, all in pnk doctor's new check, all found by reading it adversarially rather than
by any test:
HIGH — the health check crashed on an unhealthy KB. is_paid_backend raises
BackendUnknownError on a name it does not recognise, and the check passed it every PDF's recorded
backend. A KB indexed by a newer Pinakes, or with an extra since uninstalled, would make pnk
doctor itself raise — the one command someone runs because their KB is in a state they do not
understand. §4.4's coherence check has carried the identical guard, with the identical comment,
since I5; the new code was written beside it and did not copy it.
MEDIUM — a KB whose PDFs are all paid-extracted got a permanent, unclearable warning, with a
remedy (pnk sync) that on those documents spends money. The check deliberately skips
paid-extracted documents, then reported the resulting empty measurement through the branch meant
for a swept cache. Skipped-on-purpose and lost look identical to a counter.
LOW — a single out-of-range page bound was reported as a backwards range. page_start=5 on a
two-page document read "pages 5-2 is not a range within it", because the bounds were validated
after the omitted one was defaulted. It describes a range the caller never asked for, and reads as
Pinakes' mistake rather than a bad argument. Found by running the tool, not by reading it.
What the tests could not have caught. All three needed either a KB state no fixture builds (an unknown backend name, wholly paid extraction) or a human reading an error message. The increment's own tests were green throughout, and so was a sixteen-mutation pass — mutation only perturbs cases somebody already thought of, which is the same limit that let the fragment tooling ship a duplicate-heading bug at the 0.3.0 release.
I9 — Auditing the verification table (20260729 05:40)¶
HIGH — the table that verifies everything verified nothing. plans/20260727_1543-v0.2.md ends with 98 rows,
each promising a property and naming the test that holds it, under a preamble reading "a promise in
a section with no owner is a wish". 61 of the 98 test paths did not resolve. Not because the
properties went untested — nearly all are tested, usually under a better name than the plan guessed
— but because the paths were written before the tests existed and implementation renamed them.
The failure is not the renaming. It is that nothing ever read the table, so it could drift a row
at a time for nine increments with every gate green. A table of test paths is prose until something
executes it, and prose about tests reads exactly like tests. The fix is not a better table: it is
tests/test_verification.py, which resolves every reference in docs/VERIFICATION.md and fails on
the first one that does not exist. The document can now go stale exactly once — in the commit that
breaks it.
The audit found a real gap on its first run, which is the argument for doing it.
test_every_v02_check_appears was assigned to I8, named in the table, and never written. Writing it
(as test_every_doctor_check_is_exercised_by_a_test) immediately found five pnk doctor checks
with no test at all: template, reranker, model cache, extensions, links. Link coverage is
a §6.2 promise; the reranker check exists so a health check does not download weights. Both had
shipped untested since I11.
MEDIUM — I wrote the exact CI assertion the plan warned against, and only running it caught it.
The plan says the core-only wheel smoke must use "a core-only KB that does not need embeddings,
because today's smoke KB fails on sentence-transformers long before it reaches an extractor, so the
assertion would prove nothing". I built a PDF-only KB believing that satisfied it, and it does not:
the embedding backend loads before any extractor, so pnk sync on a PDF-only core install still
fails on pinakes[st]. My grep -q 'pinakes\[' passed — against the wrong extra. pnk doctor is
the only surface that reaches the extractor question on a core-only install, because it reports a
failing backend as a check and carries on. Reading the plan's warning was not enough to avoid the
thing the plan warned about; running the command was.
A plan is a historical record, and correcting it would have destroyed the evidence. The
temptation was to fix the 61 paths in place. That would have erased the only proof that predicted
test names drift — and with it the reason tests/test_verification.py needs to exist. The plan
keeps its predictions under a dated supersession note; the resolved mapping lives in docs/.
A sidecar that would not parse was replaced by a freshly minted one (20260729 07:26)¶
HIGH — the one failure the design says is unrecoverable, shipped since v0.1 and live in 0.4.0 on
PyPI. walk_sources dropped a sidecar it could not read (except PinakesError: continue,
sync.py:385) so that one bad file would not stop the walk. That was right. What it did not
account for is that the document then matches DESIGN §6.4's "new path, no sidecar" row — and the
mint path wrote a freshly minted sidecar over the file still holding the document's permanent ULID.
Every inbound pnk:// link points at the id that was destroyed, and there is no migration
machinery by design.
Three things made it invisible:
pnk syncreported success.report.okwas true,failuresempty,1 indexed.pnk doctorafterwards reportedsidecars: N readable,duplicate ids: none,failures: none— every check green, because the unparseable file no longer existed. The skip site's own comment said "reported bypnk doctor"; that safety net could never fire, because syncing repairs the symptom by destroying the evidence.- The module that owns the risk had already named it.
sidecar.write's atomic-rename comment calls ULID loss "the one failure in this module that no later command could repair" — and then handed the file to a caller that overwrote it deliberately. A guard written against a torn write says nothing about a deliberate one.
How it was found, and what that says. Not by a test — by hand-authoring L1's partner corpus
with one deliberately unresolvable link, syncing it, and noticing that pnk doctor reported 10
links where the density gate had just counted 13. The discrepancy was three links, all on one
document, and that document's sidecar had a new ULID and a created stamp from the sync. A
second, independent count of the same population is what exposed it; every check that read only
the post-sync state agreed with itself. L7 requires the gate's number and doctor's number to be
the same population for a different reason — so that a user and CI cannot disagree — and this is
the argument for computing both at all.
The fix, and one guard that was removed for failing its own mutation test. Minting goes through
a new sidecar.create, which refuses where a file exists; the refusal lives at the write rather
than in the caller, because "the only caller that reaches it" is a property of today's code. A
matching guard added to the --index-only branch of _mint proved undetectable by mutation —
deleting it changed no observable behaviour, only which of two SidecarErrors was reported,
because the indexing path re-reads the sidecar for its metadata and that read refuses first. It
was removed rather than kept, and _mint's docstring records why, so a later reader does not
"restore the missing check". A guard that cannot be mutated is not a guard; keeping it would have
been the kind of decoration this project's mutation step exists to catch.
The adversarial pass found the bigger half. The fix as first written covered only the case
where the document is absent from the index — a fresh KB, a fresh clone, a --rebuild. For a
document already indexed whose content is unchanged, pairing yields RefreshMetadata, and that
branch sits outside _apply's per-document try, so _refresh_metadata's re-read of the
sidecar raised straight through _apply, the action loop and sync(). One hand-broken file aborted
the entire corpus: no failures row, no set_meta, no commit, and every document after it
unprocessed — contradicting this module's own opening promise and docs/CLI.md's "failures are
recorded, the run continues". That is the likeliest route in: edit a link by hand, re-sync. Three
paths existed for one cause (Mint, Reembed, RefreshMetadata) and each behaved differently;
they now report identically. The lesson is about where the first fix stopped: it was written
against the reproduction, and the reproduction was a fresh KB because that is what a corpus author
happens to have. A fix aimed at a repro covers the repro's path.
Two smaller things the same pass caught, both about honesty rather than correctness. The refusal
said only "already exists, so a freshly minted sidecar cannot be written over it" — which reads like
a Pinakes bug (of course it exists) and says nothing about the character the user mistyped, while
DESIGN, the changelog and the commit message all claimed it named the parse error. The walk has to
swallow that error to keep walking, so the mint path now re-reads the one file to recover it. And
the remedy said "repair the file rather than deleting it — it holds the permanent ULID", which is
false for the second shape the tests deliberately parametrise over: id: not-a-ulid has no ULID to
repair to, and a user in a blocked pre-commit was being told not to do the only thing that
unblocks them.
What the tests are parametrised over, and why. Two unrelated parse failures — a malformed link
URI and a malformed id. The defect is any PinakesError from read_sidecar reaching the mint
path, and a test written only against a bad link would have gone quiet the moment link parsing
moved.
L1 — The partner corpus and the density gate (20260729 08:47)¶
HIGH — the gate did not gate the one shape it was built for. degrees was keyed by basename
(path.name), so two documents sharing a filename in different folders collapsed to one key and the
later-sorted one overwrote the earlier. Demonstrated against the shipped gate: a degree-6
hub — 50% above the cap of 4 — behind docs/aaa/policy.md exited 0 and was reported as "worst
degree 1 (policy.md)". Density alone permits one hub wired to everything; the degree cap exists
separately precisely to catch that, and a basename key is the single way it cannot. Now keyed by
path relative to the KB root. The committed corpora are flat, so nothing in this repo would ever
have exercised it — the fixture had to be built to find it.
HIGH — the gate counted sidecars where it meant documents, and was wrong in both directions.
An orphaned sidecar (which pnk sync deliberately keeps) inflated the denominator: 8 of 10 real
documents linked read as 27% and passed a 35% cap. A document whose sidecar had not been minted was
invisible, so the gate reported nonsense on any KB where sync had not run. Documents now come from
[sources] include, which is what the word means.
HIGH — two documents kept a privacy claim the increment made false. README.md still said
"The sole KB here is a small synthetic corpus" and CLAUDE.md "The only KB here is the synthetic
demo corpus", while DESIGN.md was updated in the same commit to "the two synthetic corpora". The
repo contradicted itself about what had been committed, in the section a reader consults because
they are worried about exactly that. The audit-the-neighbourhood rule exists for this and I applied
it to DESIGN alone — the file I was already editing — which is the failure mode the rule describes.
A claim that was true by coincidence, stated as if by construction. The gate's docstring,
check.sh and the changelog all said it "counts the same population pnk doctor reports". The
link counts are the same population, by construction. The document counts are not: doctor counts
indexed documents, the gate counts files matching include. They agree on the committed corpora and
nothing makes them. The wording now says which half is guaranteed.
Two gates and no test that either still exists. L1 added a check.sh gate and a CI job and
asserted neither, so deleting either left the suite green — in a repo that already has
test_check_sh_declares_the_pdf_quality_guard, written for that exact failure. The convention was
there; I did not apply it. Both are pinned now, and CI's negative step additionally greps for the
message rather than accepting any non-zero exit, since a crash, a missing corpus, or uv itself
falling over all satisfy the weaker check.
What the corpus taught about relations. counterpart was used both as a reciprocated 1:1
pairing (inward loan ↔ outward loan) and as a loose association (courier requirements → outward
loan). A later increment reading counterpart as a pairing would be misinformed by its own fixture.
Now governs.
The self-form fixture is not a fixed point of the product's own writer. sidecar.write
resolves self to a ULID on write, so anything that reads and rewrites that file destroys the trap
L2 needs — and pnk link (L6) writes exactly that key. The test catches it, but a long way from the
cause, so the hazard is named in the test rather than left to be rediscovered.
Mutation, twice. Seven targets before the review, seven after the gate was rewritten. Two
mutations appeared to survive and were worth more than the ones that failed: the first had not
applied at all — a str.replace searching for 'self' where the source says "self", the exact
no-op conftest._rewrite refuses, met in my own mutation harness, which now asserts the
substitution happened. The second was real: nothing asserted that the report prints the cap in
force rather than the module default, so --max-density 0.1 printed "27% of the 35% cap" and then
failed the corpus in the next line.
And the verification gate caught its own author. Renaming
test_the_committed_split_is_what_pnk_doctor_counts (misnamed — it never consulted pnk doctor)
turned tests/test_verification.py red until docs/VERIFICATION.md was updated with it. That is
I9 working on the first increment after it shipped.
L2 — Reverse-scan (20260730 16:51)¶
One root cause behind all three HIGH findings: I bypassed manifest.load and kept none of what
it was doing. The bypass is right — a partner may run a newer Pinakes whose manifest mentions keys
this one has never heard of, and refusing to read a neighbour's inbound links over that would make
every connected KB a version dependency of every other. But load is not only a parser; it is also
the place that rejects an absolute [sources] roots, rejects .. in one, and validates the include
patterns. Reading the TOML directly removed all of it and replaced none of it, and the partner's
manifest is input this KB does not control. Every one of the three failures below is that same
sentence.
A partner renaming its own docs/ silently deleted every inbound row it had. A roots entry
that is not a directory was a quiet continue, so the walk yielded zero sidecars, reported
complete=True, and the caller did exactly what it is written to do with a complete walk: delete
and replace. Reproduced — rows 1 → 0, link_scan empty, last_scan stamped fresh, so the retry was
suppressed for a full window too. This is precisely the mass deletion the complete flag exists to
prevent, arriving through the one door the flag was not watching, and no "successful walk" test
could ever have caught it because they all leave the partner's sidecars where they are. A missing
root is now a walk failure with a reason.
The partner's exclude was ignored, while a comment claimed otherwise. sidecars_under's
docstring said a document "whose document was excluded" contributes nothing; it read only roots
and include. The shipped notes template stamps exclude = ["**/drafts/**"], so this is not an
exotic configuration — it is the shape of every KB pnk init creates, and the scan was recording
inbound links from documents the partner's own KB does not contain.
A partner's manifest could crash pnk sync on a git hook. The sidecars_under call sat
outside the try, and Path.glob raises on patterns manifest.load would have rejected —
NotImplementedError for a non-relative pattern, ValueError for an empty one. Both escaped
sync() entirely. The module's central promise is "nothing here raises", precisely so a partner
that is merely broken cannot block a commit; the one call that could raise was the one left outside.
Two tests that could not fail, both of mine. test_the_partner_is_never_locked asserted the
partner had no .pinakes/ — on a fixture where the partner was never synced, so the directory had
never existed. It proved nothing was created and nothing whatever about locking; it now holds the
partner's real SyncLock while the local sync runs. And a test asserting no SQLite connection was
left open re-asserted pre-existing sync() behaviour (_run's finally: close() always releases
it), so no L2-shaped defect could have made it fail. Deleted rather than kept: a test that cannot
fail is worse than no test, because it is counted.
A failed local run blamed the partner. known_documents is read from the index, so a document
that failed to index this run is absent from it — and a genuine inbound link was then reported as
pointing at a document this KB does not have. It does have it; it failed to index it. The local
picture is now passed as None on a failed or budget-stopped run, which suppresses that check
without touching the rows, since the rows come from the partner and owe nothing to our state.
_run already guards active_content_hashes on report.ok for the same class of reason — the
precedent was there.
Dead code that credited itself with someone else's work. ScanResult.delisted and the
known_kb_ids parameter were computed every sync, complete with a docstring explaining that the
rows "are removed" — by a function that never read either. The sweep is store.forget_reverse_links,
which takes the manifest's ids directly. Removed, along with the SELECT DISTINCT that fed it.
Mutation: 11 targets before the review, 5 more after, all detected. The one apparent survivor
was equivalent code rather than a gap — taking src_kb_id from the declared id instead of the
partner's own is indistinguishable wherever a row is written, because the mismatch guard refuses
first. The guard is what carries the weight, so the test asserts what makes the assignment moot: a
mismatched id writes no rows and no kb_refs entry.
And a test premise of mine was wrong, which the failure said plainly. _replace_links only runs
for a document that gets an action, so the reverse-then-authored ordering needs the document to
actually change — a second sync skips everything and rewrites nothing. Worth keeping because it is a
fact about when authored links are re-asserted at all, not just about this test.
L3–L4 — The traversal core and pnk links (20260730 18:06)¶
Four HIGH findings, all in the properties the increment's own prose claimed loudest. That is the pattern worth keeping: the module docstring argued at length for double-capping, precedence and server-side clamping, and each of those three was where the defect was. Writing the argument down appears to have substituted for checking it.
The response was half-capped. max_rows and token_budget gated neighbours; the frontier
was appended to unconditionally. Measured: a caller asking for one row received 1,000
frontier entries — and the frontier is the part an agent parses to decide what to ask next. Now
capped, and ordered so that entries about nodes you did not get come first: capping without that
ordering let the depth notes of accepted nodes fill the whole budget and crowd out every rows
note, so a caller asking for 2 of 5 was told nothing about the 3 it missed.
"Every bound is clamped server-side" was true of two of the four. max_rows=10**9 returned
3,660 rows with an empty truncated. Three documents said otherwise. Once this is reachable over
MCP the caller supplying max_rows is the untrusted party, so the sentence was not merely
inaccurate.
A frontier entry contradicted the answer beside it. A node dropped by fan-out at one hop and
reached at another kept its fanout entry — while sitting in neighbours and having been
expanded. FrontierEntry's own docstring says "discovered and not expanded". Stale drops are
now retracted at return; terminal and depth are kept, because those describe accepted nodes
deliberately not expanded, which is the contract rather than a contradiction of it.
Half the stated precedence was inverted. The row and token checks ran before terminality was
consulted, so a terminal neighbour dropped by the row cap reported rows — inviting a retry with a
smaller request, which cannot help. Of the ten pairs the declared order implies, five were
backwards and exactly one was tested: the one the code happened to honour.
The gate had three separate ways of being vacuous, and its docstring was an essay about gates that cannot fail.
- It passed against a
traverse()that returned an emptyResult— every check was one-sided, so zero neighbours satisfied all of them. Now equalities. - It imported
MAX_DEPTHandMAX_ADJACENT_Kfrom the code it gates and compared them with themselves. Raising the caps to 10 and 150 moved the walk and the gate still passed, whiledocs/MANIFEST.mdwent on promising 64. The documented numbers are now literals in the gate — a second copy, which is the only thing that makes a silent change show up. - It had no negative check, in a repo where the immediately preceding increment added one to its
sibling job and a test that guards it. Added, with a
--expect-depthoverride so CI can drive the gate into failure on purpose and assert the stated reason.
Two more the same pass found. The row cap truncated by parent-expansion order while ranking was per-parent, so a top-ranked neighbour behind a low-ranked parent lost to a worthless one in front of it — the same mistake as truncate-then-rank, one level up. And node-level row dedup silently dropped a second distinct relation to the same target, in a module whose contract is that a fact about the graph is returned rather than dropped; rows are now deduped per edge while expansion stays per node.
A dead sort term with a docstring defending it. _rank sorted by (-weight, distance,
node_key) and explained that a nearer neighbour of equal weight ranks higher. _rank is called
with one hop's candidates, so distance was constant in every sort. Removing it changed nothing —
which is how it was found, and is the argument for deleting rather than believing prose.
A test that could not hold its name. test_depth_counts_logical_hops_not_physical_edges had no
hub in its fixture and its own docstring conceded the core never sees one; it was a second copy of
the clamp test wearing a larger claim, and docs/VERIFICATION.md cited it for a promise it could
not carry. Renamed to what it actually checks. The logical-hop promise belongs to the provider that
composes hubs.
And new behaviour shipped without tests. [retrieval] adjacent_k and _toml.integer(maximum=)
had none — the commit message's claim that a value above the cap is refused rather than clamped
was asserted and never executed, against this project's own rule that tests ship in the increment
that introduces the behaviour.
A process failure worth recording separately. L4 was built in L3's worktree while L3's
adversarial review was still reading it, so the reviewer found the tree dirty with a parallel
increment's work and had to run every probe against a copy. It cost the review nothing this time
because L4 added files rather than editing traverse.py, but that was luck. One increment, one
worktree, and the review finishes before the next one starts.
Four silent str.replace no-ops this session, one of which spliced a new import into the middle
of an existing one and produced a nonsense symbol. It is the same failure conftest._rewrite exists
to refuse, met in editing rather than in a fixture. Non-trivial edits now go through a tool that
errors when its anchor does not match.
L5 — pinakes_links on the MCP surface (20260731 11:29)¶
The defect was in the field nobody thought to assert¶
L5's own mutation pass killed all three of the targets the plan named. An adversarial review then mutated eight more payload fields and watched every one survive the full 887-test suite. Two of those were real defects, not merely untested:
directionwas keyed by node, while a row is(node, rel). Givena --related--> bandb --cites--> a, asking aboutareported the citation as running froma— the opposite of what someone wrote. Shipped in L4, copied verbatim into L5, wrong on both surfaces. The provider's own docstring argued the case for the key it used: "Keyed by node, because a node reached both ways is still one neighbour" — true of the node, irrelevant to the row.DIRECTIONSwas defined and never enforced.edges_oftestsin ("out", "both")andin ("in", "both"), sodirection="outbound"ran neither query and returned a confident empty answer with a "no links from here" hint.argparsechoicescovered the CLI; the MCP surface, the one an untrusted model types into, had nothing.
The lesson that generalises: a field with no assertion is a field that can be a constant. Ask of
each one, "which mutation would this catch?" — not "is it correct?". scored_by_query, the field
L3's docstring calls load-bearing, could be frozen to True; unresolved, whose contract says
"returned, never dropped", could be frozen to [].
A tidy fixture defeats a mutation test. Three fields survived even after tests were written for
them, because the KB-backed fixtures were too clean: the fake embedding backend's vectors are
orthonormal, so every cosine is exactly 1.0 and deleting round() changes nothing; nothing hit a
response cap, so truncated could be frozen empty; no frontier entry sat past distance 1. The fix
was to build the dataclasses directly and take the fixture out of the question.
Two copies of one payload had already drifted — the MCP frontier carried a distance the
CLI's did not, scored_by_query reached only one of them, unresolved dropped a kb_id its
sibling lists carried. Neither failed, because nothing compared them. They now share
pinakes.graph.present, and a test asserts both surfaces project the same keys.
Calling a tool is not the same as exercising it. The free-path gate was strengthened to invoke
pinakes_links rather than only list it — but the fixture KB had one document and no links, so the
whole neighbour projection never executed. A raise SystemExit planted in that loop never fired.
The fixture now authors one intra-KB and one unreachable-KB link, and the same probe fires.
The fix for a wrong answer produced a differently wrong answer, and the tests written with it
could not see that either. Keying directions by (node, rel) was right; merging to both
across expansions was not. directions accumulates over the whole walk, so an edge discovered
while expanding an unrelated parent rewrote a row already emitted from the start — and a row's
direction then changed with --depth, to exactly the untruth the fix was written to remove. Both
new direction tests ran at depth=1, where the start is the only parent, so neither could reach it.
A second adversarial pass found it by varying the one parameter the tests held fixed.
The generalisation: when a fix adds a rule, test the axis the rule is defined over. The rule was about which expansion a direction came from, and every test pinned a single expansion.
A third pass found no new defect in the traversal itself, and four in what surrounded it. The
(node, rel) scheme was probed against a reciprocal pair, a mutual same-rel pair, each direction,
a self-loop, a 3-cycle, a node reached at two different hops by different relations, a node reached
by two parents in one hop with opposite directions, and a node dropped by fan-out then re-reached —
all correct. What was still wrong sat one layer out: an assignment nobody asserted, a message worded
from the wrong end, a branch ordered ahead of a better one, and an assertion satisfied by a
substring.
Two of those are worth naming as patterns:
assert "-> related: b" in outputpassed on<-> related: b. A substring assertion over rendered text will match a longer glyph containing the shorter one, so dropping the outbound arrow entirely left the test green. Match whole lines when asserting on human output.- Splitting
f(x, scores=s)intof(x); f.scores = smoved a value out of the type checker's reach. The construction was covered by the tests that built providers directly; the assignment was covered by nothing, and deleting it disabled query ranking with every gate green. When a refactor turns an argument into a mutation, the mutation needs its own assertion at its own call site — and there were two call sites.
Left for the graph release (L3 core, predating this increment, found while probing): a node
dropped by fan-out at hop 1 and re-reached at hop 2 is emitted with distance: 2 although it is one
authored hop from the start; and a self-loop (a --sameas--> a) is dropped entirely — not a
neighbour, not unresolved, not on the frontier.
A fix applied to one surface is half a fix. Round 3 gave pinakes_links the rule that a
narrowed walk reports the narrowing before it reports dangling links — and left pnk links branching
on unresolved alone, in the same commit, so the CLI told a user their links "resolve to nothing"
about a document with a live neighbour one dropped --rel away. Both the docs and the changelog
described the MCP behaviour as though it were both. The two surfaces now share
present.is_filtered and present.arrow, which is the only way this stops recurring: the rule has
to live in one place, not be applied twice.
A remedy in an error message is a claim, and it was false. The dangling-links hint sent the
caller to pnk doctor — but doctor._links inspects only the destination side of local sidecar
rows, so when the missing endpoint is the link's source (a deleted document whose outbound rows
survive the soft delete) doctor reports links: OK and contradicts the message that sent you there.
Dropped the clause; extending that check belongs to L7, which owns doctor's link coverage.
Four review rounds, each finding real defects in the previous round's fix, then converging: 11 findings, then 11 with one HIGH, then 7 with none, then 5 with none. What the last two rounds found was never the traversal — it was the layer around it: an assignment nobody asserted, an assertion satisfied by a substring, a message worded from the wrong end, a branch ordered ahead of a better one, and a rule applied to one of two surfaces.
The rule two rounds were spent getting right had no test that could detect its inversion. Round
5 found the shipped behaviour correct on both surfaces and the precedence — filter before dangling
before "no links" — freely reversible with the suite green. The cause was a fixture that could not
make both conditions true at once: --rel narrows provider.unresolved as well as the neighbours
(edges_of receives the same rel), so a rel-filtered call leaves unresolved empty and the
branch being out-ranked never competes. --direction is the lever that does it — an outbound link
that dangles and an inbound one that is live. The assertion that named the defect in its own message
("one dropped argument away from a live neighbour") was the vacuous one.
Test the discriminating case, not the two sides separately. A precedence rule is only observable where both branches are eligible; a fixture that satisfies one at a time asserts the wording of each and the order of neither.
L5b — ruamel.yaml replaces pyyaml in the sidecar (20260731 11:29)¶
Swapping a YAML library is not a swap¶
The plan predicted three failures precisely, and all three landed as written — which is worth
recording because it is the first increment in this project where that happened. It named the 872nd
test ({id: x, : }, which ruamel parses, so the case fell through to the id check and the
parse-error branch had been asserting nothing), the free-path gate being red on day one, and the
ScalarBoolean coercion being insufficient at one level.
The free-path gate was defeated by its own harness, and I wrote the defect. _author_links in
tests/free_path_run.py — added in L5 to close a coverage hole — wrote a sidecar through
yaml.safe_dump. That put yaml into the very module list the gate inspects, and it was also the
last PyYAML sidecar writer in the repo: a fixture written by one library and read by another,
which is exactly the divergence the gate exists to forbid.
existing[:] = keep wipes ruamel's comment metadata outright. Reconciling a sequence by
rebuilding a keep-list destroys CommentedSeq.ca.items entirely — every comment in the block, not
just the removed entry's — while del existing[index] shifts the survivors. Measured: {} against
{0: '# first', 1: '# third'}. Both merge functions had it.
A comment before a sequence entry belongs to the entry above it, exactly as it does for a
mapping key. The plan pins the deletion limitation for mapping keys; it is broader than that. After
deleting the middle of three commented links, # first stays correct, # second reattaches to the
third link, and # third disappears. The surviving links are all correct; the prose beside one of
them is not.
"Unobservable" was the wrong conclusion; "observable only where something else is broken" was the right one. The plan's "assign a known key only when its value actually changed" looked untestable — every known-key value is the node read out of the document and written straight back, so a write of an unchanged document is already a no-op. Two attempts at a test passed against the mutated source and I wrote that no mutation of it could fail. A reviewer then removed the rule and the committed corpora stopped round-tripping: the short-circuit was masking the duplicate-link defect below, not proving its own redundancy. Once that was fixed the rule really was unobservable — but the claim was true by accident for two commits, and the difference is exactly what an adversarial pass is for.
-x makes a mutation look like it was caught by the wrong test. Two links mutations appeared to
be killed only by an unrelated pre-existing test; without -x both were also killed by the test
written for them. Run the mutation pass without early exit, or the report is about test ordering.
A merge key must be the identity the storage layer already uses. Reconciling links on to
alone looked sufficient and is undefined the moment two links point at one document with different
relations — which _links() accepts and the index stores as two rows, its primary key including
rel. Measured on the version that had it: dropping an unrelated third link rewrote the first
link's rel to the second's and deleted the second, leaving one row carrying the wrong relation
under the other's comment. The index's own PRIMARY KEY was the answer, and it was already written
down in store.py.
A recursive rule needs a depth bound as much as a base case. "A key absent from the new mapping
is deleted" is required at the top of provenance — or --force leaves a false paid claim behind —
and destructive one level down, where with_extraction_provenance builds a plain four-key
replacement and the user's own reviewed_by sits beside content_hash. One sentence, two opposite
correct answers, distinguished only by depth.
The exit criterion was the thing nobody ran. The plan's one falsifiable sentence — every
committed sidecar still round-trips — had no test, and running it by hand found a pnk://self/…
entry in partner-kb being deleted, rebuilt without its unknown per-link keys, and moved to the end
of its block. _links() expands self on read, so the loaded entry's raw to never equals
anything in the reconciliation set. The docs bounded the invariant with "pnk://self/… expansion",
which reads as the URI text changes and not the entry is rebuilt — a documented exclusion that
quietly covered a defect.
Quoting was applied on the path that was tested and not on the path that ships. Decision 23's
predicate reached the merge branch and the mint, but not the branch taken when a key first
appears — which is the branch pnk link will follow on a sidecar that has no links: yet, i.e.
almost all of them. Three quoting mutations survived the whole suite.
An error message is part of the interface. Three of this increment's breaking changes surfaced
as TaggedScalar, ScalarFloat and OctalInt — ruamel class names, from a library the user never
chose, with no remedy. The type is not what they need; "quote it, or drop the tag" is.
A fix instruction can carry its own defects, and two of pass 6's did. Keying links on the
(to, rel) pair made the pair the entire content of an entry, so no matched entry was ever
updated and every rel edit became a delete plus an append — landing straight in the comment
misattribution the rule was written to avoid. And "positional fallback among equal pairs" was too
vague to implement; what I wrote from it used a set, which collapses two identical entries: three
links in, one out. The final rule needed three explicit clauses — resolve before comparing,
multiplicity never a set, assign rel in place — each naming the shipped version that got it wrong.
"Exactly the call being protected" was true of the call and false of the argument. The
JSON-encodability check ran json.dumps(extra, sort_keys=True, ensure_ascii=False) — the same
function store.dumps_metadata calls — over extra alone. _metadata() hands that function
{"tags": …, "provenance": …, **extra}. A uniformly int-keyed {1: a} sorts perfectly on its own
and becomes mixed the moment the string keys join it, so pnk sync still crashed. Checking the
parts is not checking the whole, and the docstring asserting otherwise is what made it look done.
Two tests could not observe what they claimed, for the same reason. A plain read-write of an
unchanged document short-circuits before the merge runs, so a wanted that deduplicates survived
test_two_identical_link_entries_both_survive and a set-based collapse was invisible. Any test of
reconciliation has to change something first, or it is testing the short-circuit.
A warning is not an error, and a library that downgrades one is changing behaviour. A reused
anchor name raised ComposerError — a YAMLError — before the swap, and after it the document
loads, every alias resolves to the last anchor of that name, and the only signal is a
ReusedAnchorWarning on stderr. Three consequences, none visible in a passing suite: the value
silently changes; read()'s except YAMLError never sees it, because a Warning is not one; and
under this project's filterwarnings = ["error"] it escapes as a bare warning traceback rather than
a named error. Promoting it at the load makes the outcome independent of whatever warning filters
the calling program happens to have set — which is the right place for a property of the file
format to live.
An exclusion list is a set of claims, and claims rot. Every bound on the byte-identity
invariant — indentation, !! tags, anchors, CRLF, BOM, document markers — was prose in a table
until it was pinned by a test. Writing those tests measured two behaviours that were not on the
list at all: a plain (non-recursive) anchor on an empty value is destroyed, where the list
named only the self-referential case; and a file with no trailing newline gains one. Both are
byte changes to a file nobody edited, which is exactly what the invariant claims does not happen.
A bound stated only in prose cannot notice the library moving under it, and cannot be wrong out loud.
It also falsified a changelog line I had written: "!!int, !!float, !!bool, !!seq and
!!map keep working — verified". Verified of loading; the tag itself is dropped on write, so
!!int 3 comes back as 3. True of the value, false of the invariant, and the word "verified" is
what made it read as covering both.
A gate that has never been shown to fail is a claim too. The AST scan now proves it sees all
four shapes of a planted import — including the function-scoped one an import walk cannot reach —
and does not fire on any of the four legal ruamel forms, every one of which contains "yaml". The
stub signature test proves it can fail: writing transform into the expected set failed it,
because transform belongs to dump rather than __init__.
"PyYAML left the runtime" is true of what Pinakes declares and false of what a user's machine
has. Measured on a built wheel: bare, yaml is absent; pinakes[light] has it, transitively from
huggingface_hub. starlette and uvicorn list it too, but only under an extra they do not pull.
So the CI assertion is correctly scoped to the bare wheel — Pinakes never asks for PyYAML — and the
consequence is the part worth remembering: import yaml will succeed in a real install, so a
stray import in src/ would quietly work instead of failing loudly. That is what makes the AST scan
load-bearing rather than a second belt.
The worst defect in this increment was a rule the plan itself wrote. "One instance, reused
rather than reconstructed per call", justified by 282 µs against 399 µs. ruamel keeps the %YAML
directive from the last load() on the instance and applies it to every later load and dump:
read a sidecar carrying %YAML 1.1, then write an unrelated one that never did, and it comes back
with the directive injected and country: NO rewritten to false. The exact corruption this
increment exists to remove, reintroduced across documents, in exchange for 117 microseconds — and
freshly minted sidecars are contaminated the same way. Nothing softer fixes it: resetting version
after the load still emits the directive, pinning it up front is overwritten by the next load. A
performance justification measured in microseconds should be read as an argument that the
optimisation does not matter.
A gate that never reads the artifact it guards is checking a copy. The stub-signature test
listed the symbols in a hand-written Python dict, checked them with hasattr, and compared against
hardcoded signature supersets — so a stub declaring a parameter ruamel does not have was green under
pytest and pyright, which is the single failure decision 20 exists to catch. It parses the .pyi
files with ast now.
A fixture can be right for the wrong reason and hide the defect it was written for. The
two-links-sharing-a-to test edited the first entry, and entries are walked in descending index
order — so the single-pass form it was meant to catch happened to produce the correct answer.
Editing the second entry is the discriminating case: its fallback claims the link the first was
owed exactly, and both relations end up swapped under the wrong comments. Two of this increment's
tests have now needed the specific case rather than a representative one.
main was red for four merges, and local check.sh could not have known (20260801 06:05)¶
Four tests written across L6 and L7 passed on macOS and failed on CI, so 2314dea (L6) and
ed01b00 merged onto a red main and stayed there until L8's verification step 1 looked.
Two causes, one shape: a test that cannot build its precondition does not skip — it asserts the wrong thing.
chmod(0o000)is not a portable way to deny a read. Three fixtures built an "unreadable directory" that CI read anyway:pnk linkreportedno pinakes.toml therewhere the test demandedPermission denied, and'docs/locked/x.md' is not a document in this KBwhere it demandedcannot be read.
The first fix was wrong, and its wrongness is the lesson. It probed whether permissions are
enforced and skipped when they are not — reasoning that CI runs as root. CI is not root: the
probe reported permissions enforced, did not skip, and the run failed identically. Whatever that
runner does with a mode-000 directory, is_file() neither succeeded nor raised EACCES.
Skipping was the wrong shape regardless. It disables the guard exactly where it broke — the
environment the test could not model is the one that most needed testing. The refusal is now
injected: Path.is_file raises PermissionError, which is precisely what the guard exists to
catch, on every platform and with no filesystem semantics in the way. A test for "an OSError
becomes a PinakesError" should raise an OSError, not arrange for the operating system to.
- Two more of the same shape, found only by pushing the fix and watching CI again. A
300-character filename asserted to produce ENAMETOOLONG — the length at which a filesystem
says that is a property of the filesystem, and on CI the name was simply not a document. And an
embedded NUL in an include pattern asserted to raise from resolve() — on CI it raised
nothing, so the test asserted a problem that never occurred. Both now raise the error directly.
Every one of these tests asked the operating system to produce an error, and then asserted on
the answer. That is a test of the platform, not of the guard. The guard's contract is "an
OSError/ValueError from this call becomes a PinakesError" — so the test should raise one.
pathlib's wording is not a contract. A test assertedUnacceptable pattern: PosixPath('.'), which CPython renders asUnacceptable pattern: ''on other versions. The increment's promise is that the pattern the author wrote is named and the otherincludeentries survive; that is what it asserts now. Third instance in two increments of asserting a phrase where the property was meant.
The process failure is the larger one. ./check.sh was green before each merge, and green on
one developer machine is not green on the three-leg CI matrix — different OS, different Python
patch, different privileges. The project rule already says to check whether the latest run on the
default branch actually succeeded; it was not checked after either merge, and the second merge
landed on top of the first failure without noticing it.
gh run list --branch main belongs in the merge sequence, after the push, not in the next
increment's verification step. A red default branch blocks the release either way — finding it two
merges later only makes the bisect longer.
G1 — Is the eval reproducible? (20260801 00:52)¶
Decision 15 said measure before fixing, and the measurement paid for itself twice: once by finding a real defect, and once by contradicting the fix that was nearly written for it.
HIGH — the eval was reproducible by luck, and the luck was invisible. Running the golden set,
editing a document, re-syncing, rebuilding and comparing per-question outcomes: the real [light]
models agreed everywhere. A low-dimensional fake disagreed on one question in 41 between an
incremental sync and a --rebuild. Both facts are the same fact — 384-dimensional cosines almost
never tie exactly, and every tiebreak underneath resolved to chunks.id, the rowid, which
store.py's own schema comment says has no identity across rebuilds. A property that holds because
the corpus never exercises it is not a property, and G5's sign test was about to be built on it.
The fix is total ordering on (documents.path, chunks.ordinal) at three sites plus a stable
argsort; no measured number moved, which is the right outcome for a change that only breaks
ties, and is why this increment rewrites no baseline and needs no amendment to L8 step 5.
HIGH — the fixture was the algorithm, in the one place that judges the algorithm. The first
version of the tests used eight dimensions, reasoning "fewer dimensions, more ties". Swept against
the genuine pre-fix code, eight and sixteen dimensions both reported zero differences across all
four perturbations: collapse the space far enough and every candidate ties, so the ordering
underneath stops reaching the top-k at all. The relationship is not monotonic — 32 caught two
perturbations, 64 caught one, 128 caught two. Had the sweep not been run, G1 would have shipped a
green gate, a green test suite and a live defect, all three agreeing. The sweep is recorded in the
gate's own DIM docstring rather than the conclusion alone, because the next person's intuition
will be the same as this one's.
HIGH — the mutation harness deleted the thing it was measuring. The first mutation run restored
each mutation with git checkout -- <file>. The fix was uncommitted, so the first restore reverted
it; every later mutation then failed to apply to code that no longer had the target, and the suite
ran green against original code four times while reporting "0 failures" as though the mutations
had been survived. It read exactly like a well-tested change. Two rules fall out. Restore from a
copy of the mutated-from state, never from HEAD — git checkout restores to the last commit,
which is a different thing from "undo my mutation" whenever the work is uncommitted. And a mutation
harness must assert that its mutation applied: the rewritten one fails loudly if the target string
is not found exactly once, which is what turned three silent no-ops into an error.
MEDIUM — a stable sort needed its own test, and the obvious one was vacuous. kind="stable"
changes nothing a repeated run can observe: on a fixed input array NumPy's introsort is
deterministic. It changes what happens when the array grows, because partitioning depends on the
whole array — measured at 500 of 500 random tie-heavy arrays reordering their original entries. The
first test written for it used four tied chunks and passed under the mutation, because NumPy uses an
insertion sort below roughly sixteen elements and insertion sort is stable whatever kind says. A
fixture can be too small to contain the behaviour it is named after.
What the increment ended up asserting, and at which level. Three end-to-end tests state the property G5 needs, over the committed corpus and questions; four site tests each drive one ordering decision directly and are the mutation targets — one mutation, one failing test, verified for all four. The two levels are not redundant: the end-to-end tests can only observe ties the corpus happens to contain, which is precisely how the defect survived three releases.
The adversarial pass over G1's own diff (20260801 01:25)¶
Six findings, four of them real defects in work that was already green.
HIGH — a gate advertised a field it had retired. _plant rewrote the reranker's model name
and left [retrieval.confidence] fitted_for naming the real one, so _confidence short-circuited
and all 41 questions scored unknown. Both the gate's docstring and the tests claimed to compare
the confidence label. Naming the reranker was not enough either: the committed thresholds were
fitted on a real cross-encoder's logits and sit below every score the fake can emit, so the label
became a constant high — still unable to move. Thresholds inside the fake's range give
35 medium / 5 high / 1 low, and the field is finally live. The class of defect matters more than
the instance: a fixture that rewires half of a calibrated pair silently disables the thing it was
calibrated for, and nothing fails.
HIGH — the plan still asserted what the measurement disproved. Decision 15 says a final tiebreak
would be "a provable no-op" because cross-document ties are totalised by documents.path and
rowid order is ordinal order. Both premises are true about writes and irrelevant to the
output: documents.path cannot separate two chunks of the same document, and an incremental
sync by definition does not rewrite the files it did not touch, so rowid order stops matching corpus
order at the first re-chunked file. The plan is an executor doc; leaving that cell intact would have
licensed a G2–G5 executor to skip a tiebreak for a reason this increment measured to be false.
MEDIUM — half the gate's sweep has never observed anything. Of its four perturbations, added
and removed report zero differences against the genuine pre-fix code at every width swept
(8/16/32/64/128), while edited and renamed bite. --inject-difference cannot reveal this: it
corrupts all four alike. The gate now states it. A gate's own justification is a claim like any
other — this one said "it sweeps four ways where the tests exercise one", and two of the four were
along for the ride.
MEDIUM — the contract's file table was checked against the wrong question. It compared the two
tracks' owned files and never asked what a new gate touches. Every gate edits check.sh,
ci.yml and tests/test_check_script.py, which both tracks append to at the end of the same
regions; and G1 necessarily edits search.py and store.py, which the table lists under neither
track, because reproducibility is a property of core retrieval. Widened, with the reason.
LOW, and recorded rather than fixed — making the BM25 cut total costs a join: +11.5 ms on a
50k-chunk corpus where every chunk matches every term. That is the worst case a planner can be
given, the correctness is not optional, and the number now sits in docs/STATUS.md so a later
change can argue with it.
What the pass confirmed, having tried to break it: bm25() still resolves with the alias
present and returns byte-identical rows; the join multiplies nothing (both sides unique); the
load_vectors reordering costs nothing measurable; graph/provider.py, the other caller, reduces
to a per-document max and is order-independent; the four site tests each fail against pre-fix code;
and the artifact paths, cache keys and macOS wheels in the cross-machine job all resolve.
L6 — pnk link (20260801 01:41)¶
Every review commit on this increment found defects in the one before it, and most of them found
the previous commit's own fix or claim rather than something it had missed — 3ce150e (review 1's
containment fix had traded one defect for two), 986faf3 (review 2's fix was right; its stated
justification was not), 7b3f0a3 (the escaping-error class sat one line above the try added for it),
9c8f667 (the totality fix re-anchored the walk on the working directory), cdee8d8 (the test for
an untested branch entered it, but its assertion held either way), dbebd8b (a severity asserted,
not measured), and the last three, which took four goes at one containment rule.
No total is given, deliberately. Three drafts stated one and all three were wrong, because it
changes depending on whether 8b and 9b count as rounds of their own — and the last wrong figure
was introduced by the commit correcting the one before it. git log main..HEAD is the answer, and
it cannot go stale. What follows is the state after all of them, not a log:
the rule is to rewrite to the current state rather than layer corrections, and earlier drafts of
this fragment broke it four times — describing a concurrency
scenario a later round had disproved, calling every self-link a typo after the fix for the other
case existed, counting the rounds that had happened when it was written rather than the ones that
had, and asserting a safety property ("Path.resolve() is safe at both call sites") that was
wrong twice over: strict=False suppresses OSError, not the ValueError an embedded NUL raises,
and there are six Path.resolve() sites across the two modules rather than two.
One defect class, six instances, and why fixing it at the call site produced them¶
HIGH. cli.main catches PinakesError. Anything else is a traceback on a user's terminal — or
on an unattended post-commit hook. Six calls in this increment's blast radius raised something
else:
Path("~nosuchuser/x.md").expanduser()raisesRuntimeError, on<source>inlink.py. It bought nothing either: a~that does expand lands in$HOMEand is refused by the containment check on the next line. Copying a call across a boundary copies its justification too, andlinkscan's need for it (a[[links.kb]] pathmay be~/kbs/partner) did not survive the trip.Path.is_file()ignoresENOENT,ENOTDIR,EBADFandELOOPand raises everything else — so an unreadable parent directory (EACCES) and an over-long name (ENAMETOOLONG) on the same source path.- The
is_file()/is_dir()pair one branch over, in_via_alias: a partner KB directory this user cannot read raisedPermissionError. resolve_path, on the line immediately above thetryjust added for (3).- The same three, in the module
link.pycalls into.linkscan.scan_one's docstring promises "Never raises: every failure comes back inissues", and all of them sat in the three lines that ran before any handling did — sopnk syncon a hook became a traceback. There since L2. resolve_pathagain, bare inscan()'s freshness branch — which plainpnk synctakes, so a partner path that stopped resolving crashed everygit commitinside the TTL. The branch had no test at all.
Fixes 1–5 each wrapped the instance in front of them and stopped. What closed the class was moving
the guarantee into resolve_path itself — a guarantee three call sites each have to remember is a
function with the wrong contract — which then removed the wrappers fixes 4 and 5 had added.
The first version of that fix introduced a worse defect than the one it closed, and this is the
part worth keeping. resolve_path was made total: on text no filesystem call accepts it returned
Path(raw), the declared text, so an error could still name what the author wrote. That value is
relative, and five consumers use it as a filesystem base — (path / MANIFEST_NAME).is_file(),
why_not_a_kb, partner_sources, sidecars_under, _doc_id_of. So the walk silently re-anchored
on the process's working directory: the precise thing resolve_path's own first paragraph says
it exists to prevent, reintroduced four paragraphs below by the round that wrote it.
With a directory of that literal name in the CWD holding a readable pinakes.toml, pnk sync
walked the decoy, found nothing, stamped the scan complete — and replace_reverse_links deleted
every inbound row the real partner had, with report.ok true and no issue raised. That is the real
consequence, and it is silent data loss.
Round 8 also claimed pnk link would write the decoy's ULID into the real sidecar, permanently.
It would not, and round 9 reproduced the refusal. _document_in compares an absolute
joined.parent.resolve() against the relative root, which can never be is_relative_to, so it
fires before any sidecar is read — 'docs/one.md' is outside `partner`, which tells the user the
path they typed correctly is wrong and names neither the KB path nor the expansion failure. A
message defect, not corruption.
Three things kept that overstatement alive for a round. The true account was already in the tree —
link.py's own comment describes the misleading refusal — so the increment carried both versions
at once. The regression test's docstring asserted the severe reading, and under the round-7
mutation it failed on its first assertion, so the two that encoded the severe claim were never
reached: a test that fails proves the mutation is caught, never that it is caught for the stated
reason. And the claim was written from the mechanism (a relative base re-anchors the walk →
therefore the walk completes) rather than from running it. That is the same "prose written from the
design" failure as the two documentation defects below, in a commit message and a retrospective —
the two places where being wrong is hardest to notice later, because nothing executes them.
The answer is None, not a fallback value: text that names no path yields no path, and pyright
makes every caller say what it does instead — a type-checked obligation rather than a remembered
one, which is the same lesson one level up. The declared text is still what the message names; it
was always available as linked.path, which every caller already held. A total function is not
automatically a safe one — totality only moves the failure from a raise to a return value, and a
return value that is the wrong kind of thing is harder to notice than an exception.
Four tests fail against the round-7 shape, verified by mutation — including the two written for it, though one of those for a different reason than its docstring gave (above).
A defect class is not closed until it has been searched for, and the search is mechanical: list every call in the module that touches the filesystem and ask of each which errno it swallows.
Path.resolve() belongs on that list and was wrongly excused twice. strict=False suppresses
OSError; it does not suppress the ValueError raised for an embedded NUL, which tomllib
accepts in a manifest and pathlib will not open. Enumerated rather than excused, there are six
sites: _document_in (link.py:298) resolves a path built from user text and is now guarded and
tested; resolve_path (linkscan.py:178) is the fix above; and sidecars_under has four —
anchor, the roots entry, the pattern probe and the per-candidate check — all inside the
caller's except (OSError, ValueError, NotImplementedError, PinakesError).
The enumeration is the point: "safe at both call sites" named neither the number nor the reason,
so it could not be checked without redoing the work — whereas a count with line numbers is wrong
the moment it drifts, and says so. It has drifted twice already: round 8 corrected an earlier
version that called two of the sidecars_under sites partner-controlled when one is not, round
10's own fix added the fifth site, making "four" stale in the same commit that relied on it; and
round 13's added the sixth the same way.
The containment check took three spellings, and the first two were each wrong in one direction¶
HIGH. _document_in decides whether a path names a document in this KB.
joined.resolve()— the original — follows the final symlink before checking, so a symlinked document was refused as "outside this KB", with a remedy repeating the path the user had typed correctly.pnk syncindexes such a file,pnk doctorcalls its sidecar readable andpnk linkstraverses it; onlypnk linksaid it was not there, and nothing could link it in either direction.os.path.normpath— round 1's fix — follows nothing, so a symlinked directory underdocs/passed containment: the write went out of the KB through it, and in the other direction minted a permanentpnk://to a ULID this KB will never index, becausePath.globdoes not recurse a symlinked directory. It simultaneously refused a legitimate absolute path whose ancestor is a symlink — the ordinary shape on macOS (/tmp→/private/tmp) and behind any symlinked checkout — becausemanifest.loadresolves the root, so a verbatim comparison could never match it.joined.parent.resolve() / joined.nameis right in both directions. The directory chain is followed, so an escape through it is caught and a symlinked ancestor lands inside; the final component is left alone, so the document's own symlink is irrelevant — which is correct, becausePath.globdoes yield a symlinked file.
normpath must also not run first: it collapses docs/link-to-elsewhere/../x.md to docs/x.md
textually, turning an escaping path into one that looks contained. resolve() on the parent
collapses .. after following the links it sits behind.
The docstring was wrong for longer than the code. Two drafts justified the check with "what
decides membership is the path under [sources]" — a rule the check has never implemented, since it
compares against the KB root. Round 2 quoted that sentence as the lesson and left it in place;
round 3 found it still there. The residual it was papering over is now stated instead: a document
inside the root but outside [sources] can be linked, and the link will not resolve until that
document is ingested. Answering the [sources] question properly means re-implementing
walk_sources including its globs, and refusing a "link it now, ingest it next" order of work that
costs nothing.
Two documentation claims the code contradicted, in prose written from the design¶
HIGH. The new pnk link section told the reader that a pnk:// URI pointing at a KB not on
this machine is fine because "pnk doctor reports a dangling target; pnk links lists it under
unresolved". Neither happens. doctor.py filters its dangling list to this KB — the cross-KB
check is L7, the next increment — and provider.py's unresolved carries a docstring
explicitly refusing to widen: "a cross-KB target cannot be checked from here without the other KB,
and reporting one as unresolved on that basis would be asserting something this index has no
standing to know." A reassurance was invented for the one case the section was telling the reader
not to worry about, and half of it described something the design had already declined to build.
The replacement prose then made the same mistake twice more, which is the finding worth keeping.
Round 1's fix illustrated the missing lock with a post-commit hook firing a paid extraction — a
scenario hooks.py structurally prevents. Round 2's fix replaced that with "the one sync that
rewrites an existing sidecar is a paid extraction", which sync.py's --force-plus-free---extract
override falsifies, and which this increment's own edits to DESIGN, MANIFEST and CLAUDE.md all name
the carve-out for. A correction is a diff and earns the same verification as the line it
replaces; three rounds of unverified prose about the same paragraph is what happens otherwise.
Fixtures that were representative rather than discriminating¶
MEDIUM, four times. A test can be green because the code is right or because the input never reaches it, and the two look identical from the outside.
test_no_line_outside_the_links_block_changes_when_a_link_is_addedused a sidecar with a populatedtags:list, whichwrite()short-circuits as unchanged and therefore never touches. It could not have failed. Meanwhiletags:andprovenance:written with nothing under them were being rewritten totags: []andprovenance: {}on everypnk link— two lines changed outside the block, in the increment whose test says none are, against a promise stated as byte-identity. Reachable before L6 only from a paid PDF extraction, which is why L5b's sweep missed it;pnk linkreaches it on a first link, the common case. The siblingtest_a_known_key_with_a_null_value_does_not_crash_the_writerparametrises exactly these three keys and asserts only"id:" in text: it pins the absence of a crash and nothing about the value.- The embedded-NUL test put its NUL in the filename —
docs/a\x00b.md— where only the parent is resolved, so it never reached the guard it was written for and passed against the ordinary "not a document" refusal. Moved into a directory component. Caught by mutation, not by review. assert "outside" in messageagainst a fixture namedoutside.md, andassert "partner" in messageagainst atmp_pathending in/partner. Both were satisfied by the interpolated path, so the reason could have vanished from the wording with the test still green — proven by rewording the error and watching all 29 pass. Fixtures renamed, phrases asserted.- A fixture stops reaching its guard when a later fix gets there first, and nothing says so. The ordering test for the containment check was retargeted twice — once when the static refusal was added, once when that learned to resolve the prefix — because each fix caught its input earlier, leaving the test green and its guard unexercised. Both times the mutation found it and the reading did not. Re-run the whole mutation battery after every fix, not only a mutant for the fix itself: a fix can silently disarm a test written for something else.
- The test written for the freshness branch — the branch a finding had just called untested —
asserted only
report.ok, which holds whether that branch runs or not. Proven by forcingis_staleto returnTrue: the branch never ran and the test still passed. A skipped-fresh row carries no issue, solink_scanis the assertion that discriminates. Found by a reviewer, not by the round that wrote it, in the commit whose message called its other two fixes mutation-verified — "mutation-verified" is a per-assertion claim, not a per-commit one.
A docstring claiming a safety property its function cannot have¶
MEDIUM. _doc_id_of's owner argument was documented as preventing the pnk://self/…
retargeting defect. It cannot: only .id is returned, so owner never reaches an observable —
measured both ways, the mutation is caught by no test and the output against a partner sidecar
carrying the exact retargeting shape is byte-identical. The protection is real but lives in
linkscan.scan_one, which keeps the links it reads. A plausible rationale attached to the correct
line is harder to catch than a wrong line, because reviewing it means re-deriving the claim rather
than reading the code.
Mutation testing: a killed run poisons everything after it¶
HIGH, methodological. The first mutation run blew a two-minute timeout and was killed
mid-mutation, so its finally never restored the source. The next run's pattern then failed to match
the already-mutated file, reported "pattern not found", skipped — and that guard stayed disabled for
all ten mutants that followed. The signature is unmistakable once known: one unrelated test failing
on every mutant, including mutations that cannot reach it.
Two things made it recoverable: the disabled guard had its own test, so the failure was loud, and
./check.sh had been green minutes earlier, which dated the contamination. The fix is a baseline
snapshot taken before the first mutation and asserted after every restore — not git diff --quiet,
useless in the increment's own worktree where the source is legitimately dirty. Scope the run to the
modules under test, too: the full-suite run is what blew the timeout that caused this.
Every fix was mutation-tested against the test written for it, and three escaped, each in a
different way that "green" could not distinguish. The NUL guard had a test whose input never
reached the line. The containment-ordering test stopped reaching its branch twice, when a later fix
caught its fixture earlier. And review 14's next() guard had no test at all: the "" and "."
written for it raise at the glob() call, not at the step, so the guard one line down was never
executed — which review 14's own commit message called "eleven mutants, each killed by the right
test", and this paragraph called "all but one".
The method is now: mutate every behaviour in the function, not the ones the diff touched. That
standard was stated before it was met. The sweep that first claimed it covered three behaviours;
an independent 47-mutant pass over the whole of sidecars_under and scan_one killed 33 and left
14 alive — 2 provably equivalent, 12 unpinned. Every one of the 12 was checked and the code is
right in each, so they are coverage rather than defects, and they are listed rather than closed:
the two halves of the exclude disjunction (a deliberate mirror of sync._excluded), the
continue that bounds a pre-walk escape, .resolve() on anchor and on base, the is_file()
and sidecar-suffix skips, the two sorted() calls, partner_sources raising, and
LinkTargetMissingError's count.
Naming them is the point. A number for the battery is unverifiable afterwards — the runs leave no artefact — but which behaviours are unpinned is checkable by anyone who repeats the sweep, and that is what a later reader needs.
One mutant is genuinely equivalent: substituting the locally declared [[links.kb]] id for the
partner's own when writing an alias target changes nothing, because the refusal above has already
established the two are equal. Saying so is part of the result — the rule is enforced by that
refusal, which is caught, and the docstring records it so nobody simplifies the variable away on
the grounds that they are the same.
Green expires at the next keystroke¶
HIGH. ./check.sh ran green, then a docstring was reworded to 101 characters, then the increment
was committed. Under set -e a failing ruff check means the eleven gates after it never ran either:
the increment's own verification stopped at gate two, unnoticed, because the earlier green run was
still in mind. The rule already says green-before-review; what this adds is that the run has to be the
last thing before the commit, including after an edit to a comment.
A containment rule argued in prose and implemented for half its inputs¶
HIGH. sidecars_under reads a partner's [sources], and its docstring says why that input is
untrusted: "without the same check here, a partner manifest could point the walk at any directory
on this machine, and roots = ["/"] would be an unbounded walk on a post-commit hook." The check
existed for roots. include is exactly as partner-controlled and had none, so
include = ["../../outside/*.md"] walked out of the partner KB and this one recorded inbound links
from files the partner does not own — complete true, so sync persisted them.
The line that looks like the guard is not one. candidate.relative_to(root) ran on every match,
and relative_to is purely lexical: docs/../../outside/planted.md is relative to the root as a
string, returning docs/../../outside/planted.md rather than raising. A .. is only collapsed by
resolving, which is what the roots branch does one block above and this one did not. Two spellings
of the same rule, ten lines apart, one of them not implementing it.
The fix then took four goes, and every wrong one came from spelling the rule differently from
the place that already had it right. link._document_in resolves the parent and leaves the
final component: the directory chain is followed, so .. collapses and an escape through a
symlinked ancestor is caught, while the document's own symlink is irrelevant. That is the rule.
Each attempt reinvented it:
- Per candidate, after globbing. Correct about what to refuse, but it refuses the results:
globhas already enumerated and stat'd the whole tree by the time the first match is inspected, soinclude = ["../../../../**/*.md"]still walked the machine on everypost-commit. And an escape setscompletefalse, so nolast_scanis written and the TTL cannot suppress the retry either — unbounded, forever. - Refuse any pattern containing
.., before globbing. Bounded, and wrong in the other direction:../notes/*.mdstays inside the KB and the partner's ownwalk_sourcesingests it. This KB called a legitimate manifest an escape and then never refreshed that partner again. Refusing a partner's valid configuration is the same defect as accepting an invalid one. - Resolve the prefix before the first glob component. Defeated by a pattern that starts with
one:
*/../../../outside/**/*.mdhas an empty prefix, so the check passed unconditionally and the..ran insideglob— attempt 1's defect, reachable again. It also refused a fixed pattern naming a symlinked document, because with no glob component the "prefix" is the whole path and resolving it whole follows the final symlink:include = ["alpha.md"]refused whileinclude = ["*.md"], reaching the same file, was accepted. - Join the whole pattern and apply
_document_in's spelling to it. A glob component is just a name that does not exist, whichresolve()collapses lexically, so oneresolve()answers it with no enumeration. Ten patterns measured — a..staying inside, a directory genuinely nameda..b, a literal bracket, two escapes, one behind a leading glob, a symlinked directory under a glob, a symlinked document by both spellings, and an absolute — all correct, escapes refused in 0.12ms without touching a 3000-file tree. None of the ten contained**followed by... - Drop
**from the probe.**matches zero or more components whilePath.partscounts it as one, so keeping it let a following..cancel it and the probe landed one level below where the walk goes.**/../../**/*.mdprobed inside the KB and walked the directory containing it, recursively — linear in the outside tree, and silent, because an escape is only noticed once a candidate is yielded and that pattern matched none. Dropping it is exact rather than merely conservative: each component**expands to is one a following..then pops, so the zero-expansion is the highest the walk can reach.
Attempt 4's measurement was real and its ten patterns were all correct. It was the sampling that was wrong — ten hand-chosen inputs, none of which combined the two tokens whose interaction is the whole difficulty. A table of cases proves the cases in it, and reads like proof of the rule.
An absolute pattern is refused separately, because glob cannot walk one wherever it points —
including at this KB's own docs/. It had been folded into the escape message, which was simply
false for that case.
The lesson is not about paths. Three of the four attempts were written by reasoning about the problem afresh instead of copying the spelling from the function twenty lines away that had solved it. A rule implemented twice is a rule with two behaviours; the fix was to make the third implementation textually identical to the first two and say so in all three.
Each attempt was found by mutating its predecessor, and three of them disarmed an existing
test: a fix that catches its input earlier leaves the older test green with its guard
unexercised. By the last round two guards written in earlier increments had gone dead this way —
L2's roots containment check, whose test was satisfied by the substring "outside the KB" that the
new per-candidate check also emits, and scan_one's own except around the walk, whose only
input (include = ["/etc/**/*.md"]) the absolute branch now answers first. Both were found by
mutating behaviours this increment never touched.
So the rule is stronger than "re-run the battery after every fix": the battery is over the whole
function, not the diff — and a promise worth a guard is pinned directly (here, by making the walk
raise) rather than through an input that some later fix can intercept. Running it that way in the
next round found three more unpinned behaviours: the sidecar existence check, which predates L6
(7570a69), and the */** boundary and the next() guard, both written two rounds earlier by
this increment (425d106). The sharper reading is the second one — the code least likely to be
pinned is not the oldest, it is what a recent fix added while attention was on the defect it
closed.
The fix for "one bad entry is not the end of the partner" was itself incomplete, one line either
side of where it landed. probe.parent.resolve() sat above it unguarded, so an embedded NUL in
any but a pattern's final component still raised out of the function; and Path.match("") sat
below it, so an empty exclude entry did the same — a case the new guard's own comment cited by
name as its reason for being scoped tightly, and then did not handle. Both produced the outcome the
commit had just declared impossible. A guard placed by reasoning about one call is a guard for
one call; the same mechanical sweep that closes an escaping-error class — list every call that
can raise, ask what each does with bad input — is what this needed, and it is the third time in
this increment that lesson has been relearned rather than applied.
One more from the same sweep: the containment predicate was fooled by a trailing .., because
Path("/kb/..").is_relative_to("/kb") is lexically true. The final component is left unresolved
so a symlinked document stays readable, and .. is never a document.
sync.walk_sources has the identical shape for the local manifest and is not fixed here: it
is the user's own configuration rather than a partner's, and changing the engine's document walk is
not this increment's to do. Reported instead, and now scoped as its own increment and PATCH release
in plans/20260731_2128-source-walk-containment.md — which measured a third defect this pass had not: an
absolute local include is a raw NotImplementedError out of cli.main, the same escaping-
error class L6 spent four passes closing on the partner side.
Smaller things¶
pnk link A Awrote a self-loop, which says nothing and would return the document as its own neighbour. Refused now — and worded for both ways of arriving, because only the ULID is known here: the target really is this document, or it is a different file carrying the same id, which is a KB fault in its own right. "would link to itself" told someone who had named two different documents that one of them was itself, and pointed at neither the duplicate norpnk doctor, which finds it.os.replaceonto a symlinked sidecar destroyed the link and left a regular file, with the real file elsewhere still holding the old text.create()guards this explicitly;write()did not, andpnk linkis the first command a person points at a file of their own choosing. It now writes through the link.- The error fallback named the local KB root while the comment beside it claimed it named the declared path — reporting an unrelated readable directory for a failure that had nothing to do with it. Neither of the two tests written for that fix caught it: both asserted only the message prefix.
why_not_a_kbreproduced, one level down, the defect its own docstring exists to record. The three-way split was added because anis_dir()split called an existing regular file "no such directory" — "the one answer a person would check and find false". But the caller's probe isis_file(), so apinakes.tomlthat exists and is a directory, or a symlink to nothing, fell through to "no pinakes.toml there" with the file plainly visible inls. Found in review 9 by reading the docstring's justification against the code beneath it, which is a cheap check worth running on any function whose comment argues for its shape.pnk linktakes no lock, so a concurrent write to the same sidecar can lose one side's change. Rename-atomicity prevents a torn file, not a lost update, and DESIGN §2.2 now says which.- STATUS's surface you can use today table had no
pnk linksrow at all, an hour and a quarter after it shipped in 0.5.0 (20260731 11:27; the row landed inb96d247, 12:44) — found while writing the increment by reading the neighbourhood rather than the diff.
L7 — pnk doctor's link checks (20260801 05:40)¶
The check read a partner's index, and DESIGN §6.2 forbids exactly that¶
HIGH. The cross-KB check opened <partner>/.pinakes/index.db read-only to ask whether the
target document exists. §6.2 rules that out in the sentence that defines reverse links: they come
from the other KB's committed sidecars, "not its index, which is gitignored and simply absent
in a fresh clone, and which could not be read without holding a second KB's lock" — repeated
verbatim in linkscan's module docstring, which is the module the check imports from.
mode=ro is not enough, and this is the part worth keeping. Measured: a read-only connection still
materialises index.db-shm and index.db-wal inside the partner's .pinakes/, and cannot
checkpoint them away on close. A diagnostic command wrote into a KB it was only asked to look at.
Two more consequences fell out of the same choice: a partner cloned but never synced answered
"missing" for every target, and a partner whose .pinakes/ is mode 0500 degraded silently with an
internal StoreError message that misdiagnosed the cause.
The fix is the machinery L2 already had — partner_sources + sidecars_under + read_sidecar —
which is design-conformant, works on a fresh clone with no index at all, and is now tested that way.
The rule was in the imported module's own docstring. Not a subtle design point: a paragraph in the file the new code imports three names from.
The metric's numerator and denominator came from different populations¶
HIGH. Coverage is COUNT(DISTINCT src_doc_id) / active. sync's SoftDelete sets
state = 'deleted' and drops the chunks — it never deletes that document's origin = 'sidecar'
rows. So a deleted document still counted toward the numerator while leaving the denominator,
and the headline number of this increment reported 2 of 1 documents linked (200%).
A ratio built from two queries is two populations until something makes them one. The join is one line; noticing it needed one was the work.
The declared [[links.kb]] id is not evidence of which KB is at that path¶
MEDIUM. The check keyed partner document sets on linked.id — the local declaration.
linkscan.scan_one refuses that substitution with LinkedKbIdMismatchError, and DESIGN §6.2 rule 1
states it as a rule, because trusting the manifest files another KB's links under this alias.
Measured both ways with a manifest declaring X over a partner whose real id is Y: a target that
existed in Y was reported unresolved, and one that did not was silently resolved.
Two directions need two tests, and only one of them is obvious. Filtering on the declared id also skips a partner whose real id is the one wanted — a dangling target that goes unreported rather than misreported — and that mutant survived until a test was written for it specifically.
Four remedies could be blanked with the suite green¶
MEDIUM. The plan required "every new WARN carries a remedy" precisely because the meta-guard
(test_every_problem_carries_a_remedy) runs on a fixture where these checks are OK and carry no
problem. The helper written to stand in for it asserted is not None — which "" satisfies, while
the guard it substitutes for asserts truthiness. Four of five remedies were emptiable.
A stand-in for a guard has to assert what the guard asserts. It now returns the string and each caller asserts a phrase from it.
A test named for a guard, authoring nothing that reaches it¶
MEDIUM. test_an_unreadable_linked_kb_path_is_a_warning_not_a_traceback was written for the
sentence "a diagnostic command reporting a traceback is the one outcome pnk doctor may not
have", and named why_not_a_kb's "third caller needing the same try". It authored no cross-KB
link -- so wanted was empty, _unresolved_cross_kb returned before touching the partner, and the
test pinned the guard in _linked_kbs and neither of the two in the function the review had just
added. Both are load-bearing: a partner directory behind a mode-0000 parent raises PermissionError
out of partner_sources, and a roots entry carrying an escaped NUL -- which tomllib accepts and
Path.resolve does not -- raises ValueError out of sidecars_under.
Third time in two increments that a fixture stopped one step short of its guard, and the shape is always the same: the test sets up the failure but not the demand for it. An unreadable partner is only reached by code that has a reason to read it.
The dangling-link side of the soft-delete interaction had the same gap in miniature -- the fixture
that proves the numerator excludes a deleted document already produced 1 dangling inside this
KB in the detail it held, and asserted nothing about it. The fix was one line in a test that
already existed.
Mutants that were not the logic they claimed¶
Methodological. Four "blank the remedy" mutants replaced "A cross-KB target…" with
"" or "A cross-KB target…", which evaluates to the original string. All four reported SURVIVED,
which read as four coverage gaps and was really one broken harness. Rebuilt to replace the whole
remedies.append(...) call, all four die.
This is the second increment where a mutant that did not reproduce the real prior logic was briefly taken for a result. The check is cheap: a mutant that survives should be run against the case it claims to break before it is believed.
G2 — The headroom measurement, and what it found (20260801 12:14)¶
G2 was built to answer one question: is the graph release's gate reachable at all? It is not, on
this corpus. The precondition needed at least 7 of the ~18 single-KB multi-hop questions to fail
today. One fails. G3 does not start, and the answer arrived before anything bumped
schema_version and forced every KB in existence to rebuild — which is the whole reason the
measurement was sequenced first.
HIGH — the demo corpus has no tags, and one directory. The plan assumed otherwise.
tests/demo-kb is thirty documents in a flat docs/, and not one sidecar carries a tags key.
With mentions cut (decision 6), that leaves exactly one structural edge kind that crosses a
document boundary: co-located, through a single thirty-way directory hub. shared-tag derives
zero edges. sibling, parent/child and in-section are all intra-document and cannot bridge
two evidence documents by construction. So the "derived structure" the graph release exists to
evaluate is, on the committed corpus, one hub — and G5's own text reasons about "the directory
layout and tag vocabulary of tests/demo-kb" as though a vocabulary existed. It does not.
Whatever G3 would derive here, a result carried by it is a claim about one directory.
HIGH — a reachability probe on a thirty-document corpus is close to vacuous, and the reason is
not the probe. candidates_per_source is 30 and the corpus has ~30 chunks, so the vector channel
already returns essentially every document with a positive cosine: the funnel sees the whole
corpus on every query and then cuts to final_k = 5. A failing question is therefore almost never
a recall failure the channel could fix by reaching further — it is a ranking failure. That is why
the probe reports at-seed separately from liftable: two of the three questions the fake backend
called liftable were already among the fused candidates and merely ranked below the cut, having
traversed no edge at all. A ceiling built from those would have read as headroom and been none.
The numbers, real [light] models, tests/demo-kb at 20260801 12:14. 18 multi-hop questions,
1 failing (mh-withdrawn-collection-register), liftable 1 without authored edges and 1 with,
beyond-2-hops 0, membership-only 0. Required: 7 failing and 7 liftable without authored
edges. It fails on the first clause by six.
The questions were frozen before the probe ran, and were not re-authored afterwards. Thirteen new multi-hop chains, authored from pairs of documents that between them answer one question and share no vocabulary on the thing that joins them, with the second hop phrased in the first document's words. Seventeen of eighteen are answered correctly. Re-authoring them until seven fail is fitting the question set to the gate — the circularity decision 14 removed by cutting cross-KB questions, and undetectable once done. The honest reading is that a corpus of thirty short, topically disjoint documents cannot produce a hard multi-hop set: picking 5 of 30 is not a discriminating retrieval task, and the pipeline scores 0.94 on it.
MEDIUM — the fake backend and the real models disagree about the shape of the answer, and only one of them is the measurement. Under the deliberately tie-heavy hashing fake the same set shows 9 failing and 3 liftable without authored edges (6 with) — the exact with/without gap the plan predicted L1's hand-authored links would produce. Under the real models both collapse to 1. A measurement taken on the fake would have reported a different failure of the precondition and invited the wrong remedy.
MEDIUM — _score read Outcome objects, so the artifact could not have been re-scored. Every
metric is a function of five fields per question, but the scorer was written against the in-memory
type. Splitting score_rows(rows) out is what makes the committed artifact checkable offline, and
it is what test_the_committed_41_score_exactly_their_pre_growth_values runs on: no weights, no
network, and the 41 pre-growth questions reproduce their baseline byte-identically — measured
on macOS against a baseline written by CI's ubuntu runner, which is the same cross-machine
agreement G1's new CI job independently confirmed the same morning.
LOW — the first --fake run silently asked for real weights. _fake_kb asserted each manifest
substitution appeared exactly once; provider = "fastembed" appears twice (embedding and rerank),
so the assertion fired and the run aborted — correctly. Loosening it to "replace whatever is there"
would have left the rerank provider real and made an "offline" gate download a model. The expected
occurrence count is asserted per line, not assumed.
Review pass — MEDIUM, the empty-set skip could swallow a typo. load_questions read
raw.get("questions") or [], so a file whose key was misspelled produced an empty list — which the
new skip then reported as "a template deliberately ships none" and exited 0. Under the old
behaviour that file failed. An absent key is now an error and only an explicit questions: []
skips; the two cases are genuinely different and the skip is only safe because it can tell them
apart.
Review pass — MEDIUM, read_outcomes promised more than it delivered. Its docstring said it
"refuses a file whose rows are not rows — never a partial read", and a row missing confidence
raised a bare KeyError from the middle of the loop. Every one of the five fields reaches a metric
in score_rows, so a row missing one cannot be scored; they are now checked by name.
Review pass — LOW, fused_candidates is a stage, not an entry point. It does not run
check_coherence, because search does that before calling it. Anything reaching for the new
public function directly is querying an index that may have been built by a different embedding
model, which returns confident nonsense rather than an error (§4.4). Stated in the docstring rather
than duplicated in the function, since search would then run it twice on every query.
Source-walk containment — one rule, three sites, enforced at one (20260801 13:28)¶
The durable lesson: a containment rule argued in prose beside one of its two inputs is a rule for
one input. manifest._sources states that a source root must stay inside the KB and enforces it
for roots. include sat two lines away, validated nowhere. The same lexical
candidate.relative_to(root) non-guard then appeared at three sites — linkscan.sidecars_under
(fixed in L6 review 10), sync.walk_sources, and the sidecar sweep beside it — and the one whose
docstring carried the argument was the one that did not implement it.
All three defects were re-measured on 0.7.0 before anything was changed, against a plan that had
measured them on 0.5.0 at 900aae7. All three still reproduced, unchanged:
| Before | After | |
|---|---|---|
include = ["../../outside/*.md"] |
2 indexed, a sidecar minted outside the KB, document keyed docs/../../outside/secret.md |
ManifestError at load, naming the pattern and the root |
include = ["/abs/path/*.md"] |
bare NotImplementedError traceback out of cli.main |
ManifestError: "is an absolute path", with its own remedy |
docs/escape -> /outside, include = ["*/*.md"] |
1 indexed, a sidecar minted outside the KB |
0 indexed, the pattern reported, nothing written outside |
HIGH — a fourth defect, found by a test that was meant to pin correct behaviour. Layer 1
deliberately accepts a .. pattern that lands inside the KB (include = ["../notes/*.md"] from
docs/), because what matters is where a path lands rather than whether .. occurs in it. The test
asserting that then failed on the document's key: relative_to is lexical, so it returned
docs/../notes/n.md. Measured with roots = ["docs/", "notes/"] and
include = ["../notes/*.md", "*.md"], one file on disk produced one indexed document and two
failures — "appeared after the walk had already read this directory" — because the sidecar found
under one key was invisible under the other, and the unmatched sweep reported an indexed document as
unmatched. Nothing in the plan predicted this; it exists only because the legal .. case had never
been exercised.
The fix is lexical collapse (posixpath.normpath), not resolve(). Resolving would follow a
symlinked directory and re-key every document under it — docs/alias/x.md becoming
docs/real/x.md — which on an existing KB is a path change against a permanent identity. Lexical
collapse touches only paths containing .., and every one of those is already broken today.
Containment does not rely on it: the per-candidate check resolves.
The predicate was copied from linkscan.sidecars_under, not re-derived, and that was the whole
point. Reviews 11, 12, 13 and 14 each found a different defect in a different spelling of this one
rule: refusing any .. (rejects a valid manifest); resolving only the prefix before the first glob
component (defeated by a leading *); resolving the whole path (refuses a symlinked document
while accepting the same file via *.md); and keeping ** in the probe (it matches zero
components while Path.parts counts it as one, so a following .. cancels it). Re-deriving would
have cost that sequence again for nothing.
MEDIUM — the static layer is the bound, and the dynamic layer is the guard; neither covers the
other. Checking candidates after globbing refuses the results while still paying for the
enumeration, which is what the roots rule exists to prevent —
test_an_escaping_pattern_is_refused_without_enumerating_the_tree counts entries pulled from the
generator, not resolve() calls, because the cost being avoided is the walk itself. And a symlinked
directory has no .. and no absolute path, so it is invisible to any load-time check. The
per-candidate test breaks rather than continues, and runs before the is_file()/sidecar
skip: a pattern reaching outside that matched only directories or only sidecars hit one of those
continues first, so the walk left the KB and reported nothing.
LOW — the default include is safe by luck, not by design. ["**/*.md", "**/*.txt"] does not
escape through a symlinked directory, because pathlib's recursive ** skips them. Any user who
writes a non-recursive pattern loses that, which is exactly the shape of a guarantee nobody knows
they are relying on. Stated in walk_sources' docstring rather than left as folklore.
Mutation round — three survivors, two of them defects (20260801 13:38)¶
Eleven guards broken on purpose. Eight were caught immediately. The three that survived were worth more than the eight:
HIGH — the per-root skip copied from linkscan was data loss here. sidecars_under does
if pattern in escaping: continue, so a pattern known to escape contributes nothing under any later
root — correct there, where a dropped candidate costs one inbound link and a partner's [sources]
is one statement about one KB. Copied into walk_sources it means something else entirely: the
escapes this loop can see are symlinks, which are a property of one directory rather than of
the pattern, and a dropped candidate here is a deleted index row and an orphaned sidecar. So
docs/escape -> /outside silently stopped */*.md collecting anything under an unrelated second
root. Removed, with a test. "Copy the predicate, do not re-derive it" was the right instruction and
this was still the wrong thing to copy — the predicate and the policy around it are different
decisions.
MEDIUM — the containment check ran before is_file(), and no test could tell. Every symlink
test matched a file, so moving the check after the skip changed nothing observable. The case the
ordering exists for is a pattern that matches only a directory (or only sidecars): it hits that
continue first, and the walk leaves the KB reporting nothing. */* against a symlinked directory
containing a subdirectory is that case, and it now has a test.
MEDIUM — the break bounded nothing, because sorted() had already drained the generator. The
plan carried break, not continue, on a 360× measurement from linkscan review 12 — where the
loop is lazy. Written here as for candidate in sorted(root.glob(pattern)) the enumeration a
symlinked escape triggers has already happened by the time the first candidate is inspected, so
the break saved only the loop body, and the resolved cache made even that one dict lookup. This
is the shape of a guard inherited with its justification and without the property the justification
rested on. The loop is now lazy; output order does not depend on it, because walk_sources sorts
what it returns and the per-root sort only decided which of two candidates sharing one key won —
and they describe the same file with the same hash. Measured: 301 entries enumerated before, 1
after, and both the break and a reversion to sorted() are caught by that number.
Two tooling corrections swept in the same PATCH (20260801 13:52)¶
tools/link_density_gate.py resolved one of its two bases and not the other, so any
non-canonical root — every /tmp path on macOS — exited with a traceback. One root.resolve() at
the top of census, and a test driving the tool through a symlinked parent.
tools/fragments.py's duplicate-heading defect was already closed, and the open-corrections
entry saying "the tool is unchanged" is stale. Measured rather than assumed: three fragments
(two fixed-*, one added-* whose body begins - **Fixed: …**) spliced into a section that
already had a ### Fixed produce exactly one ### Fixed, one ### Added, and the
category-prefixed entry filed by its filename, which is where the category belongs. Both halves
have regression tests already —
test_fragments_merge_into_a_category_heading_that_already_exists asserts count("### Added") == 1.
Closed by the 0.6.0 release-prep commit, not by this one.
LOW, and the reason to write this down: fragments.py --apply is anchored to the repo it lives
in, not the working directory. Testing it by cd-ing to a temp tree spliced this worktree's
CHANGELOG.md and deleted its changelog.d/ fragment, reporting success. --repo exists exactly
for that and the tool's own test suite uses it. The damage was recoverable only because the
fragment had already been committed — which is the same rule the G1 mutation harness earned:
commit before running anything that rewrites the tree. A git checkout -- to undo a mutation
in the same session then reverted an uncommitted fix in tools/, for the second time in this
project, and was caught only by re-reading git diff --stat afterwards.
The reachable-ceiling probe, against a corpus it did not ship with (20260804 04:21)¶
HIGH — a measurement tool that absorbs malformed input reports a number that looks valid. The
finding class, stated once because it generalises past this tool: every defect below is an input
the probe accepted, turned into a plausible verdict, and reported with no mark on the output. That
is strictly worse than a crash. A crash costs an hour; a number that is quietly wrong is read into
docs/STATUS.md, decides whether a schema_version bump is licensed, and is not falsifiable after
the fact — nobody re-derives a measurement that already looks fine. Anything that converts input
into a number owes its caller a refusal for input it cannot measure, and the refusal must be a
named failure, never a diagnostic line a reader has to notice.
The two found by the rehearsal that ran the probe against an external KB — both measured on
demo-kb under the offline fake backend, where the corpus reads 18 multi-hop / 9 failing / 3
liftable (the real [light] reading of the same corpus is 18 / 1 / 1, so the real-model impact of
each is several times larger):
- A hop
expectnaming a path not in the index resolved through a lookup that answered""for an unknown path, so the hop was recordedlands=False, reachable=False— failing and unreachable, identical in the output to a genuine one. One typo tookfailingfrom 9 to 10 whileliftablestayed 3. On a 200-document corpus converted by hand from a frozen question set, this is not hypothetical. - A
multi-hopquestion with nohopsincremented the denominator and produced no verdict, so it could never be countedfailingand appeared in no other figure: 18 became 19, invisibly. The scaffolded template documentedid,question,expectandkindand never mentionedhops— the trap was armed by our own template, which is why the fix edits both the tool andsrc/pinakes/templates/notes/eval/questions.yaml.
MEDIUM — the first fix was narrower than its own commit message claimed, and an adversarial pass found three more of the same class. Worth recording because the pattern is the lesson, not the individual bugs: a guard written against the two known instances validated the thing the bug report named rather than the property the measurement needs.
- A document with no chunks. The guard asked whether the path was in
documents. Every node the channel walks is built from thechunkstable, so a document with zero chunks — a blank file, a note that is only front matter, a PDF whose free extraction yielded nothing — passes a path check and is still incapable of landing or being reached. It reproduced defect 1 digit for digit (failing9 → 10,liftable3), from a path spelled correctly. "The name resolves" is not "the measurement can use it". - A
multi-hopquestion with exactly one hop, which the guard's own wording called "multi-hop in name only" and let through. This one is worse than the defect it was written for, because it movesliftableupward (3 → 4). Under-counting fails safe against a floor; over-counting licenses the schema bump. A guard on a threshold must be written in the direction that can do harm, and the harmful direction here was the one nobody had an example of. - An empty hop
query, absorbed the same way, and a golden set with nomulti-hopquestion at all, whose entirely-zero report is indistinguishable from a measured one.
MEDIUM — the second review pass found the same hole again, in the key nobody had looked at.
question.filters is applied to the last hop and was never validated. A tags, path_prefix or
source_type the index does not hold makes that hop unable to land whatever the corpus contains.
Measured on demo-kb under the fake backend, against its 9 failing / 3 liftable: one such filter
took failing to 10; the same filter across every multi-hop question took the run to 18 failing
/ 0 liftable, exit 0, unremarked. (The review pass that found it quoted only the second figure
for a single question — checking it is what caught the difference, which is the M5 lesson applied
to the fix's own write-up.) It is the empty-query defect wearing a different key, it moves both
binding clauses, and failing moves upward. Two review passes each found one more instance of a
class the first fix was supposed to have closed, which is the actual lesson: the guard has to be
written from the list of everything the measurement consumes, not from the list of bugs already
reported. What probe() consumes is now the checklist — hops, each hop's query and expect,
the document behind that path, and filters. It is validated through search's own _filter_sql
rather than a hand-written copy, so the check cannot drift from the semantics it is checking.
MEDIUM — a third pass, and the same lesson a third time: the artifact recorded every setting
except the one that moves the number most. retrieval.rerank records the mode (local), never
the reranker's provider and model — and lands is expect in the top final_k after
reranking. Demonstrated by the reviewer on one corpus, one path, one manifest, with only
[rerank] model changed: 9 failing / 3 liftable became 18 / 12, and every identifying field in the
two artifacts compared equal. Worse, the commit that added the block claimed it mirrored
eval.py::_header — which carries three blocks, embedding, rerank and retrieval, its
docstring saying it holds "every setting that can move a row". The copy took two of the three and
dropped precisely the one not derivable from the others. When you cite a prior art as the
standard you met, diff against it. index_built_at joined the payload at the same time: a corpus
edited since its last pnk sync is measured as it stood then, and nothing else would say so.
LOW, and the most human of the findings: one defect, two accusations. The filters check ran
before the hop-path check, and filters cannot admit a path the index does not hold — so a mistyped
expect under a healthy filters: block produced two problems, the first of them pointing at the
wrong line, and a {len(problems)} count that overstated. Ordering between checks is part of a
refusal's correctness, not a detail: the message that names the wrong cause costs the same debugging
hour the guard was written to save.
LOW — a sentence assembled from parts is a sentence nobody read. The per-kind wording was
spliced mid-clause into three messages, and on the branch no test covered — a non-multi-hop
question carrying hops, which load_questions allows — it rendered "so this probe never measures
this question — only multi-hop — so no figure moves for the query rather than for the corpus —
the same silent deflation as a mistyped path": two sos, and a closing clause asserting the
deflation the same sentence had just denied. The commit message claimed that wording was fixed; no
test exercised it. Each message now ends with a whole sentence, and a test covers the branch.
MEDIUM — a test can pin a claim it cannot falsify. test_the_probe_names_the_kb_it_measured
ran only --fake, and --fake measures a copy of the demo KB: every assertion in it was satisfied
by a probe that ignored --kb and hardcoded the demo path, which is the very defect the test
names. It now runs a real --kb against a KB deliberately not called demo-kb (a small runner
script registers the fake backend in the subprocess). A test whose fixture is the default cannot
detect "always reports the default".
The same mistake then repeated one layer down, and is worth recording because it is so easy to
miss twice: the replacement asserted that kb_root is resolved, using tmp_path — which is
already absolute and already resolved, so dropping the .resolve() left the entire suite green.
An assertion whose fixture already satisfies the property tests nothing. It now also runs with a
relative --kb from the corpus's own parent directory, which is the only shape that can fail.
MEDIUM — naming the corpus is not naming the measurement. The artifact recorded which KB was
measured and not what produced the numbers. failing is hop.expect in the top final_k
passages, downstream of candidates_per_source, fusion, fusion_top_k, rerank and
vector_tier — all per-KB manifest keys. Two artifacts from two configurations of the same
corpus were indistinguishable, the exact defect the KB-naming fix was written to close, one level
in. eval.py's artifact header already recorded the same set for the same reason; this one now
does too.
MEDIUM — quoting a number without its backend. The first commit message and changelog fragment
said "measured on demo-kb: failing 9 → 10" without saying the numbers were the hashing fake's;
this repository's own retrospective already records that the fake and the real models disagree
about the shape of that answer, and the real reading is 18 / 1 / 1. A user-facing fragment reads as
the real measurement. Every measured number carries the configuration that produced it, or it is
a different claim than the one intended.
MEDIUM — four passes, and the identity question kept moving outward one input at a time. Pass
one: the artifact did not name the corpus. Pass three: it named the corpus but not the pipeline.
Pass four: it named corpus and pipeline but not the golden set — the input every printed figure
is computed from, and the one this branch's own refuse-edit-re-run loop changes most often.
Demonstrated on one corpus, one index, one manifest, rewriting only the hop queries into a generic
word: 9 failing / 3 liftable became 18 / 9, and every recorded field except reports compared
equal. The payload now carries the golden set's resolved path, a sha256 of its bytes and its
counts, plus revision on both model blocks — a revision selects weights as surely as a model name
does. One correction the sixth pass earned: that "needs no re-sync, so nothing else would move with
it" is true of [rerank] revision, which nothing compares against the index, and false of
[embedding] revision, which search.check_coherence guards — change it without a re-sync and the
run stops rather than drifting. Both are recorded; only one of them could ever have moved a figure
in silence. The general form: an artifact
must identify every input its numbers are a function of, and the way to find them is to enumerate
the function's arguments, not to wait for a reviewer to name one.
LOW — the contradiction moved instead of leaving. The per-kind wording was fixed once by
appending the conditional sentence to the end, which left "the hop is recorded
failing-and-unreachable. No figure this probe prints moves" — an assertion and its denial, one
sentence apart, in a message the previous commit claimed to have fixed. The consequence is now
entirely inside the conditional (_consequence), so a non-multi-hop question is told nothing
was recorded at all, and the test asserts the class ("no line may claim a hop was recorded")
instead of one superseded string.
LOW — one more absorption, found by asking what check_measurable does not compare. Every hop
was validated on its own and never against its siblings, so two byte-identical hops passed:
MIN_HOPS satisfied, one retrieval written twice, and liftable moved from 3 to 4 on demo-kb —
upward again. A YAML copy-paste is the realistic route.
MEDIUM — five passes, and the fixture-satisfied assertion came back twice in one commit. The
pass-four commit added the golden set to the artifact and asserted it only under --fake — where
the measured golden set is demo-kb's, so hardcoding the demo path and digest passed every
assertion and the full suite. That is the identical defect pass two found for kb_root, recorded
in this very fragment as "a test whose fixture is the default cannot detect 'always reports the
default'", reintroduced by the same author two commits later for the input he had just added. The
same commit pinned revision on both model blocks against manifest.<section>.revision — and
demo-kb declares neither, so both assertions were None == None. Writing the lesson down does
not apply it: the check is mechanical — for every assertion, ask what value the fixture already
has, and whether a hardcoded constant would pass. _fake_kb now writes distinctive revisions
into its copy, and the golden-set identity is pinned on the real---kb run.
LOW — one more absorption, one normalisation short. The identical-hops check compared
(query, expect) byte-exactly, so upper-casing or padding the duplicated query defeated it while
the retrieval stayed identical — FTS5 folds case, every backend here splits on whitespace. Measured
on demo-kb: liftable 3 → 4, exit 0, the same upward move the check was written to stop. The
fingerprint is now case-folded and whitespace-collapsed. A guard on "the same input" must
normalise the way the consumer normalises, which is the _filter_sql lesson again in a smaller
key.
The fix removes the place the defect could live, not just the symptom. _doc_id is gone;
check_measurable validates the golden set against the active documents rows and the chunked
subset up front, and probe is handed the resulting map, so an unknown path has exactly one place
it can be handled and that place refuses. Validation runs before the backend loads — on a real
run that is a model download, and a run that is going to refuse should refuse in a second.
Two smaller defects of the same family. --fake silently discarded --kb, so
--kb <corpus> --fake measured demo-kb and reported its numbers as the corpus's; and neither
output format named the KB, so two runs against two corpora produced artifacts indistinguishable on
inspection — which is exactly what made the discarded --kb survivable. The pair belongs together:
a silent substitution is only dangerous because the output is anonymous, and naming the input in
the artifact is the cheapest defence a measurement tool has. The closing prose's hardcoded
>= 7 was the same error in prose form, a claim about one corpus printed under the numbers of
another; the threshold now stays with the corpus's own measurement plan.
On testing a refusal in a subprocess. These tests run the probe against a KB whose manifest
names a backend the test subprocess never registered, so a run that got past the refusal fails
too — a bare non-zero exit proves nothing. Every refusal test asserts the named message and the
offending id/path, and test_a_well_formed_golden_set_is_not_refused is the control that keeps the
message attributable to the question rather than the environment. Two assertions were weak for the
same reason and were tightened: "hops" in stderr is contained in every refusal's closing
remedy, and "--fake" in stderr is satisfied by any argparse error, since the usage line names
both flags.
One deliberate over-reach, recorded so it can be overruled. A question-level expect naming a
missing document refuses the run although probe() never reads expect — it measures hops. It
cannot move any figure the probe prints, and refusing hard-stops a corpus whose frozen question set
may not be edited. Kept because a golden set naming a document the index does not hold is broken
for make eval, which does read it, and measuring a release precondition against an unchecked
question set is not worth the saved minute — but the refusal now says which of its lines move the
count and which do not, rather than claiming they all do.
The edge census — a .get default that would have hidden its own defect (20260804 11:25)¶
MEDIUM — the first draft of the text renderer used report.edges.get(kind, 0), which is exactly
the failure class this feature exists to prevent. The whole point of edge_census is that a kind
missing from the dict must never read the same as a kind genuinely at zero. _render's first draft
indexed the dict defensively, so if edge_census ever regressed to drop a kind — a renamed key, a
kind skipped by mistake — the printed table would still show kind 0, silently correct-looking,
while the JSON output (built with dict(self.edges), no default) would be missing the key outright.
Same bug, two output formats disagreeing about whether it happened. Caught before committing, by
asking what a reviewer would ask: "would this line notice its own input being wrong?" Fixed to
report.edges[kind] — direct indexing, so a dropped kind crashes loudly in text output too, matching
JSON. The instinct to make a formatter defensive is usually right and was wrong here: this
formatter's job is to report a fact edge_census promises to supply completely, and a default that
papers over the promise being broken is worse than a crash — the same lesson
docs/RETROSPECTIVES.md's reachable-ceiling probe retro already drew about the probe's inputs,
recurring one layer up, in the probe's own output code.
HIGH — the first edge_census design counted every hub bucket, so co-located and
shared-tag could never report zero on a corpus with documents in it, which is exactly the case
the feature exists to surface. Found by an independent adversarial-review pass (a fresh agent
given the diff and the requirement, not the implementation reasoning), not by the author: a
directory holding one document, a tag on one document — plans/20260803_2239-corpus-probe-run.md
itself calls this shape "most dirs connect nothing" — still contributed its member count to the
sum, because sum(len(members) for members in hub.values()) does not care how large each bucket
is. A corpus with real documents and zero shared structure would have printed a large positive
co-located, the opposite of what the census exists to show. Fixed by excluding buckets of size
one (_spoke_count, tools/reachable_ceiling_probe.py) — a bucket with nothing else in it
derives no edge. The reviewer also showed the first reconciliation test could not have caught
this: its "independent" expectation was sum(len(members)) over the same kind of total
(document count, tag-row count), which is invariant to which bucket each item lands in — a
grouping bug and a correct grouping produce the same sum. The fixed test computes its own
per-bucket sizes from the raw tables and applies the same size-two-or-more filter, so a fixture
now needs at least one real multi-member bucket and at least one genuine singleton bucket
(docs/alone/solo.md, deliberately alone) to mean anything.
HIGH — the first reconciliation fixture could not have caught a wrong parent-child formula,
twice. 900 words replaced an initial 240: at 240 words per heading section, structural chunking
produced exactly one chunk per heading_path, so every groups[a] * groups[b] term was 1 * 1,
and a flat += 1 per group-pair passed unnoticed. Raising every group to 900 words moved every
group to size 2, not different sizes — and 2*2 == 2+2, so the next plausible wrong formula
(groups[a] + groups[b]) also passed unnoticed; the adversarial review caught this one, mutating
the multiplication to addition and finding the test still green. Fixed with three unequal group
sizes (2, 3, 5 chunks, from 800/1300/2000 words against max_tokens = 510) chosen so no pairwise
sum equals the corresponding product. The general form, twice-demonstrated: a fixture built to be
"nonzero" is not a fixture built to distinguish the correct formula from the plausible wrong ones
near it, and "I already raised the fixture size once" is not evidence the new size clears every
nearby coincidence — each candidate wrong formula has to be checked against the actual numbers
chosen, not assumed defeated by "bigger."
MEDIUM — the first authored reconciliation counted links rows; edge_census counts unique
pairs. graph.authored is a set per document, so two rels between the same two documents
(both legal — pnk link refuses only an identical (target, rel) pair, not a second relation
between the same target) still count as one edge. The first test's "independent" expectation
filtered and counted rows, which happened to agree only because the original fixture had no
document pair linked twice. Fixed by comparing unique frozenset({src, dst}) pairs instead of
rows, and the fixture now deliberately links one pair twice with different rels to make the
distinction observable.
Common thread across all three: an adversarial reviewer with no stake in the implementation asked "would this test's expectation still be right if the code grouped things differently, or if the same edge were recorded twice?" — a question the author, having just written the grouping code being tested, did not think to ask about their own logic.
The docs site — 20260804 11:55¶
Building a rendered site over docs/ was worth it for one reason before any of the styling: the
strict build is a link checker nobody had run over these files. Its first pass reported 31
failures, and they split into two classes worth keeping apart.
Twenty-three were links that leave docs/ — ../plans/…, ../CLAUDE.md,
../tools/record_claude_fixtures.py. Every one of them is correct on GitHub and dangles on the
site, because the site only has pages for docs/. These were never wrong; they became wrong the
moment a second renderer existed. All are now absolute github.com URLs, which work on both.
Eight were heading anchors, and the fix was not the obvious one. Every failure was a heading containing an em dash or an angle-bracketed code span, and the tempting repair — rewrite the eight links to whatever MkDocs generated — would have fixed the site by breaking the copy people already read in the repo. The three algorithms disagree:
| Heading | GitHub | Python-Markdown | pymdownx |
|---|---|---|---|
## 2.1 The manifest — pinakes.toml |
21-the-manifest--pinakestoml |
…-manifest-pinakestoml |
✓ |
# The sidecar — <file>.pnk.yaml |
the-sidecar--filepnkyaml |
✗ | the-sidecar--pnkyaml |
Python-Markdown collapses the whitespace the dropped em dash leaves behind; pymdownx keeps it but
strips anything angle-bracketed as an HTML tag, where GitHub slugs the escaped heading and keeps
file. mkdocs_hooks.py implements GitHub's algorithm instead, so the site matches the docs rather
than the docs being edited to match the site. The general rule: when a doc is rendered in two
places, the renderer that arrived second adapts. Anchors are a published interface.
Two headings carry an angle-bracketed code span today, which is why this is a hook and not two edits — nothing stops a third, and the next one would fail a build with no obvious cause.
Not done, deliberately: PDFScout's 01-getting-started/-style directory tree. docs/ filenames
are read by tools/fragments.py, tools/status_header_gate.py, tools/shared_file_overlap.py,
tests/test_verification.py, CI and ~150 source comments. The numbered chapters a reader sees come
from mkdocs.yml's nav labels and javascripts/section-numbering.js; the directory layout
contributes nothing to them.
Renaming the repository broke PyPI trusted publishing — 20260804 12:40¶
Renaming lucagattoni/Pinakes → lucagattoni/pinakes was checked against everything inside the
repo — 64 URLs, the docs site, CI, the gates — and all of it passed. What it broke was outside the
repo: PyPI's trusted publisher matches on the exact repository name, and it is registered on
pypi.org, where no gate here can see it.
So v0.9.0 tagged, built, smoke-tested, and was refused at the upload:
invalid-publisher — valid token, but no corresponding publisher
repository: "lucagattoni/pinakes" # the token's claim, post-rename
# the publisher still says "Pinakes"
Nothing was uploaded, which is the only reason this was recoverable: PyPI never allows
re-uploading a version, so had the rename landed between two files of the same upload the version
would have been burned and 0.9.0 would have had to be skipped. Verified against
https://pypi.org/simple/pinakes/ rather than the JSON API, which is CDN-cached and has read as
"missing" for a correct upload before (20260729).
The durable lesson: a rename's blast radius is every system that identifies this repo by name, and most of them are not in the repo. Before renaming, enumerate the external identifiers — trusted-publishing publishers, deploy keys and webhooks, any CI in another repo that clones this one, badge and package-index metadata — because a repo-wide grep proves only that the inside is consistent. GitHub's redirect for the old URL is what makes the inside look fine and hides this.
Ordering that would have avoided the outage entirely: update the external publisher first, then rename, then tag. The publisher can name a repository that does not exist yet, so there is no window where neither name works — and the reverse order guarantees one.
Interrupted sync — a TZ test that used the fixture helper would have proven nothing (20260804 12:46)¶
HIGH — the first draft of the UTC-timestamp regression test called test_sync.py's own run()
helper, which hardcodes now="20260725 16:00" for every other test in the file specifically to
bypass the real clock. sync.py:709's stamp = now or datetime.now(UTC)... only reaches the
real-clock branch when the caller passes now=None — and run(kb, ...) never does, because that
fixed string is what makes 60-odd other tests in test_sync.py deterministic. A TZ test built on
top of run() would set TZ, call run(kb), read back meta['built_at'], and find it exactly
"20260725 16:00" regardless of which clock sync.py actually used — passing identically whether
the site under test read datetime.now() or datetime.now(UTC), because neither ever ran. Caught
before committing by asking the same question the increment's own instructions insist on for the
mutation pass: does the assertion distinguish the fixed from the broken code, or only look like it
does? Fixed by calling sync() directly with no now= override in both TZ tests
(test_a_real_sync_stamps_utc_not_local_under_a_non_utc_timezone,
test_estimate_only_stamps_utc_not_local_under_a_non_utc_timezone — the second reaches its own
independent clock at sync.py's _estimate_only, unaffected by the outer now= either way, so it
needed no such change but is named here for the record) — and mutation-verified afterward by
reverting each site to datetime.now() and confirming the corresponding test failed by roughly the
test's chosen offset (Pacific/Kiritimati, UTC+14), not merely failing.
The general rule this confirms rather than discovers: a test helper that fixes an input to make
most tests deterministic is exactly the place a new test targeting that specific input must not
reuse the helper — the fixture built to remove non-determinism from one property will just as
readily remove the property a new test exists to observe. docs/RETROSPECTIVES.md's own advice on
claiming a test is mutation-verified — run the mutant, don't just trust the assertion reads
correctly — is what caught this one before the mutation pass would have had to.
The progress printer's closing newline assumed the loop always reaches its own end (20260804 13:07)¶
MEDIUM — an independent adversarial review found that cli._progress_printer's "finished" branch
(done >= total) is the only place the printer ever emits its closing newline, and _run's loop
(sync.py) does not always reach done == total. A [budget] cap or any early exit stops the
loop partway through, so the last progress(done, total) call for that run has done < total, and
the printer's \r-prefixed line is left open — no trailing newline — for whatever prints next
(print_sync_report, or an error message on an unhandled exception) to land on. Confirmed live: a
progress line followed immediately by report text on the same terminal row. No test in the original
commit exercised done < total as a run's last call — test_progress_printer_throttles_...
always ended at done == total, and the progress-callback sync test never triggered an early stop.
Fixed by splitting _progress_printer() into (progress, finish): progress behaves as before,
but also tracks whether a line is open (dirty); finish closes it with one newline if so, and is
a no-op otherwise. run_sync calls finish unconditionally in a finally around the sync()
call, so it closes the line whether the run finished normally, stopped on a budget cap, or sync()
raised. Mutation-verified: made finish() unconditionally clear dirty without printing;
test_progress_printer_finish_closes_a_line_an_early_stop_left_open caught it (expected "\n",
got "").
The general shape of the miss: a "does this print the right thing" test that only drives the
happy path (the loop's last call always being its final call) cannot see a defect that only
exists on an early-exit path, because the assertion and the code under test share the same
unstated assumption — "the loop reaches done == total" was never itself questioned, only how the
printer behaves once it does.
A green ./check.sh only proves the worktree's own venv is green (20260804 13:30)¶
MEDIUM — test_estimate_only_stamps_utc_not_local_under_a_non_utc_timezone shipped with no
pinakes[pdf] skip marker, and ./check.sh was green anyway, because this worktree had [pdf]
installed. The test writes a real baseline-1p.pdf and calls page_count on it for real (its
own docstring already said so), which needs pypdfium2. The planner's worktree did not have the
extra installed and hit AttributeError: module 'pypdfium2' has no attribute 'PdfDocument' at
merge time — same commit, same script, different venv. CI's check job runs a three-leg matrix
over [light], [light,pdf] and [light,pdf,claude] specifically because core stays torch- and
pypdfium2-free by design (CLAUDE.md § Tooling); this would have gone red on the [light] leg
after merge, which the merge-time worktree-mismatch is what actually caught here.
The rule this earns: a green ./check.sh run proves the worktree's own dependency set is
green, not the matrix — pytest silently skips whatever the installed extras cannot exercise
rather than failing loudly, so a test that forgot its @pytest.mark.skipif(not
pdf_extraction_runnable(), ...) marker doesn't skip or fail locally when the extra happens to be
present; it just runs, passes, and says nothing about the leg where it can't. Before landing any
test that touches a PDF fixture, the claude extractor, or a real embedding backend
(sentence-transformers/fastembed), check which pdf_runnable()/pdf_extraction_runnable()/
paid_runnable() predicate (tests/conftest.py) it needs and mark it — and separately, run at
least the affected file with the relevant extra uninstalled (uv sync --extra light --frozen,
run, then uv sync --extra light --extra pdf --extra claude --frozen to restore), because that is
the only way ./check.sh passing locally actually predicts the [light] CI leg rather than just
restating the worktree it ran in.
G3 — the node model and the edge set (20260804 16:30)¶
HIGH — a hierarchy derivation that was quadratic in a document's chunk count. parent-child
was derived by testing child.heading_path.startswith(parent.heading_path + " > ") over every
chunk pair within a document. Measured on one document: 2 000 chunks 0.23 s, 4 000 chunks 0.85 s,
8 000 chunks 3.32 s — so a single 32 000-chunk document (one long PDF) would have spent ~50 s
deriving, on a path pnk sync runs from three git hooks. Replaced by grouping chunks by heading
path and having each path look up its own ancestors (" > ".join(segments[:d])), which is the same
relation and is linear in chunks: 32 000 chunks now 0.59 s. The corpus that would have exposed
this is the one that cannot: the RFC realism corpus has an empty heading_path on every chunk, so
its 106 806 chunks derived zero hierarchy edges and cost nothing. A performance defect invisible on
the only corpus at scale.
HIGH — a mutant that nothing caught, in a filter that looked redundant. authored_pairs
filters links on src_kb_id = ? AND dst_kb_id = ? and then joins both ends to doc nodes.
Changing the AND to an OR failed no test, because the join already drops a foreign document
ULID — a foreign document has no local doc node. That reasoning is wrong in exactly one case, and
it is a case that happens: fork a KB — copy the directory, mint a new [kb] id, and every
document keeps its permanent ULID. A reverse scan of the fork then writes
(fork_kb, D, local_kb, E) where D is also one of our documents, and the OR reads it as "our
D cites E" — an edge nobody authored here. The lesson generalises: a filter that a second filter
appears to make redundant is only redundant under an assumption, and the assumption is the thing to
test. Pinned by test_a_forked_kb_sharing_a_document_ulid_does_not_forge_a_local_authored_edge,
which builds two real KBs rather than inserting the row.
MEDIUM — the deriver was cross-checked against the instrument the go decision was measured on,
and this was worth more than any single test. tools/reachable_ceiling_probe.py derives the same
relations in memory, written independently. Comparing the two censuses on tests/demo-kb
(test_the_stored_edge_set_agrees_with_the_probe_the_decision_was_taken_on) caught two mutants no
targeted test did, and the RFC corpus reproduced the go decision's drop table exactly — sibling
106 506, shared-tag 643, co-located 262. A second implementation of the same spec is a cheaper
oracle than a third round of hand-written assertions, and it answers a question no assertion can:
did G3 build the graph G2 measured, or a different plausible one.
MEDIUM — the plan's orientation rule, read literally, makes every hub unreachable. G3's spec
says the provider queries "src = ? OR dst = ? for those kinds and src = ? for hub kinds". A hub
spoke is stored hub-first, so src = ? answers "who is in me" — and a member asking "what am I in"
needs dst = ?. Read literally, no member could ever enter a hub, and co-located/shared-tag
are the two kinds the go decision measured carrying all nine liftable questions. The sentence's real
content is the symmetric half: a src-only read of a symmetric kind silently drops half of every
relation. Built as three explicit functions — peers(), members(), hubs() — so the two halves
of a hub kind cannot be confused for one query, and pinned by
test_a_hub_is_entered_from_a_member_and_expanded_from_the_hub.
HIGH — a kind selection that could be silently dropped, in the function that preaches against
it. peers()/neighbours() took local_kb: str | None = None and skipped authored when it was
absent — the same "confident, wrong, smaller answer" its own docstring warns about for a src-only
read, from the other side. G5's gate runs with and without authored edges; a caller who forgot
the keyword would have got the "without" arm believing it ran the "with" one, and lost the
highest-trust edge class with nothing printed. Now a TraversalError naming
select_kinds(drop=["authored"]) as the way to mean it. select_kinds() refusing an unknown name
was designed against exactly this and did not cover it — the refusal guarded the name and left
the ingredient.
MEDIUM — every corpus that certifies this increment has two of the six kinds at zero.
parent-child has derived zero edges in every measurement ever taken here — one-level headings in
both committed corpora, an empty heading_path throughout the RFC corpus — and no committed sidecar
carries a tags: key, so shared-tag is exercised only by synthetic fixtures. The cross-check
against the probe therefore passed with _hierarchy_edges deleted, because its non-vacuity guard
was a total over all kinds. Fixed by asserting each compared kind is non-zero and naming the two
this corpus cannot exercise, so a kind that silently stops deriving fails rather than reads as
agreement. A "total > 0" guard on a per-item comparison is not a non-vacuity guard — it is one
item's evidence spread over all of them.
MEDIUM — parent-child is materialised pairwise, and its row count is the product of two
sections. APPROACH §3 keeps hierarchy direct — "the one relation that stays direct" — so an
ancestor heading of a chunks and a descendant of d chunks is a·d rows. Measured at
max_tokens=400 on plausible document shapes: 5.8×, 16.3× and 53.5× the chunk count. Extrapolated
to 300 documents of the worst shape that is ~10 M rows. Not changed here — hubbing it contradicts
APPROACH §3 and restricting it to the immediate parent narrows the relation the go decision's probe
measured — but pinned by a test that asserts the arity, and reported to the planner as a spec
question rather than absorbed as an implementation choice.
LOW — a duplicated tag inflated a hub's member count where nothing could see it. derive
collects edges into a set, so tags: [t, t] produced one spoke — but _tag_buckets appended the
document twice, and the < 2 minting rule counts the bucket. A single document repeating one tag
therefore minted a hub with one spoke and a divisor of 2. The test named
test_a_duplicate_tag_in_one_sidecar_is_one_spoke passed with the deduplication removed, because
the set downstream hid the mechanism it named. Deduplicated where the length is decided, and pinned
by the single-document case that has no set to hide behind.
LOW — a hub with one member is derived state that connects nothing. A directory holding one
document, a tag on one document, a heading with one chunk: expanding it returns only the node that
reached it. The spec only says degree-zero hubs are reaped, which full re-derivation gives for free.
Degree-one hubs are minted at zero benefit — a node, a spoke, and an entry in G6's hub report — so
they are skipped, which also makes the census directly comparable to the probe's (_spoke_count
counts buckets of two or more). Reachability is unchanged for the channel as APPROACH §4A defines
it today; §4B's all-chunk seeding is itself flagged for re-evaluation on the sqlite-vec tier, so
the claim is scoped rather than unconditional. The alternative reading is recorded here because it
was a choice, not a deduction.
G5 — the parent-child ceiling, measured before the gate ran (20260804 21:05)¶
The measurement the arity decision required, and the corpus that could supply it was neither of
the two anyone would have reached for. plans/20260804_1844-decision-parent-child-arity.md
keeps parent-child transitive and asks for a ceiling "against a corpus whose chunker actually
populates heading_path" — because the projection of 5.8×–53.5× the chunk count had never been
run against one. Both obvious candidates fail for different reasons, and only the second was
known:
| corpus | documents | chunks | carry a heading_path |
heading depth | parent-child |
|---|---|---|---|---|---|
| RFC realism | 300 | 106 806 | 0 | — | 0 — structural chunking degraded silently |
tests/demo-kb |
30 | 60 | 60 | always 1 | 0 — every document is flat |
this repo's docs/ + plans/ |
43 | 2 671 | 2 671 | median 2, max 4 | 13 232 |
tests/demo-kb populates heading_path on every chunk and still derives zero hierarchy edges,
because a depth-1 path has no ancestor. A corpus can satisfy "the chunker works" and still
exercise nothing, which is a second way to get a zero that looks like a measurement — and it is
the shape the go decision's own bound warns about, one layer in.
The numbers¶
Real Markdown, written by hand, with real nesting — 43 of this repository's own documents, indexed
into a scratch KB (never committed; docs/ and plans/ are the corpus):
- 4.95
parent-childrows per chunk — 13 232 over 2 671 chunks. Againstsibling's 2 628 andin-section's 2 509, hierarchy is 71% of every stored edge, on a corpus wheresiblingis one row per adjacent chunk. It lands below the 5.8× floor the decision projected, so the projection was pessimistic rather than optimistic. - Derivation costs nothing. The whole edge set derives in 0.158 s; the hierarchy alone is 0.004 s for those 13 232 rows. G3's ancestor-lookup form is linear in chunks and quadratic only in a document's distinct heading paths (median 11 here, max 76), exactly as it claims. The cost is row count, never wall clock, which is what the decision predicted and is worth stating because it changes which mitigation would ever be needed.
- Index growth is 12.9% — 11.17 MB with the hierarchy against 9.89 MB without, both
VACUUMed. On a corpus of this shape the absolute number is 1.2 MB, and it scales with rows, not bytes of text: an index dominated by 384-dimensional embeddings (the RFC corpus is 265 MB for 106 806 chunks) would grow proportionally far less.
The ceiling is not alarming, and the standing risk is unchanged¶
Extrapolating 4.95 rows/chunk to the RFC corpus's 106 806 chunks gives ~529 000 hierarchy rows against its 107 802 total today — a five-fold graph, derived in well under a second. That is a number, not a problem, and it does not license switching to the immediate-parent variant: the decision is explicit that the variant is the arm to measure if the ceiling proves alarming, never the default to switch to first.
What is still unmeasured is the tail. 4.95 is a mean over documents whose median depth is 2.
The decision's standing risk — "a corpus with deep heading nesting and large sections could make
parent-child the dominant kind" — is about a shape none of these three corpora has: deep
nesting and many chunks per section, where the row count is the product. This corpus's worst
document carries 76 distinct heading paths and its arity stays modest because its sections are
short. Nothing here refutes that risk; it bounds the ordinary case and leaves the tail where the
decision left it.
The tail, measured (20260804 22:39) — and it is alarming¶
The paragraph above left the standing risk as prose. It is now a number. A purpose-built worst-shape corpus — six documents, heading depth 4, every heading path carrying ~26 chunks, which is the a·d product the risk names — was generated, synced with the same real backend, and measured the same way:
this repo's docs/ + plans/ |
worst-shape corpus | |
|---|---|---|
| chunks | 2 671 | 2 483 |
| heading depth, median / max | 2 / 4 | 3 / 4 |
| chunks per heading path, median | short sections | 26 |
parent-child rows |
13 232 | 132 630 |
| rows per chunk | 4.95 | 53.42 |
| share of every stored edge | 71% | 94.7% |
| index growth | +12.9% (1.2 MB) | +113.4% (13.3 MB) |
| derivation | 0.004 s (hierarchy) | 0.84 s (140 079 edges, every kind) |
53.42 lands at the very top of the decision's projected 5.8×–53.5× band, so the projection was accurate at both ends rather than pessimistic: 4.95 sits below its floor for ordinary prose, 53.4 reaches its ceiling for the shape it warned about. The index more than doubles. Derivation stays cheap — 140 079 edges in 0.84 s — which confirms the decision's own prediction that the cost is row count, never wall clock, and therefore that no mitigation aimed at derivation time would help.
What this corpus is, and is not. Synthetic and deliberately adversarial: generated Markdown with uniform nesting and uniform section length, built to make the product as large as plausible rather than to resemble anyone's notes. It is not evidence that real corpora do this — neither real corpus measured above comes close. It is evidence that the shape is reachable without anything exotic, because depth 4 with long sections is an ordinary specification or manual.
No variant is switched to here, on purpose.
plans/20260804_1844-decision-parent-child-arity.md
is explicit that if the ceiling proves alarming the immediate-parent form is the arm to measure,
never the default to change first — and --drop parent-child is already a reported leg of G5's
matrix. This is the input to that decision; the decision is the planner's.
G5 — the expansion channel (20260804 21:35)¶
HIGH — a ranking rule that would have made depth 2 unreachable, and it read as the careful
choice. The channel's output is ordered and then cut at candidates_per_source. The first
implementation ranked (distance, -cosine, …), on the reasoning that a two-hop chunk should not
outrank a one-hop one on similarity alone — which sounds like the conservative reading of APPROACH
§4A's "score expanded chunks by edge weight and link distance". It is not conservative, it is
silently disabling half the feature: with distance as the primary key, every one-hop chunk
precedes every two-hop one, so on any corpus where one hop already fills the cut, depth 2
contributes nothing to the output at all. The channel would have been depth-1 wearing a depth-2
budget — and the reachability ceiling that unblocked this increment was measured at two logical
hops, so the gate would have been measuring something the precondition never covered. Now
(-cosine, distance, …): cosine ranks, distance breaks the ties cosine cannot, and
test_a_two_hop_chunk_outranks_a_one_hop_one_when_the_query_says_so fails if the order comes back.
HIGH — every tiebreak resolved to a surrogate id, and it looked stable because of another
module's ORDER BY. Fan-out, frontier order and the final ranking all broke ties on
nodes.id. Those ids are deterministic today — but only because edges.derive enumerates
documents ORDER BY path — so the channel's answers depended on an invariant of a different
module, unstated in both. That is G1's defect in a new place: _hydrate's unordered
WHERE c.id IN (…) was stable in practice too, until a rebuild moved one golden-set question.
Every tiebreak here now resolves to (documents.path, chunks.ordinal), the total order G1 gave the
rest of the pipeline. The lesson is not "avoid surrogate ids" — it is that "this is
deterministic" is a claim about the code that computes it, and if that code is somewhere else, the
determinism is an assumption rather than a property.
HIGH — a fixture that was not the shape its own docstring described, in the tests written to
catch exactly that. The membership-exclusion fixture built one document of six flat sections, so
that ordinal 0 and ordinal 4 would be reachable only through their document's membership edge.
Its body was f"## Section {i}\n\nword{i} " * 30 — which repeats the heading thirty times, and
produced 180 chunks nested under Section 0 > Section 5. A parent-child hierarchy, inside a
fixture whose entire purpose was to have none, in a file whose docstring names "an assertion
satisfied by something other than the property it names" as the failure it is written against. It
was invisible until an unrelated ordering change made the walk reach one chunk more. Two things
followed: dropping the # Title was not enough either — the chunker then reads the first ##
as the root and derives Section 0 > Section n, the same defect one heading level down — and a
fixture that a test's meaning depends on now asserts its own shape (_assert_flat_sections: six
chunks, every heading path depth 1, zero parent-child edges). A fixture is an assertion.
MEDIUM — a mutant that survived eighteen others, in the half of a rule the other half hides.
The membership exclusion is two filters: a document never passes through to itself, and a root's
own document never contributes member chunks at any depth. Deleting the second left the whole
suite green, because at hop 1 the source is the root's document, so the first already covers
it — and no fixture ever reached a root document from somewhere else. The shape that does is
A —authored→ B —authored→ {A, C} at adjacent_k=1: at hop 2 the source is B, the first filter
does not apply, and without the second, A takes the only slot to re-contribute chunks the query
already had. Two rules that agree on every case you have built are one rule until you build the
case that separates them.
LOW — a throwaway mutation harness produced two runs of contradictory results. Mutating a
source file, running pytest and restoring in a loop reported extra failures in tests unrelated to
the mutated file, twice, while the machine was also running a two-hour re-index and an eval matrix.
Clearing __pycache__ between mutants and running each mutant on a quiet machine gave seven clean,
reproducible results. The mechanism was not pinned down — a stale bytecode cache after a
same-second restore and a subprocess that failed to spawn under load are both consistent with what
was seen. Recorded because the reaction is the reusable part: a mutation result that implicates a
file the mutant did not touch is a result about the harness, and re-running it on a quiet machine
costs a minute.
G5 — a root spends a fan-out slot it is then discarded from (20260804 22:30)¶
HIGH — the fourth review round, on a rule the third round had already written down. Round three
added a filter to _offer_chunks dropping candidates already found this hop or already emitted by
an earlier one, with the reasoning stated in the comment: "a slot spent on it adds nothing, and
dropping it before the cut is the same rule the membership exclusion applies one level up". The
rule is right. It was applied to two of the three categories it covers.
A root reaching found is discarded twice over. _accept skips it before emitting, and run
seeds self._expanded with the roots so it never joins the frontier either — so a root contributes
neither a row nor a hop. Yet it had already taken one of the adjacent_k slots on the way, and the
neighbours of a fused top-k chunk are very often other fused top-k chunks: sibling connects
adjacent ordinals, and adjacent ordinals of a chunk the query matched are exactly what the vector
stage also ranked highly.
Measured on graded_neighbour at the shipped default adjacent_k = 8: 4 candidates returned as
built, 10 with roots dropped before the cut — and all six of the new ones are chunks fusion had
not found, which is the only thing the channel exists to contribute.
Why it survived three rounds. Every existing assertion about roots was set-level — "neither
root may be emitted", "expansion still runs" — and a set-level assertion cannot tell a slot
spent from a slot saved. Both are satisfied by the defective walk. The test that catches it counts
instead: adjacent_k = 1, two candidates, one of them a root, and the single slot must reach the
non-root. With the filter removed the walk returns [], which is the failure mode named in the
assertion's own message.
What the fix is worth, and where that cannot be seen¶
tests/demo-kb moved zero questions across all three gate legs — off, expand,
expand-no-authored — with aggregates identical to four decimal places. That is not evidence the
fix is inert: demo-kb's documents are about two chunks each, so adjacent_k = 8 never saturates
and a root never displaces anything. A corpus can be incapable of exercising a change, and
reporting "no movement" from one is reporting the corpus, not the code. The RFC realism corpus —
300 documents, 106 806 chunks — is where the fan-out cut actually binds, and it is the corpus the
gate runs on.
The backstop that now survives mutation, on purpose¶
_accept's if node in self._roots: continue is unreachable once _offer_chunks filters. It
stays: "a root is never emitted" is an invariant of the emit point, and a later caller adding a
second way into found should not be able to break it from a distance. The honest consequence is
recorded in the docstring and in the mutation table — deleting that line fails no test, while
deleting the _offer_chunks filter fails exactly one. A backstop whose mutant survives is fine;
a backstop silently counted as the enforcement is not, which is the same
assertion-satisfied-by-something-else failure in its bookkeeping form.
G5 — the gate, run: expand ships off (20260804 22:52)¶
HIGH — the increment's deliverable is a negative measurement, and it is a clean one. On the RFC realism corpus, at G5's own HEAD, against a schema-3 index rebuilt for this run:
| leg | multi-hop | improved | regressed | p |
|---|---|---|---|---|
off |
7/20 | — | — | — |
expand, drop authored |
4/20 | 0 | 3 | 1.0000 |
expand, all kinds |
4/20 | 0 | 3 | 1.0000 |
licensing p = 1.0000 (max, the more conservative of the two). expand defaults off, and
tools/graph_gate.py exits 1. Clause 1 fails in both runs; clause 2 fails as well —
by_kind[multi-hop] 0.350 → 0.200 against a 0.02 tolerance — and clause 4 fails on recall_at_k
for the same movement.
Lost: content-disposition-http-takeover (hit at rank 1 → not found at all),
http-rate-limit-status-code-provenance (rank 4 → miss), sip-invite-2xx-retransmission-defect
(rank 2 → miss). Nothing was lifted.
reachable ≠ retrievable — the finding, and the plan predicted its shape¶
The reachability probe found 9 of the failing multi-hop questions reachable within two logical hops without authored edges, which is what unblocked the graph release. The retrieval instrument lifts none of them, and the channel's extra candidates displace three answers two-list fusion already had. The plan drew this distinction itself — "a ceiling gauge cannot rank, and an argument cannot measure" — and G5's whole existence is the reason it could be checked rather than assumed. A reachability precondition is necessary and nowhere near sufficient, and the gap between the two is not a small correction: it is 9 questions against 0.
The go decision also anticipated the outcome's meaning: "If expand did not pass, the finding is
'graph structure does not help this corpus', and the response is a corpus or a different channel
design, never an escalation to a more expensive one." Nothing here licenses PPR.
Two things that make the number narrower than it looks, both stated rather than worked around¶
Clause 3 passes vacuously, and clause 4 nearly so. The RFC corpus has [retrieval.confidence]
commented out, so every question scores confidence: unknown; neither the confidence-lost nor the
newly-found-at-low term can be non-zero there. The decomposition clause 3 exists for is exercised
only by the synthetic artifacts in tests/test_graph_channel.py — which is precisely why the plan
insisted the gate be driven by synthetic fixtures as well as by the corpus. A gate whose only
fixture is the real corpus can only be tested in whichever direction that corpus happens to point,
and here two of its four clauses would never have fired.
The --drop parent-child arm is inert on the gating corpus. No chunk in the RFC corpus carries
a heading_path, so parent-child and in-section derive zero edges and dropping them changes
nothing by construction. The arm the arity decision added cannot say anything here. Its cost was
measured separately (see the ceiling fragment); its retrieval value remains unmeasured, and the
corpus that could measure it does not exist yet.
The --drop sibling arm answers its question, and the answer is "neither"¶
The go decision added this arm to ask, with the instrument that measures retrieval quality rather
than a reachability ceiling, whether 99.2% of the graph's mass earns its place. On this corpus
sibling is 106 506 of the 107 411 non-transit structural edges, and dropping it produces
exactly the same 4/20, the same three regressions, and the same p = 1.0000 — one question's
rank moves, and it is a miss either way.
So sibling neither helps nor hurts. It is 99.2% of the stored graph and it is inert in both
gauges: the reachability probe already found removing it cost nothing, and the retrieval
instrument now agrees. Two independent measurements, and the harm the channel does comes from
somewhere else entirely — the document-level path, membership transit into co-located (262
edges) and shared-tag (643) hubs, which pull whole documents' chunks into the fusion.
With the caveat that makes it a narrower claim than it looks: every chunk here has an empty
heading_path, so a "sibling" in this corpus is an adjacent arbitrary size-slice, not an adjacent
section. The arm has measured the value of size-slice adjacency, which is what this corpus's broken
structural chunking produced — not the value of sibling as designed. On a corpus whose chunker
works the question is still open.
Five legs land on the same number; the sixth does not, and I had already written that they all did¶
The full matrix, off at 7/20 (recall@k 0.3500, MRR 0.421):
| leg | multi-hop | improved | regressed | p | ms/query |
|---|---|---|---|---|---|
off |
7/20 | — | — | — | 2012 |
expand |
4/20 | 0 | 3 | 1.0000 | 2051 |
expand-no-authored |
4/20 | 0 | 3 | 1.0000 | 2106 |
expand-no-sibling |
4/20 | 0 | 3 | 1.0000 | 2067 |
expand-no-parent-child |
4/20 | 0 | 3 | 1.0000 | 2028 |
expand-no-link-distance |
4/20 | 0 | 3 | 1.0000 | 2024 |
expand-in-degree |
6/20 | 1 | 2 | 0.8750 | 2237 |
MEDIUM — the process finding, and it is the file's own failure class caught in the act. With
five of the six legs written I recorded that "every leg lands on the same number, which is what
makes the result robust" — a claim about six legs asserted from five, while the sixth was still
running. The sixth then came back different: in-degree salience is the only configuration that
lifts anything (imap-utf8-two-strategies), and the only one that regresses two rather than three.
The claim was wrong within minutes of being written, in a fragment whose subject is an assertion
satisfied by something other than the property it names. A generalisation over N runs written
while N−1 have finished is not a measurement, it is a prediction wearing a measurement's clothes.
The correction is recorded rather than quietly overwritten, because the tempting fix — waiting and
writing the true sentence — would have hidden how easy it was to write the false one.
What the sixth leg does and does not license. expand-in-degree at 1 improved / 2 regressed is
p = 0.8750: nowhere near the gate, and still a net loss of one question against off. It is
reported, never gated — three variables against one threshold is not a decision procedure — and
noticing that it is the best-performing leg after seeing the numbers is exactly the exploratory
fitting the pre-commitment forbids. It is a direction for a future measurement on a corpus that can
carry one, not a result.
Latency, the other exit criterion. off 2012 ms/query against expand 2051 — 1.02×, so the
channel costs about 2% on a 106 806-chunk index and the "slow at query time" risk did not
materialise. In-degree salience is the expensive leg at 2237 ms (1.11×), which is the one place the
matrix's timing separates the configurations at all.
The with/without-authored split is stronger here than on tests/demo-kb¶
All 391 of the corpus's authored links are intra-KB — every to: names the corpus's own KB
ULID — so unlike tests/demo-kb, where only 12 of 16 survive the cross-KB inertness rule, the
with-authored leg has every one in play. They still move only two questions' ranks and flip no
outcome, and both gated runs regress the identical three questions. The anti-circularity guard had
nothing to catch, because there was no win to be circular about.
The pre-commitment held¶
A result short of the table ships the channel off, with counts and p-value recorded, untuned.
Nothing was tuned, no weight moved, no threshold was revisited after seeing the number. The
authored weight's measured at G5 marker is discharged by this run as "measured, and it changed
no outcome" rather than by a fitted value.
G6 — Edge-hub reporting (20260805 06:20)¶
MEDIUM — the enumeration order this check's own sort relied on was implicit. The first cut
enumerated hub node ids with SELECT DISTINCT src FROM edges WHERE kind = ?, no ORDER BY — the
only query touching edges in this codebase without one; every read in graph/edges.py orders
explicitly, and that file's own docstring argues for it. It happened to come back in ascending
src order today (a covering-index scan on edges_src(src, kind)), which is exactly the "mint
order" the first test's fixture needed to differ from degree order to prove the .sort() was doing
real work. That property held by accident of a query plan, not by anything this function asserted.
Fixed with an explicit ORDER BY src. Found by an adversarial review agent, not by any test — no
test could have caught it, since the property under test (the sort) still worked; only reading the
query against the file's own convention surfaced it.
MEDIUM — one of the three hub kinds shipped with zero assertions on its printed text. _hub_label
has three branches — tag, dir, heading — and the landing commit tested tag (the sort-order
fixture) and heading (the human-actionable-label fixture) but never dir. co-located appeared
in a docstring only, explaining why the other fixtures were built to avoid triggering it — which
reads, on a second look, exactly like the tests were routed around covering it rather than covering
it. test_a_directory_hub_is_named_by_its_kb_root_relative_path closes it.
LOW, folded into the fix above — a degree tie had no assertion in either direction. top.sort(key=
lambda item: item[1], reverse=True) is a stable sort, so two hubs at equal degree kept whatever
order they arrived in — which is to say, the same implicit query-plan order the first finding
flags, one layer further in. Given a real tie the printed order was accidental twice over. Fixed by
sorting on (-degree, kind, key) explicitly, and test_a_degree_tie_breaks_deterministically_and_
the_rest_are_counted builds four hubs tied at degree 2 whose mint order is the reverse of their
correct printed order — reverting the tiebreak makes the mutant print d, c, b instead of
a, b, c, which is the sharpest test in this increment: it fails on a reverse=True-only sort
that every other test here would still pass.
LOW, self-inflicted — a vacuous assertion, written by the implementer, in this same increment.
While extending an existing test's block with a new line, an assertion checking for a string that
appears nowhere in src/ — "unchecked until the links release" not in detail — was added twice
(once by the edit that introduced the increment's other tests, once copied into a third test written
later in the same session). It always passed, proved nothing, and is exactly the failure class this
project's own CLAUDE.md names: "an assertion satisfied by something other than the property it
names." Caught by cross-referencing the diff against src/ during this same retrospective pass
(grep -rn "unchecked until the links release" src/ returns nothing), not by any tool — nothing
in check.sh can distinguish a true-but-empty assertion from a load-bearing one. Removed.
Read together: three of these four findings trace to the same root — an implicit order (query
plan, sort stability) standing in for an explicit one, discovered because it happened to agree
with what a fixture needed. G3's own docstrings warn about exactly this shape for a src-only read
of a symmetric edge kind ("a confident, wrong, smaller answer"); this increment reproduced a milder
version of it one level up, in the read that reports G3's own structure back to a human.
The [light] backend error — a fixed test that only looked environment-independent (20260805 07:41)¶
MEDIUM — the existing test_a_missing_extra_names_the_install_command was silently coupled to
which extras happen to be installed in the checkout running it, and this dev checkout already has
fastembed (a transitive dependency of some other extra) even without [light] explicitly
requested. Once BackendMissingError started naming an installed alternative, that test's
monkeypatch.setattr(builtins, "__import__", refuse) — which only blocks sentence_transformers —
left fastembed genuinely importable, so load_backend picked it up as the alternative and the
old assertion ('uv add "pinakes[st]"' in remedy) started failing for the right reason: the new
code path executing, not a bug. A version of this test that had merely added the new assertions
without also forcing "nothing else is installed" would have been true by luck of this machine's
site-packages, not by construction, and would flip on a bare CI leg or a machine with no
fastembed at all. Fixed by monkeypatching importlib.util.find_spec directly in both the
"no alternative" and "alternative present" tests, so each names its precondition instead of
inheriting whatever the environment happens to have — the same discipline docs/RETROSPECTIVES.md
already names for tests that read like they exercise a real-clock or real-install branch but
route around it.
Confirmed by the mutation pass: forcing _installed_alternative's find_spec check to always
report "installed" broke test_a_missing_extra_names_the_install_command specifically (asserting
alternative is None), and reverting _import to drop the detected alternative broke both
alternative-path tests specifically (asserting alternative == FASTEMBED) — each mutation failed
the test that names the property it broke, not an unrelated one.
The heading-coverage check — two findings, one of them about my own test¶
HIGH — the open correction's diagnosis was approximately right and precisely wrong, and the
difference decides the fix. Item 1 read "the heading grammar is Markdown-shaped; RFC section
numbering is not, so nothing matches and the strategy quietly becomes size-based". That describes a
regex being tried and failing. What actually happens is chunk.py:131:
_markdown_blocks is never called for a .txt file, and _plain_blocks sets
heading_path=None unconditionally. Nothing failed to match because nothing was tried. The
consequence for the fix is not cosmetic: tightening or extending a regex would have changed nothing,
and the real change is adding heading detection to a code path that has none. It also bounds the
blast radius in the useful direction — tests/demo-kb is Markdown, so a plain-text grammar cannot
move the golden set, and "changing chunking needs eval justification" becomes a thing you can
prove rather than argue.
A measurement replaced a threshold. Chunking the committed corpora directly — no index, no
embeddings, chunk_document called in a loop — gave demo-kb 60/60 and partner-kb 55/55 at 100%
against the RFC corpus's 0%. Bimodal, so the predicate can be "zero for this source type" and the
check carries no constant anybody had to calibrate. The alternative, a fitted percentage floor,
would have needed a corpus to fit against and would have fired on ordinary documents whose opening
paragraph precedes their first heading.
MEDIUM — mutation testing refuted one of my own tests within a minute of writing it. I wrote
test_heading_coverage_counts_only_active_documents, asserting the state = 'active' filter. M1
deleted the filter and the test stayed green. The reason is that SoftDelete drops a document's
chunks as well as flipping its state, so a chunk-counting query has nothing to over-count either
way — unlike _links, which counts documents and genuinely needs the filter it records having
shipped without (2 of 1 documents linked (200%)).
The test was kept and renamed to what it proves (test_a_removed_documents_chunks_stop_being_counted),
with the refutation written into its docstring so nobody re-derives the wrong claim from the old
name. The filter stays as defensive consistency with _links, marked as unreachable by this
fixture rather than presented as guarded.
This is the file's own recurring failure class caught in the act: an assertion satisfied by something other than the property it names. Three of the four mutants died as intended (the zero-per-type predicate, the two-cause remedy split, the WARN status); the one that survived was the one whose name made the strongest claim. Green proved the test ran; only the mutant proved what it could detect — and for one of five, the answer was "not the thing in its name".
Item 5 — doctor never prints the operator's home directory (20260805 08:06)¶
LOW — two residual home-directory leaks exist, both correctly out of this increment's scope.
An adversarial review confirmed the sweep of src/pinakes/doctor.py is exhaustive against every
PinakesError subclass doctor.py forwards, and found no defect in the fix itself (three
independent mutations — a no-op _de_homed, a swapped _local tuple order, and a reverted
_sidecars call site — each broke exactly the test that should catch it, and nothing else). Two
things remain that print an absolute path containing the operator's home directory, both outside
the item's stated boundary ("paths outside the KB stay as they are"):
-
_linked_kbs'sexcept OSError as exc: absent.append(f"{linked.name} ({exc.strerror or exc})")— theor excfallback stringifies a bareOSError, whose default__str__includes.filenamewhen set. The path involved is a linked KB's resolved location, notmanifest.root— legitimately a different KB elsewhere on disk, not this one — so it falls outside "paths outside the KB stay as they are" by the same reasoning that already keepshf_cache_dir()untouched. Rare: only fires whenwhy_not_a_kb'sOSError.strerroris falsy, an edge case its own docstring already calls out as rare (an unreadable parent directory). -
budget.prices.PricesMissingError(reason=str(exc))— ships a package-relative path (prices.toml's location inside the installed wheel or an editable checkout), notmanifest.root-derived, so_de_homedcorrectly leaves it alone. In an editable/source install, that path is often literally under the developer's home directory too (e.g.~/Code/pinakes/src/pinakes/budget/prices.toml) — the same shape of leak ashf_cache_dir(), for the same reason (a real filesystem location worth showing, not KB-derived).
Worth keeping: if "no home directory in pnk doctor output, ever" becomes the actual goal
rather than "no home directory via the KB's own location", both of these are where to look next
— they were not fixed here because the item's own text draws the boundary at manifest.root, and
extending it is a separate decision.
measure_sync_cpu.py measured the launcher, not the work (20260805 17:37)¶
HIGH — the tool answered its one question with a number that was precisely, confidently wrong,
and every test passed. sample_percent ran ps -o %cpu= -p <pid> against the pid it launched.
The invocation the tool was written for — and prints in its own --help and changelog fragment —
is -- uv run pnk sync ..., which makes uv the measured process and pnk its child. uv waits
and burns nothing.
Measured on this repo before the fix, one identical one-core busy loop:
| launched as | reported |
|---|---|
| the busy loop directly | 1.0 cores |
the same loop behind uv run |
0.0 cores |
The failure mode is the expensive one: 0.0 cores for a sync saturating a core does not read as a
broken tool. It reads as the finding item 6 went looking for — "the loop is not CPU-bound, so
multiprocessing buys nothing" — and it would have been quoted into a design decision.
Why the tests could not catch it. All seven ran sys.executable -c <busy loop>: a direct child
that does the work itself. The suite covered the sampler, the units, exit-code propagation, empty
and non-positive arguments, and the trailing-interval bug — everything except the one process
shape the tool exists to be pointed at. Coverage of the code was complete; coverage of the
invocation was zero. This is the recurring class named in docs/RETROSPECTIVES.md — an
assertion satisfied by something other than the property it names — reached from a new direction:
not a weak assertion, but a fixture that was never the real subject.
Fixed by summing %cpu across the root pid and every descendant from a single ps -A snapshot
(one snapshot, so a child starting or exiting mid-walk cannot be double-counted or missed), plus a
test whose command is a launcher that burns nothing itself.
The new test's upper bound is load-bearing, and mutation proved it. With the tree walk
neutered, the launcher still reported 0.1 cores of its own interpreter startup — so
assert peak > 0 would have passed the mutant. Asserting > 0.5 fails it. A threshold above
"anything a waiting process can produce" and below one core is what makes the assertion name the
property; "non-zero" would not have.
Also corrected: %cpu is a decaying average over up to a minute (man ps), not the
instantaneous reading the docstring claimed. Right for the steady-state multi-minute loop this
measures, but it means peak is the peak of a smoothed series — a low peak is much weaker
evidence of an idle machine than a high peak is of a busy one, and the docstring now says so.
Generalisable: when a tool's purpose is to be run one particular way, one test must run it that way. A synthetic fixture chosen for speed silently replaced the subject here, and no amount of assertion strength on the wrong subject would have helped.
Fixture copies carried the developer's own .pinakes/ into the test workspace (20260805 17:49)¶
MEDIUM — three tests failed on main immediately after two clean merges, on a machine where
nothing was wrong with the code, and the failure impersonated the exact defect this repo watches
for. CLAUDE.md says a clean auto-merge is not a correct merge; both branches had been green
individually; main then failed. Every signal pointed at a bad merge. The cause was a
tests/demo-kb/.pinakes/index.db dated 1 Aug — a leftover from a manual pnk sync predating the
graph release's schema_version 3 bump — which five shutil.copytree calls copied into the test
workspace along with the documents.
The tests were coupled to whether the developer had ever run the tool by hand. CI clones fresh,
so .pinakes/ never exists there and the suite is permanently green; a dev box that has exercised
the fixture once fails until the directory is removed. .pinakes/ is gitignored, so nothing in the
diff, the merge or the branch could have shown it.
The idiom was already in the codebase and applied to four of nine call sites.
ignore=shutil.ignore_patterns(".pinakes") was used in test_eval.py, test_search_reproducibility.py
and twice in test_partner_kb.py. The other five simply omitted it. A guard applied at some call
sites is not a guard — it is a coin flip weighted by which test the copy happens to be in, and no
gate could notice the omission because the states it protects against are gitignored and absent in
CI.
Verified in both directions, planting a poisoned fixture rather than reasoning about it. With a
deliberately corrupt tests/*/.pinakes/index.db in place, all 105 tests across the four affected
files pass. Removing the guard from one call site fails exactly that site's test
(test_edges.py::test_the_stored_edge_set_agrees_with_the_probe_the_decision_was_taken_on,
StoreError), which is what makes the guard's presence the property under test rather than an
incidental line.
Generalisable: when a test copies a directory the tool also writes into, the copy must name what it excludes. And a loud environment-coupled failure is not a cheap one — this one cost real time precisely because it arrived wearing the costume of a merge defect, right after a merge.
An exact assertion between two different roundings — green locally, red on one CI leg (20260805 17:55)¶
MEDIUM — a coin-flip assertion that passed for the wrong reason, and whose failure was earned by
an unrelated correct change. test_reports_cores_the_way_macos_percent_converts_to_them read the
two numbers off the peak: line and asserted cores == pytest.approx(percent / 100.0) — at
approx's default relative tolerance, i.e. effectively exact. But report() renders percent at
0 dp and cores at 1 dp: they are two roundings of one value, not one value printed twice. Exact
agreement is a coincidence of the input, never a property of the code.
It held only while a single-process sample sat at exactly 100.0. The tree-sum fix
(measured the launcher, not the work)
made a one-core loop
read 101.4 — parent plus child — and "101"/100 != 1.0 turned CI red.
The tell is which legs failed. check (light pdf) failed; check (light) and
check (light pdf claude) passed on the same commit, same code, same test. Three legs disagreeing
about one assertion is not a flaky environment — it is an assertion whose truth depends on a
measured value nobody controls.
And it failed on merged main, not on the branch. The branch's own ./check.sh was green,
twice, because this machine's readings rounded agreeably. A local gate cannot rule out an assertion
that is only usually true.
Fixed with abs=0.06 and the arithmetic written down: 0.5 of display error in the percent is
0.005 of a core, plus 0.05 from the cores field's own rounding, so 0.055 is the largest honest
disagreement between the two fields.
The loosened bound still bites — verified, not assumed. Dropping the /100 from
CpuTrace.cores fails exactly this test (1.0 against 101). A tolerance that admits formatting
disagreement while still rejecting arithmetic disagreement is the assertion the test always meant
to make.
Generalisable: comparing two rendered numbers is comparing two roundings. Either compare the values before formatting, or state a tolerance derived from the display precisions — never an exact comparison "because they should be equal". This is the repo's recurring class once more: the assertion named "the conversion is right" was actually testing "the two roundings happen to agree".
Mutation testing found a guard that could not fire (20260805 19:07)¶
LOW as a defect, worth keeping as a method result. The numbered-heading predicate's
document-level check was written straight from its own spec, which says the numbers must form a
valid outline walk and that no number repeats. Both were implemented: a seen set alongside the
step-validity rule.
Mutating the clauses one at a time, eight of nine mutants were killed by the test named for that clause. The ninth — deleting the no-repeats check entirely — broke nothing.
The first instinct is "write the missing test". The right answer was that no such test can exist: every step the walk permits raises the number tuple lexicographically — a sibling raises its last component, a first child appends to it, an ancestor's next sibling raises a shallower one — so an accepted sequence is strictly increasing and a repeat is unreachable. The check was dead code wearing a guard's clothes.
It was removed rather than kept as defence in depth, and the reasoning put in the docstring. A guard that cannot fire still reads as one, and the next person to touch the step rule would weaken it believing this had their back. The spec keeps the no-repeats sentence — as a statement of intent it is correct, and the implementation note now says why it needs no code.
Generalisable: a surviving mutant asks a question before it asks for a test — is this reachable at all? Adding a test for unreachable code is how dead code acquires the appearance of coverage. This is the inverse of the failure this project keeps meeting: usually an assertion is satisfied by something other than the property it names; here a guard was satisfied by something other than itself.
Running it found what reading it could not (20260805 19:15)¶
MEDIUM, and the reason it is recorded beside the note above: the two findings came from opposite methods on the same increment. The dead guard was found by mutating code. This one was found only by building a KB and using the feature the way a user would.
[chunking] headings = "numbered" added to an already-synced KB, then a plain pnk sync:
| result | |
|---|---|
plain pnk sync |
1 unchanged · every heading_path still empty |
pnk sync --rebuild |
1 indexed · the three heading paths, and the first parent-child edge |
An incremental sync re-chunks a document only when the document changed. A manifest-only edit
changes no content hash, so the feature silently does nothing — and pnk doctor then reports
exactly the condition the user just tried to fix.
Every test passed throughout, and no test could have caught it. The unit tests call
chunk_document directly with the parameter set; the mechanism that drops it lives in sync.py's
change detection, one layer up. The defect is not in either component — it is in the seam, and a
seam is only visible from outside both.
It is also pre-existing: max_tokens and overlap have always behaved this way. Three releases
did not surface it because no [chunking] key had ever been worth flipping on a KB already indexed.
Adding the first one that is, is what made an old defect newly reachable — and "my change did not
cause this" is not the same as "my change did not make it matter".
Generalisable: for any change that adds a knob, turn the knob on a real KB before landing. Unit tests verify a component honours a parameter; only using it verifies the parameter arrives.
A warning that cleared itself without the fix being applied (20260805 20:20)¶
HIGH — the first draft turned a silent defect into a lying one, and every test passed. The
chunking-drift warning was correct. What was wrong sat 300 lines away: sync.py wrote the current
chunking identity into meta at the end of every sync, including the incremental one that had
just refused to re-chunk anything.
So the sequence was:
| step | what the user saw | what was true |
|---|---|---|
edit manifest, pnk sync |
1 unchanged + the new warning |
index still built the old way |
pnk sync again |
1 unchanged, no warning |
index still built the old way |
pnk doctor |
OK chunking coherence |
index still built the old way |
A warning that clears itself without the fix being applied is worse than no warning: it converts "the tool said nothing" into "the tool said it was fine". The index actively claimed a coherence it did not have.
Found by running it a second time, not by testing it. The unit tests asserted the warning
appears — it did. Nothing asserted it persists, because persistence only fails on the second
invocation, and a test that runs an operation once cannot see a defect that needs it twice. The
fix's own test is now ..._persists_until_the_rebuild_actually_happens, which syncs three times.
The correct rule turned out to be narrow: record the identity only when every chunk in the index was produced by this run — a rebuild, or a first build into an empty index. An incremental sync re-chunks only what changed, so after one the index is a mixture, and there is no single honest value to record. Leaving the old value is right: it keeps warning, which is exactly what a mixed index deserves.
Generalisable, and it is the second time today: a state-writing side effect belongs with the
work it describes, not with the command that happened to run. set_meta is called once per sync and
was treated as "the place identity goes" — but identity is a claim about the chunks, and only one
of those code paths actually produced them all.
The corpus rejected two of my fixes, and that is the result (20260805 21:00)¶
The measurement §5.3 demanded was run, on real RFCs, in doubling rounds: 66 → 131 → 259 → … The rule was state the predicate first, measure second, and treat a poor match as a finding rather than a licence to loosen a clause. It held, twice, in the direction that costs something.
Round 1 (66 documents) found a false positive. RFC 769 lists facsimile command codes as
56 - SET-UP, 57 - DATA, 58 - END. Consecutive integers, short labels, column 0, blank lines
around — every clause passed, and the predicate produced three headings that are not headings.
The first fix was wrong, and the corpus said so. "A heading's title must not begin with
punctuation" kills it. It also killed three genuine documents: 5.1. /get, 2.7.3. "iprev", and
RFC 2010's entire outline, which numbers real sections 1 - Rationale and Scope — the identical
shape as the false positive. Form cannot separate them. What separates them is where they
start: an outline begins at section 1, a list of opcodes begins at 56. That rule changes exactly
one verdict across the corpus, and it is the wrong one. It shipped as clause 9.
Round 2 (131 documents) found a second false positive, and rejected a second fix. RFC 778
numbers a procedure — 1. Connect to COMSAT-GAT host…, 2. Send the command… — starting at 1,
so clause 9 does not catch it. The obvious discriminator is that a heading stands alone: require a
blank line after the candidate. Measured, it removed the false positive and four genuine
documents with it, because real headings wrap:
7.4. The Network Information Center and
Requests for Comments Distribution Contact
Rejected, and RFC 778 is recorded as an accepted bound instead. Labelling the steps of a
numbered procedure as sections is defensible; 56 - SET-UP was not. Not every false positive is
worth a rule, and a rule that costs real structure to buy a marginal one is a bad trade even when
its net count looks fine.
What the corpus did buy: clause 10. A recurring convention numbers top-level sections 1.0,
2.0, mixing the two freely — RFC 2006 runs 6 then 7.0, RFC 2024 runs 1.1 then 2.0. Read
literally those are depth changes no walk can accept, and the document is rejected whole.
Normalising a trailing zero fixes it, and is safe precisely because a real subsection never carries
.0.
Clause 10's own test then caught a bug the walk could not see. The walk normalised 2.0 to 2
and accepted the document — while the heading stack still used the raw depth, nesting 2.0 under
1.0. The document passed and the hierarchy was wrong. Two places consume a number's depth and only
one had been taught the convention.
Generalisable, and the reason the doubling protocol matters: clause 9 was derived from 66 documents and looked complete; 131 documents produced a false positive it could not catch. A fix validated at one corpus size has been validated at one corpus size. Every round both re-checks the previous fixes and gets a vote on the next.
The sync-CPU number, and the instrument proving itself in the field (20260805 21:56)¶
The measurement item 3 demanded since 20260804 was finally run, and it reverses the item's own
framing. 55 modern-era RFCs — 16 557 chunks — rebuilt under fastembed:
| wall-clock | 1 497.7 s (~25 min), 1 451 samples |
| peak | 500% — 5.0 of 10 cores |
| mean | 480% — 4.8 of 10 cores |
The loop is serial and the backend under it is not. sync.py embeds one document at a time, so
the loop is single-threaded — but ONNX Runtime is already using half the machine beneath it. Item
3's own fork therefore resolves against the change it was written to consider:
The backend already saturates the machine → the loop is fine, and the win is a bigger batch (embedding several documents' chunks in one
embed()call), not processes.
It also lands exactly on the trap that item named: "do not stack a process pool on top of a
threaded backend". At 4.8 cores already consumed, two workers would take ~9.6 of 10 and
anything beyond oversubscribes. The intuitive fix — a pool sized os.cpu_count() - 1 — would have
been nine workers on a machine with room for two.
The instrument proved itself in the field, and it is the reason to trust the number. Sampled live from the same process tree:
| process | %cpu |
|---|---|
measure_sync_cpu.py |
0.7 |
uv run |
0.0 |
the actual pnk sync python |
491.9 |
The pre-fix tool watched the launched pid — uv run — and would have reported 0.0 cores for a
workload using five. That is not a number anyone would have questioned: it is the finding item 3
went looking for, and it would have licensed exactly the process pool this measurement rules out.
A tool whose failure mode is "confirms your hypothesis" is the most expensive kind.
Bounded, and the bound is stated rather than buried: fastembed only. sentence-transformers
needs the 2 GB [st] extra and is unmeasured, so nothing here licenses a claim about torch.
A second number fell out of the same run, unasked: 15 559 of 16 557 chunks carried a
heading_path — 94%. The corpus that opened this whole line of work indexed 106 806 chunks with
zero. The grammar works on real documents, and that is the first evidence of it outside a test.
Removing a blanket guard exposed what it had been incidentally protecting (20260805 22:11)¶
MEDIUM — and the finding came from an existing test, not from reading the diff. pnk init's
emptiness check was removed so a directory with content could be adopted. test_ci.py then failed:
it asserted that an existing .github/workflows/pinakes.yml is refused, and its evidence was the
string "not empty".
The obvious read is "update the assertion". The real finding is that the emptiness check had been
holding a second job nobody had written down. write_workflow does refuse to overwrite a
hand-edited workflow — but it runs after pinakes.toml has been written. With the emptiness check
gone, that refusal would leave a half-made KB: a manifest the user never asked for, and a re-run
that now fails with "already a KB", whose only way forward is deleting a file init created
itself.
Moved to _check_target, before any write, with the failure mode in the comment. The test now
asserts "already exists" — more precise than "not empty", and it names the only thing actually
in the way.
A second decision was refined in the same pass, and it is recorded rather than quietly taken.
The decision as written said "add a refusal naming any file init would write that already
exists". Implemented literally, that refuses on README.md and .gitignore — which a real
repository always has — so adoption would still have been impossible in exactly the case the item
exists for. The intent was "do not destroy the user's files"; the implementation honours it by
never overwriting and reporting, which is strictly safer than refusing and actually achieves the
goal. --ci is the one exception, because it is an explicit request rather than a side effect.
Generalisable: a coarse guard removed is not one behaviour removed. Before deleting one, ask what else has been quietly standing behind it — and take the failing test that follows as evidence about the system, not as a chore.
Slimming CLAUDE.md — a relocation's real cost is its pointers (20260806 00:33)¶
HIGH — the reference sweep has to run on the source file's name, not on the text that moved.
Extracting two sections out of CLAUDE.md left seventeen citations across the tree pointing at
content the file no longer carries. Not one of them quotes the moved wording; they name the file —
`CLAUDE.md` calls "the free path stays free" non-negotiable, "the invariant CLAUDE.md gains
says so explicitly", "for the reason CLAUDE.md states". A grep for the moved sentences finds
zero of them, which is exactly what "keep docs in sync — grep for what changed" instructs you to
run.
The two sweeps measured the difference:
| Sweep | Scope | Found |
|---|---|---|
| Neighbourhood audit, on the moved terms | docs/, tools/ |
4 |
Second pass, on the string CLAUDE.md |
whole tree | 13 more, all in src/ and tests/ |
src/ and tests/ were never opened by the first pass, because a docs-only change does not look
like it touches them. They held the majority: embed.py, sidecar.py, cli.py, ids.py,
extract/claude.py, extract/pageyield.py, budget/ledger.py, and five test docstrings, each
citing an invariant that had just moved to
docs/INVARIANTS.md. The
rule now: after relocating anything out of a file, grep -rn '<that filename>' over the whole
tree and re-judge every hit — a pointer names its target, so it is invisible to a
content-based search.
MEDIUM — docs/INVARIANTS.md is an index because the facts already had owners. Before
extracting, each of the nine invariants was checked against the docs that would hold it: eight
were already stated in DESIGN.md (§1 allowlist, §2.2 sidecar, §3 storage/schema, §5 ledger and
Decimal), MANIFEST.md (the id rows, the round-trip bounds table), VERIFICATION.md (§ The
sidecar round-trip) or CLI.md (--clear-cache=paid). A verbatim move would have created a second
copy of eight facts inside the file set whose stated rule is one fact, one home — and a second
copy drifts silently, because nothing compares them. So the page links each owner and writes out
only the five implementation rules nothing else states.
MEDIUM — the no-loss check has to be mechanical. Normalising every sentence of the old file and
matching it against the new homes flagged 29 candidates: 25 were false positives and 4 were real
losses — a measured number (980 RFCs), the "first time since it opened" qualifier, a plan's
self-description, and the why behind read the clock, never compose a timestamp, whose only
in-repo home had been the sentence being deleted. Re-reading the diff would not have separated those
four from the twenty-five; only a per-sentence check did.
Not fixed, out of this change's scope: tests/test_extract_claude.py and
tools/record_claude_fixtures.py both still require a local YYYYMMDD HH:MM for a recording's
--at, against the repo's UTC rule adopted 20260804 11:32. One for
plans/20260731_1202-open-corrections.md.
A plan's blocking premise was inferred, not measured — and it was wrong for two days (20260806 04:05)¶
HIGH — the reason a plan gives for a blocker is reused long after the blocker is gone.
plans/20260805_1721-metadata-as-retrieval-context.md said tests/demo-kb could not measure the
injection experiment because "no section spans multiple chunks, so there are no continuation chunks
to rescue — the mechanism has nothing to act on". Measured with the real chunker and the real
tokenizer over all 30 documents: 30 of 30 sections span more than one chunk, and 29 of the 30
continuation chunks do not contain their own heading text. The corpus carries the exact mechanism
the plan said it lacked.
The error was an inference from the wrong mechanism. The plan's fact was right — the documents are
~7 lines — but it reasoned that a short document fits in one chunk. chunk.py splits on paragraph
blocks first (Block is "one paragraph under one heading path") and only then applies token
limits, so a 7-line document with two paragraphs yields two chunks of 27 and 31 tokens under a
120-token budget. Nothing about the token budget was ever reached.
What makes this worth recording is that the plan's conclusion was right. Use the RFC corpus — correct. So the wrong premise cost nothing at the time and would have cost a great deal later: it is the sentence a future agent quotes when deciding whether some other experiment can run on the demo KB. A conclusion is checked when it is acted on; a reason is copied forward unexamined.
MEDIUM — the honest reason demo-kb cannot license this is arithmetic nobody had done. 66
answerable questions, 4 misses, and 56 of 62 hits already at rank 1. The whole improvable
pool on recall@k is 4, and the project's own sign_test(4, 0) returns p = 0.0625 — so a
perfect result fails the p < 0.05 bar the graph channel was held to. That is a power limit, not
a mechanism limit, and the two have different remedies: a mechanism limit is fixed by a different
corpus, a power limit by a different metric or more questions. Writing the wrong one down sends the
next person to the wrong fix.
MEDIUM — reasoning from a committed artifact nearly produced a confident wrong number. The miss
count above was first read out of tests/demo-kb/eval/outcomes.json, whose header predates G5 —
no graph_channel, no edge_kinds, no retrieval.adjacent_k, and graph_gate.read_leg reports its
channel as (absent). Two independent reasons the number could not be trusted from that file alone:
the artifact is only rewritten under --write-baseline, so CI never refreshes it; and compare()
tolerates a ±0.02 drift, which spans 4 to 6 misses — and sign_test(5, 0) = 0.0312 passes.
The claim "a perfect result cannot license" was therefore one question wide. Re-running the eval
settled it: aggregates identical, and all 74 rows match the committed file exactly. The rule is
not "distrust artifacts" but an artifact whose header does not match the current binary is
evidence about the past — the identity check graph_gate.check_identity performs on legs is the
same check a reader owes any committed number.
MEDIUM — four silent failures sat in front of an experiment costed at "~2 h rebuild + eval". The
RFC corpus stamps no max_tokens, so the default 510 applies against a measured window of
512 with 2 special tokens: zero headroom, and prepending anything pushes every full chunk
past the window. Embedding an over-length string raises no warning and no error (measured, empty
warnings list), and assert_chunkable cannot catch it because it validates max_tokens, never
max_tokens + prefix. The direction is what makes it dangerous: truncation removes text from
exactly the long chunks the hypothesis is about, biasing the result toward no movement — a false
negative that reads as a clean result. Separately, the lexical channel cannot be injected at all
without a new chunks column, rewritten FTS5 triggers and a schema_version bump, because
chunks_fts is an external-content table filled by triggers copying new.text.
The estimate was not wrong about the run; it costed the run alone. Step 2 is three increments and a measurement, and none of the three was visible from the plan as written.
LOW — a plan that forbids retrying below "the threshold" has to name it. The document's anti-circularity clause read "a result short of the threshold is reported rather than retried" while nothing in it ever said what the threshold was. An unfalsifiable gate is not a gate; it was fixed by decision rather than discovered, but it survived a full adversarial review of the plan because the sentence sounds like a commitment.
2a — Real titles and a measured chunking reserve for the RFC corpus (20260806 06:12)¶
The plan's worked reserve was wrong by more than twofold, and only measuring found it. The
injection experiment's plan offered 480 as the corpus max_tokens, from "a measured max prefix of
30" — RFC 9110's largest title > heading_path with section numbers stripped. Measured properly,
over every heading path of RFCs 8600-8799 (195 documents, 5 of the 200 numbers unpublished),
tokenised with the corpus's own BAAI/bge-small-en-v1.5: the largest prefix is 68 tokens, the
per-document largest has median 31, p95 51, p99 61. The median document already exceeds 30.
RFC 9110 was an unrepresentative sample for one reason nobody spotted: its title is two tokens
long, and titles run to 32.
Reserving 30 would have truncated roughly half the corpus's longest chunks — silently, since an over-length embedding input raises no warning and no error. That truncation removes text from exactly the continuation chunks the hypothesis is about, so it biases toward no movement: a false negative that reads as a clean result. The reserve shipped is 96, deliberately 41% above the measured maximum, because 200 numbers is under a third of the modern band.
The plan said "do not re-derive these" about a table whose own line numbers had already drifted. Same failure class, one level up: a number recorded as measured invites reuse, and reuse is exactly what makes a sampling flaw permanent. The four "read this before writing a line" findings in §2 were all correct and all load-bearing; the one worked number beside them was not.
A defect I nearly introduced, caught in my own adversarial pass. Preserving the KB id across a
re-run — needed once the output directory holds permanent document ULIDs — makes a re-run look
identity-preserving while write_kb still rewrote pinakes.toml. That would silently discard the
[retrieval.confidence] thresholds 2c will fit onto this corpus, with every command reporting
success: the same KB by its id, no longer the same KB by its calibration. The fix removed the
reason rather than the symptom — an existing manifest is not rewritten at all, so the id is stable
by construction and the existing_kb_id reader that motivated the concern was deleted. Its cost is
that a re-run does not adopt a changed [chunking], so the run now names both values.
What nothing else in the repository exercises: a title with YAML punctuation. RFC 8713 is
"IAB, IESG, IETF Trust, and IETF LLC Selection, Confirmation, and Recall Process: Operation of…".
Every committed corpus is hand-titled in plain words, so the first colon in a title: arrives from
the RFC Editor — into the one file holding a document's permanent ULID. ruamel quotes it and it
round-trips; that is now asserted through sidecar.read rather than assumed.
Verified by execution, which the plan had none of. Two RFCs built and synced with the real
fastembed backend: both documents indexed under their published titles, 801 chunks, largest
token_count exactly 414 — so the stamped cap binds on real text, as finding 1 said the default
510 does against a 512-token window.
A bound left in place, not fixed. corpus.json describes the run, not the directory: a
re-run over a smaller --rfcs set records fewer RFCs than docs/ still holds. Pre-existing, and
now slightly more visible because titles.kept_from_earlier_run hints at accumulation. Worth
fixing before the run at 2f, where the corpus's composition has to be attributable.
2b — Refusing a prefix that would not fit (20260806 08:24)¶
A refusal with no caller is a refusal nobody has watched fire, so this one was written to be
runnable on day one. The manifest option that turns injection on belongs to 2d, so nothing on the
indexing path calls assert_prefix_fits yet. The temptation was to ship the check alone and let 2d
supply the thing that exercises it. What shipped instead is the whole prefix construction —
metadata_prefix, embedding_text, and the unnumbered_heading_path they read — so the refusal
measures exactly the string that will later be embedded, and every part of it is reachable from a
test today. 2d then adds one option and one call, not a mechanism.
The additive estimate was measured rather than argued. The check counts each distinct prefix
once, with its separator attached, and adds chunk.token_count rather than re-tokenising every
injected string — a document has orders of magnitude fewer heading paths than chunks. That is only
safe if the sum never under-counts the concatenation. The reasoning (a tokenizer that splits on
whitespace before merging cannot produce more tokens from prefix + sep + text than from its
parts) is sound but is the kind of reasoning that is wrong once per project, so it was run: against
BAAI/bge-small-en-v1.5, over 43 503 chunk/prefix pairs from 195 RFCs, the estimate was
exactly equal to the concatenation's real token count every time — not merely bounding.
The same run reproduced 2a's corpus figures from an independent code path (largest prefix 68,
per-document largest median 31 / p95 51 / p99 61, longest title 32) and confirmed the refusal fires
for 195 of 195 documents at the default max_tokens = 510 and for none at the corpus's 414.
The reserve is checked, not the worst chunk in hand, and that was a decision. Per-chunk pairing
is more permissive and more exact: it refuses only what would actually truncate today. It was
rejected because the two legs of an A/B comparison must chunk under the same max_tokens or they
are different corpora — so what has to be safe is the setting, not this morning's text. A
document that passes because none of its chunks happens to reach the cap would start truncating on
the next edit, mid-experiment, silently. Refusing the setting is stable across documents and across
edits to them.
A field added to a frozen dataclass is a field somewhere else forgets to copy. _with_pages
rebuilt every PDF chunk field by field to attach page numbers, so unnumbered_heading_path would
have arrived as None for PDFs only — green suite, silent gap. It now uses dataclasses.replace,
which cannot omit a field, and a test pins the property. The same reasoning kept the field out of
as_row: the stored form is the citation form, and a second column is a second thing to keep in
step with it.
Markdown keeps whatever number its author typed, and that is the same rule rather than an
exception to it. ## 1. Introduction yields 1. Introduction in both paths, because nothing
parsed a number there — # is syntax and is already gone, and the text after it is the author's.
Only the grammar that parsed a number is entitled to remove one. A regex over the joined string
would have been shorter, would have drifted from the grammar's own rule, and would have eaten the
404 from # 404 Not Found.
Two findings from the increment's own adversarial pass, both about a value with more than one
home. The first: the prefix's join was introduced as a new constant, which would have made four
literal " > "s in this module plus graph/edges.HEADING_SEPARATOR reading them back — for a
format that is persisted in chunks.heading_path, where a disagreement empties three edge
kinds and reports nothing. It is now one HEADING_JOIN used at every site that builds a heading
path, with the consuming copy named in its docstring. The second: title is the user's field in a
hand-edited sidecar, so it can be "" or " ", and the first draft treated whitespace as
content — injecting a separator with nothing in front of it into every chunk of that document.
Neither would have failed a test or a run; both are the shape this project keeps meeting, a value
that is wrong in a way nothing reports.
2c — A golden set authored by agents that did not know what it was for (20260806 11:42)¶
The anti-circularity rule was implemented rather than promised. The plan requires the questions frozen before any injection code exists, "so that no number can influence them" — and the deeper risk it names is fitting the question set to the mechanism, which "is undetectable afterwards". An author who knows the hypothesis cannot prove they ignored it, and no reviewer can check it from outside. So the set was authored by six agents over disjoint document slices, each told only that it was writing an evaluation set for a retrieval system and forbidden from reading this repository, where the plan would have told it. Blind authorship is the only version of that guarantee that survives review.
The exit criterion failed the first time, and how it failed was the useful part. 70 questions
produced an improvable pool of 9 against a criterion of 10. The shape mattered more than the
number: 51 of 60 answerable questions were already at rank 1, lexical and simple-lookup both
scored 1.00, and 8 of the 9 pool members were paraphrase. On a corpus of distinctive
technical vocabulary — protocol acronyms, registered code points, field names — BM25 with a
reranker essentially solves the classes that share words with their document.
The obvious fix was the wrong one, and it would have passed. Authoring more paraphrase
questions reaches a pool of 10 quickly. It also enriches the set with exactly the class a change to
the embedded text is most likely to move, which is fitting the instrument to the hypothesis one
step removed: the criterion would pass and the result would mean nothing, with nothing in the
artifact to show it. The two new slices carried the same proportional mix as the first four,
over 135 documents no question had touched. The pool went to 15, and it changed shape — 11
paraphrase, 2 lexical, 2 simple-lookup, where before it was one class. A pool spread across classes
is what makes compare()'s per-class guard able to catch a change buying one class out of another.
The expensive error in a question set is self-concealing, so the set carries its own evidence.
A question pointing at the wrong document looks exactly like a retrieval miss. It would have
inflated the pool and made the power criterion pass for the wrong reason — the measurement then
resting on questions nobody could answer either. Every answerable question therefore records the
sentence from its document that answers it, verbatim, and tools/verify_rfc_golden_set.py refuses
the set if a sentence is not there. All 96 verified. Whitespace is normalised because RFC bodies
are hard-wrapped at ~72 columns, which also means the recorded evidence is often the fragment on
one line rather than a whole sentence.
A slice can only check its own slice. Each author confirmed its no-answer topics absent from
its own 50 documents. Checked across all 195, three had mentions elsewhere — "camel case" in
rfc8618 against a question about the CAMEL protocol, one mention of blockchain databases in
rfc8673, Bluetooth as a transport example in rfc8628 and rfc8793 — and later, SCADA as a category
label and NFC as both a URI transport and Unicode Normalization Form C. None answers its question,
and all were kept deliberately: a calibration set wants plausible near-misses, and a lexical
collision that answers nothing is exactly that. Two topics were duplicated across slices and
dropped.
filter and multi-hop are absent by decision. filter needs metadata worth filtering on and
these sidecars carry a title and nothing else. multi-hop is the graph channel's class and
graph_channel is off here, so such a question would score as an ordinary lookup while being
reported under a name claiming otherwise.
The corpus is not committed, so everything that reads it had to be. The questions, the fitted
confidence thresholds and the before leg all live in the repository; the 195 documents do not.
That is the same lesson build_rfc_corpus.py was written for — a 300-RFC corpus once produced this
project's most useful finding and died with the machine that held it. The thresholds are stamped
into the builder's manifest template rather than pasted into a generated pinakes.toml, which also
makes both legs of the comparison fitted identically by construction: refitting after a change
would measure the refit.
A defect found by asking what would protect the artifact this increment produces. Capturing a
before leg raises the question of what stops it being compared against an after produced
differently — and graph_gate.check_identity turned out to guard only the graph channel's own
settings. Reading eval.header for what it does record showed the gap was one level lower: the
function's docstring promises "every setting that can move a row", and [chunking] was not in it.
Every other outcome-deciding setting was. So the leg committed here could not have been shown to
have been chunked at 414 rather than the default 510 — the precise confusion 2a's reserve and 2b's
refusal exist to prevent, surviving into the artifact that records their result. Three fields, no
schema bump, because no row changed.
2d — The screen said no, and the controls are what make that worth believing (20260807 09:07)¶
The result: 6 improved, 6 regressed, 84 unchanged, over 96 answerable questions. The
pre-registered criterion was strictly more improvements than regressions, so the screen is a
no-go and the schema_version bump at 2e is not taken. Per the pre-registration these numbers
are the whole report — they are not evidence for or against the hypothesis in either direction, and
they appear here and in the increment's commit message and nowhere else. The screen's own artifacts
were deliberately not committed.
What was measured, stated exactly, because a null is only as good as its controls. Injecting
title > heading path into the text that is embedded — the vector channel alone — moved
nothing net on this corpus at rerank = "none". The both-channel form was not tested: the
lexical channel needs a new chunks column and a schema bump, which is the cost this screen
existed to decide. The dilution objection that disqualified vector-only as a gate applies in full
to this null: RRF fuses an injected vector channel against an unchanged BM25, so a real effect is
attenuated before it reaches a rank.
A null result is a claim about the world only if the instrument was pointed at it, so four controls were run before the comparison — three of which the plan did not ask for.
| Control | Result |
|---|---|
The uninjected index still reproduces 2c's baseline (rerank = "local") |
110 of 110 rows identical, twice — once on main's binary, once on this branch with the option off |
| Both legs are the same corpus | 195 documents, 43 353 chunks, and one sha256 over every chunk text, equal |
| The injection actually reached the vectors | mean cosine 0.8398 between the before and after vectors of 2 000 sampled chunks; zero unchanged |
| The prefix was the intended string | 195 of 195 published titles, zero filename stems — finding 5's confound absent — and 93.2% of chunks carrying a heading path |
The third is the one that decides how to read the null. Chunk texts are byte-identical between the legs by construction, so if the injection had silently not happened, every artifact in the experiment would look exactly as it does now — same corpus, same questions, a clean flat result — and the conclusion would be drawn from a no-op. Measuring the vectors is the only thing separating "no effect" from "no injection", and it cost one script.
The option-off path being a verified no-op is not a formality either. The same binary that
carries the injection reproduced the frozen baseline row for row, which is what licenses comparing
this branch's after leg against a before leg captured on it.
The shape of the movement is more interesting than the count, and it is not being reported as a
result. Of the 6 improvements, 5 were paraphrase — the class the hypothesis targets, and the
only class with power on this corpus. Of the 6 regressions, 2 were simple-lookup questions that
had been at rank 1. That is what a dilution cost looks like: a prefix adds tokens to a vector whose
question needed none of them. Twelve of 96 rows moved, so the mechanism is doing something; it is
simply not doing more good than harm through one channel. This is an observation about a
measurement that was pre-registered not to be interpreted, and it must not become the premise of a
retry — the anti-circularity rule says a result short of the threshold is reported rather than
retried with a different injection format.
Two things surfaced by building it that the plan had not anticipated.
- The refusal is a per-document failure, not an aborted run.
assert_prefix_fitsraises aChunkingErrorfrom inside_index_document, and everyPinakesErrorthere is already caught by sync's per-document handler: the transaction rolls back, the document is named in the report and recorded in the index forpnk doctor. That is the right shape — one pathological heading path should not cost a 195-document corpus its other 194 — and it still removes the silent truncation, because a refused document is not indexed at all. But "refuses the corpus" was the plan's phrasing, and the test written for it originally asserted a raise that never comes. - With injection on, every document is prefixed, because
skeleton()falls back to the filename stem. A document can reach the embedder with noheading_path; it cannot reach it with no title. So on an uncurated corpus the injected string is a filename — finding 5's condition, now located at the sync boundary rather than in the abstract. It is why the RFC corpus mints published titles before its first sync, and it is the strongest argument for the option defaultingoff.
A test that passed vacuously, caught during development and worth naming. The assertion that
the injected prefix uses the sidecar's title was written over a sidecar-only edit — which is a
RefreshMetadata action, so it updates the row and re-embeds nothing. all(...) and
not any(...) over an empty list are both true, and the test was green while proving nothing. The
fix was --rebuild plus an explicit assert backend.embedded precondition. Any assertion of the
form "everything embedded looks like X" needs a companion assertion that something was embedded.
tools/two_leg_gate.py exists because the instrument had the same gap one level up. 5993521
made an eval artifact record the chunking it was produced under; nothing compared it —
graph_gate.check_identity takes three legs shaped to the graph channel and inspects k,
embedding, rerank, ranking and retrieval, but not chunking. Two legs chunked at different
max_tokens therefore compared clean, which on one RFC is 63 of 1 858 chunk texts differing: a
rechunk reported as the effect under test. The new tool refuses on any header difference outside
one named key, and it excepts that key by path, not by block — excepting the whole chunking
table would hide exactly the rechunk it is there to catch.
2d review — the grep that was necessary and not sufficient (20260807 09:54)¶
HIGH — a rebuild could leave a half-injected index, and the check that should have caught it was
looking for the wrong thing. Before landing the injection, this increment verified that
sync.py has exactly one .embed( call on the indexing path — grep -rn '\.embed(' src/
returns that one, plus two query-side calls. The conclusion drawn was "every vector on the indexing
path goes through the switch". It does not follow, and the counter-example was already in the file:
_copy_forward_protected_document writes rows into embeddings with an INSERT … SELECT from the
index being replaced. It produces vectors without embedding anything, so no grep for .embed(
could ever have found it.
The consequence was the failure class this project exists to prevent. A KB with a paid-extracted
PDF, [chunking] metadata flipped to "prefix", and pnk sync --rebuild: the protected document
keeps its uninjected vectors, set_meta stamps chunking_metadata = "prefix" over the whole
index, and the next pnk sync and pnk doctor both report no drift. Every command succeeds,
and half the index is injected.
The fix separates the two costs that had been treated as one. The docstring said the document is "never re-extracted, never re-embedded", as though those were the same protection. Extraction is what spends money; embedding is local and free, and the chunk texts are carried forward anyway. So the chunks are still copied and the extraction still never re-run, and the vectors are recomputed under this run's settings — which fixes both directions, since turning injection off again had the mirror-image defect.
One guard is deliberately louder than it needs to be today. unnumbered_heading_path is not
persisted (chunk.Chunk says why: the stored form is the citation form, and a second column is a
second thing to keep in step). A carried-forward chunk therefore cannot say what its path looks
like with the section numbers removed, so injecting the stored form would quietly prepend the
citation form this experiment measured at 44% numbers and rejected. Unreachable now — only PDFs are
ever protected and the PDF path records no heading path — but step 5 of this plan is PDF layout
heuristics, which is exactly what would make it reachable. It refuses with a named remedy instead.
What the review round found beyond that, all of it in code this increment wrote:
--sign-test printed FAIL at 0.05 and exited 0 |
the flag names the 2f gate, which licenses the irreversible schema bump; a driver branching on $? would have taken it |
A miss was written to the artifact as Infinity |
invalid JSON: JSON.parse rejects it, jq silently coerces it to 1.8e308 — the one outcome the rank ordering exists to make visible became a very good rank |
| A truncated artifact exited 1, same as a genuine no-go | read_outcomes only refuses a file that parses; a JSONDecodeError is a ValueError, which the first version of the handler did not catch |
| Nothing recorded which leg was which | transposing --before/--after inverts the verdict, and the identity check cannot catch it — it is never told which value is the baseline, only that the two must differ |
pnk doctor's half of the drift promise was untested |
a mutant reading a constant there passed the entire suite |
| Two more vacuous assertions | backend.embedded == [rows] is [] == [] when a run indexes nothing; all(…) over an empty list again |
Three of those six are the same defect in three costumes, and it is worth naming once. An assertion of the form "everything we produced looks like X" is silently satisfied by producing nothing. It has now been found three times in this increment alone — once during development, twice in review — so the rule is: any assertion over a collection the code under test produced needs a companion assertion that the collection is non-empty, and preferably one that names the expected count.
The review that found this was itself a measurement, and it half failed. Five adversarial
lenses were run as independent agents; a usage limit killed 15 of the 17 agents mid-flight,
including every verifier. The workflow returned {"confirmed": [], "refuted": []} — which reads
exactly like a clean review and was nothing of the sort. The findings were recovered from the
agents' own transcripts and verified by hand. An empty result from a harness that partially
failed is not evidence of absence, and a report that does not distinguish the two is worse than
no report: confirmed: [] was one careless sentence away from becoming "the review found nothing".
2d review, round two — the argument for the key's placement was false when written (20260807 10:34)¶
HIGH — the option was put in [chunking] rather than [retrieval] because [chunking] is
recorded in the index and therefore cannot flip silently. That reasoning was correct about the
mechanism and wrong about every KB in existence. chunking_drift treats a key absent from meta
as unknown, never drifted — the rule that stops an upgrade demanding a rebuild of every index —
and chunking_metadata is absent from every index built before this release. Only a --rebuild
ever stamps the chunking identity, so the absence is self-perpetuating. Measured on a KB built by
this branch with the key then deleted, which is exactly the shape a 0.15.1 index has:
drift () embedded 0 backend calls 0
pnk doctor: OK chunking coherence: index matches the configured chunking
An affirmative OK over uninjected vectors, forever. For a pre-existing index the two manifest sections behaved identically, which is the thing the placement argument was written to rule out.
The fix turns on a distinction the original rule did not need. max_tokens and overlap have
been settable since v0.1, so an index that fails to record them could genuinely have been built
under any value: absence there is ignorance. chunking_metadata is different — no release that
could have written any existing index was able to inject anything — so absence proves off.
store.ABSENT_MEANS says so, and because it resolves to the default, it fires only for a user who
explicitly opted in: the compatibility guarantee it looks like it threatens is untouched. Both
directions have tests, and the second one matters as much as the first — an unclearable warning on
every upgraded KB is the failure mode the heading-coverage check already had to answer for.
MEDIUM — the fix for round one's finding shipped with the same class of hole inside it. Round
one found that --rebuild carried a protected document's vectors forward, and the fix re-embedded
them. But the re-embed called embedding_text without calling assert_prefix_fits, so the one path
that re-embeds without re-chunking became the only path with no truncation guard — and it is the
path that needs one most, since its chunks were sized by whatever max_tokens built the previous
index and are never re-chunked. A fix for a silent-truncation defect that reintroduced silent
truncation one function away.
MEDIUM — and it also introduced a way to publish a half-written document. DETACH requires the
transaction closed, so that function commits in a finally; with the writes inside that block, a
document whose embedding failed was committed active with chunks and zero vectors, which
_apply's rollback() could no longer undo and --rebuild's unconditional index swap then
published. No later sync repairs it: the file's content hash is unchanged, so every future run says
Skip. Now the old rows are read under the attach and every write happens after it, in one
transaction the caller can still roll back.
MEDIUM — the eval artifact labels a leg from the manifest, never from the index. Every
[chunking] value in a header is read from pinakes.toml at eval time. Because flipping metadata
changes no chunk's text, hash or span, an eval run against an index that was never rebuilt produces
a byte-for-byte plausible artifact stamped metadata: "prefix" over uninjected vectors — and
tools/two_leg_gate.py would accept it as the injected leg, since it compares headers to headers.
The instrument that licenses an irreversible schema bump could be handed a leg that never existed.
eval.run now compares the index's recorded chunking against the manifest and refuses before
scoring a single question.
The four rules this round leaves behind.
- A grep for the operation is not a proof about the outcome. Round one's defect survived a
check for every
.embed(call site because the offending path produces vectors withINSERT … SELECT. Round two's survived a reading ofchunking_driftbecause the defect is in what the function does with a key that is not there. Both times the search was over the wrong set — the question is never "where is this called" but "what else can reach this state". - A fix is a change, and gets the same review as one. Two of this round's findings are in code written to fix round one, hours earlier and with the defect fresh in mind.
- State an argument in terms of the population it covers. "
[chunking]is recorded, so the flip is reported" was true of indexes this release builds and false of every index that existed. The sentence never said which it meant. - A partially-failed harness must not report like a completed one. Round one lost 15 of 17
agents to a usage limit and returned
{"confirmed": [], "refuted": []}— indistinguishable from a clean review. The findings were recovered from the agents' transcripts by hand; one was the HIGH above. Round two ran complete: 14 findings, 14 judged, 9 confirmed, 5 refuted.
T1 — The version archive, and what mutation testing found in it (20260807 19:36)¶
HIGH — the plan's own baseline undercounted the sites it had just corrected, and the second
count was as wrong as the first. The template-release plan was re-verified against main at
71911e2 on 20260807, and that re-run specifically corrected the notes@1.0 site list: it had
said "both, in test_init.py", and the correction raised it to "six sites in five files", with a
box explaining that the original defect was a grep scoped to one file. Running the corrected
command at that same commit returns nine sites in eight files. The three it still missed were
tests/test_sync_links.py:66 and the two committed KB manifests, tests/demo-kb/pinakes.toml and
tests/partner-kb/pinakes.toml. The lesson is not "grep wider" — the plan already said that. It is
that a count in a document is a measurement, and re-running the command is the only thing that
distinguishes a corrected count from a confidently wrong one. The correction was written and
believed; nobody re-ran it.
HIGH — a formatter can edit a file the project has promised is frozen. The archive's whole
value is byte-identity: _versions.toml records a SHA-256, and pnk upgrade will diff against
those exact bytes. check.sh runs ruff format --check . over the whole repository, and ruff
reformats Python inside Markdown fences. A template README.md that ever gains a python fence
would be rewritten in its archived copies too — the project's own formatter editing a version
that already shipped — and it would surface as a ledger mismatch one leg away from its cause.
Latent today (no template README has a fence) and closed with a [tool.ruff] extend-exclude.
Generalisable: whenever a repository declares some bytes immutable, list every tool with write
access to them. The gates were audited; the formatter was not, because it is not a gate.
MEDIUM — mutation testing found a branch no input could distinguish, which is the same defect
class as a gate that cannot fire. git_history_reason probed --is-inside-work-tree and then
--is-shallow-repository. Neutering the first changed no test result: outside a checkout the
second exits 128 anyway, so the first was dead weight wearing the clothes of defensive care. It
was removed. This increment exists because pnk doctor carried a check that could never fire for
eleven releases; shipping a redundant branch inside its fix would have been the same mistake at
one-tenth the scale. A branch that no test can tell apart from its absence is a branch nobody
knows works.
MEDIUM — the first mutation run was invalid, and its invalidity was informative. Three mutants
changed content_hash and turned the whole suite red rather than one test, because the committed
ledger pins the hash function: change it and leg (iii) fires everywhere. That coupling is a feature
— the hash definition cannot drift silently — but it destroys the per-assertion signal, so the
harness was corrected to regenerate the ledger under each hash mutant, which is the tree such an
implementation would really have shipped with. Separately, two tests both tampered with
README.md, so a mutant exempting the README failed all three. A mutant that travels to another
test means the two tests share a vector, not that either is wrong — but the sharing is worth
removing, because it makes every future mutation result harder to read.
MEDIUM — leg ordering decided which remedy the user is given, and the wrong order gives the
opposite one. Editing a frozen _versions/<live>/ file also makes the live files differ from
it. With the live-vs-archive comparison first, the gate reported "the live files differ from
archived 1.1" and advised bumping the version — when what happened was that a published version
was edited and needs restoring. The ledger check now runs first, so each fault reports its own leg.
Found by running the failure paths, not by reading the code: both orderings look correct on the
page and only one is.
LOW — "exactly one commit" was unsatisfiable in the increment that introduces it. Leg (vii) as
specified fails on its own landing commit, because ./check.sh runs before the archive is
committed and git log returns zero. It is at most one: zero means not yet committed, one means
added and never touched, two or more is the violation.
HIGH — the gate reproduced, inside itself, the exact defect it was built to catch. Given
--templates as a relative path, leg (vii) built its git log pathspec against the process
working directory while git resolved it against the templates directory. It matched nothing, git
log returned empty, zero commits read as "not committed yet", and the gate printed
history leg (vii) ran … none edited and all legs green over a tree carrying the coordinated
three-file edit. That is the strong mode claimed while nothing was checked — the thing this
increment exists to stop pnk doctor doing. Every leg-(vii) test passed --repo explicitly, so
nothing covered it. A flag that changes where a tool looks needs a test that runs it from
somewhere else; resolving paths once at the argument boundary is the fix, and the general rule is
that a relative path and a cwd argument must never be chosen independently.
HIGH — leg (vii)'s first design blocked the project's own procedure, and no single mechanism
fixes it. Counting every commit that touched an archived directory fails a branch that adds one
and then corrects it during review — which is exactly what docs/BUILDING.md requires (green
./check.sh before review, review fixes in their own commit). The failure text said the archive
"still says what the version said when it shipped" about a version that had never shipped, and the
only escape — amend or rebase — is the operation that also defeats the leg. Two candidate fixes each
looked sufficient and each was blind: counting only commits already on origin/main stops catching
the coordinated edit before it merges; comparing content against origin/main stops catching an
edit that has already merged. The leg needs both halves, and the way to see that was a table of
three scenarios against two candidate rules, not a closer reading of either candidate.
MEDIUM — a test asserting on a configuration was satisfied by the prose describing it. The test
pinning fetch-depth: 0 onto the gate's CI job grepped the workflow file for the string. The job's
own comment explains why the setting is there and contains it, so deleting the setting left the
test green. Caught only by mutating it. Grep a config file and you assert about a document; parse
it and you assert about the configuration — the test now loads the YAML and reads
jobs.template-drift.steps[checkout].with.fetch-depth.
MEDIUM — nothing pinned that the gate was wired in at all. Deleting the check.sh line and the
entire CI job left all forty-odd tests in tests/test_template_drift.py green, because every one of
them drives the tool directly. A test suite for a gate proves the gate works; it says nothing
about whether anything runs it, and those are different claims. Two assertions now cover the
second one.
MEDIUM — the hash covered bytes that do not ship, and could bake them into the ledger. It walked
the working tree, so a gitignored .DS_Store in the template directory turned ./check.sh red on a
clean checkout with the remedy "bump the version and archive the new files". The worse direction:
present while --print-hash generated a ledger row, it was folded into the committed sha — leaving
the author green and failing only on a clean CI checkout, pointing at an archive nobody had touched.
The fix is ignored, not untracked, and the distinction is load-bearing: measured against a real
hatchling build, a gitignored file does not reach the wheel while an untracked-but-un-ignored
.orig does. Hashing git's tracked set would have hashed away a stray file that really
publishes, and given a brand-new archive the digest of the empty string.
Process note — two independent skeptics disagreed, and the disagreement was the useful output. One confirmed the stray-file finding; another refuted it, having implemented and measured a tracked-set fix and shown it strictly worse. Both were right about what they tested, and neither had tested the rule that was actually adopted. A refutation kills a proposed fix, not necessarily the finding — the two have to be judged separately, and the second reviewer's evidence is what made the third option obviously correct.
T2 — Reporting template drift as a line count (20260807 22:28)¶
HIGH — A test that never calls the function under test cannot kill a mutant inside it.
test_the_kb_identity_block_never_produces_a_hunk was written for the mutant the plan called the
one that matters: rendering the old side with the KB's recorded reference and the new side with the
installed one, which is what a reader of init.py:75 would naturally write, and which puts a
[kb] hunk in every report on every KB. The test asserted exactly that property — by rendering
both versions itself. So the mutant went into doctor._template, the test rendered its own two
sides correctly, and it passed. The property was right, the altitude was wrong. It now also asserts
the count doctor reports, against a synthetic pair that differs on one line: correct is two, a
leaking identity block is four.
HIGH — A line-count assertion is blind to an edit that substitutes rather than adds. The
invariance test edits the user's manifest and asserts the reported count does not move, which is
what catches a report built from pinakes.toml instead of from two archived templates. With that
defect injected, replacing fake-model with fastembed-model and final_k = 5 with
final_k = 4 left the count identical — one line replaced by another is still one line on each
side of a diff. The test was invariant under the implementation it existed to reject. Appending a
comment line fixes it, because no substitution can absorb an added line.
Both were found by running the mutation pass, not by reading the tests. Two of five mutants survived a suite that was green, and neither survivor was a bug in the increment.
MEDIUM — A comparison that reads one of four files must not report 0. A template version
denotes four consumed files; this check diffs pinakes.toml.j2. A bump touching only
eval/questions.yaml renders two identical manifests, and the first implementation said
0 lines differ — true of the manifest, read as nothing changed. It is not a hypothetical: of
the ten commits between the notes template's first version and its second, five touched the
golden set and none touched the manifest. It now says same manifest and names what a version
covers beyond it.
MEDIUM — The most-read string promised something no release can deliver. The cannot compare
remedy ended "from the next template version onward the comparison is automatic". Under D-2b
notes@1.0 is deliberately unarchived, so a KB recording it stays uncomparable however many
versions ship after — the sentence was false for precisely the readers who see it most, which is
every KB in existence. What a later version changes is the next KB, and the remedy now says that
and says the missing content is gone rather than pending.
A second copy of "the variables this build supplies" was one commit from existing.
tools/template_drift_gate.py leg (vi) asserts every archived version renders, under a context
written out in the gate. The product now renders both sides of a comparison under
template.render_context. Two literals, and the failure mode is the gate staying green while
pnk doctor raises on the KB in front of it — the gate would have been asserting that the archive
renders under a context nothing uses. The gate now builds its keys from template.CONTEXT_KEYS,
and test_render_context_supplies_exactly_the_declared_union pins the remaining seam.
T3 — pnk upgrade, print only (20260808 00:02)¶
HIGH — a same-length mutant is invisible to Python's bytecode cache, so a mutation run can report
the exact false negative it exists to prevent. Two of T3's eight mutants — == 1 → >= 1 on each
branch of the placement predicate — came back SURVIVED. Applying one of them by hand and running
the same test killed it immediately. The harness was mutating src/, and CPython invalidates a
.pyc on the source's (mtime truncated to the second, size): == 1 → >= 1 changes neither, so
a mutation written and reverted inside one second was executed from stale bytecode. Every mutant that
did die changed the file's length.
Two things follow, and neither is about this increment.
- A mutation harness must invalidate bytecode explicitly — delete the module's
__pycache__entry after writing and after restoring. Without it, "mutation-verified" is a claim the harness cannot support for precisely the smallest, most surgical mutants, which are the ones worth running. - The failure was silent and reported success.
SURVIVEDand "this code path has no test" are the same string, and only one of them was true. What caught it was disbelief — the surviving mutant looked like it should have been caught by a test that named uniqueness in its own title — not the tooling.
And the disbelief was half right, which is the second finding. Once the cache was cleared, one
mutant still survived: relaxing already applied from "at exactly one position" to "somewhere"
killed nothing, while the same relaxation on the clean branch was caught at once.
::test_a_hunk_whose_context_matches_twice_is_a_conflict covered one branch of a two-branch rule and
read, from its name, as though it covered the rule.
::test_an_already_applied_hunk_matching_twice_is_a_conflict_too is the missing half — a user who
adopted a change and kept a second copy of the block — and it was written because a mutant demanded
it, not because anyone re-read the predicate.
MEDIUM — the invariance test demanded the wrong property, and passing would have been worse than
failing. "A user's edit never appears in the report" was written as "the two outputs are
byte-identical", and it failed: a user's provider = "fastembed" does appear, as unchanged
context, because the context is what their own manifest renders to. The property that matters is
narrower — their edit never appears as a +/- line — and the first version would have forced the
implementation to hide context lines to satisfy a test, which is the tail wagging the dog. The
distinction is now in the test's docstring: the context lines are the user's, the changed lines are
the template's.
MEDIUM — textwrap broke a command across two lines, and the wrap was added for readability.
The first wrapped remedy printed run `pnk at the end of one line and init` on a throwaway
directory at the start of the next — a remedy naming a command nobody can copy, produced by the
change meant to make remedies easier to read. _fill now glues the spaces inside a code span
before wrapping. Worth remembering wherever prose meant for a terminal contains something meant to
be typed.
LOW — the plan predicted a mutant would kill two tests and it killed one, because the implementation is stronger than the plan assumed. Dropping uniqueness was expected to fail both the twice-matching test and the reordered-manifest one. It fails only the first: reordering is caught by matching the hunk's lines contiguously and in order, which is a different clause. A separate mutant — every line merely has to be present somewhere — kills the reordering test. The plan's prediction assumed a looser predicate than the one built; the two tests assert two properties, which is what it was really asking for.
HIGH — the "writes nothing" test could not see a write, and its docstring stated the reason
backwards. T3's central claim is that the command writes nothing, and the plan names the mutation
that proves it: open the manifest for writing and confirm ::test_nothing_under_the_kb_is_written
fails. It did not fail. The snapshot compared the bytes of the files only, so a rewrite of
identical content was invisible, and directories were filtered out, so mkdir(".pinakes") was too.
Both mutants survived and the suite was green.
Two things made it hard to see, and the second is the lesson.
- The mutation run had reported this mutant as KILLED, because the mutant I wrote also dropped a stray file — a strictly stronger mutation than the plan's. A mutant that does two things cannot tell you which one the test caught.
- The helper's docstring asserted the opposite of the truth: "Bytes rather than mtimes: an mtime comparison passes for a rewrite of identical content." Mtime is precisely what catches that; bytes are what miss it. It read as a considered trade-off, which is why nobody re-derived it. A wrong reason stated confidently is worse than no reason, because it ends the inquiry.
The snapshot now compares the path set, the bytes and st_mtime_ns, over files and directories
alike, with a table naming what each of the three is blind to on its own.
HIGH — the already applied predicate asked a whole-file question about a per-hunk fact. It
tested whether the hunk's removed lines occur anywhere in the manifest. A manifest is
comment-dense and repeats blank lines and bare # everywhere, so any hunk deleting one could never
be already applied: the user who adopted that change by hand was told conflicts — and under
T4's all-or-nothing rule, that refuses their whole --apply run. It now asks whether the hunk's
before image is still there, which is the same question scoped to the hunk's own region, with the
pure-addition case carried by an explicit not removed guard where a reader can see it.
MEDIUM — a control test passed under the very mutant it was written to kill. The first version of "a deletion not yet applied is still clean" deleted a line in the middle of the file. With trailing context, removing a line breaks the after image's contiguity on its own, so the first half of the predicate answered and the half under test was never consulted. Only a deletion at the end of the file — where there is no trailing context — reaches it. The shape of a fixture is part of what it tests, and "it deletes a line" was not specific enough to be a test of anything.
MEDIUM — _range and _section were written by hand and asserted by nothing. Six mutants
across them survived: emitting a two-number range where a one-line range is bare, dropping the
empty-range back-off, an off-by-one on the start, _section returning None for everything,
reading the wrong side, and dropping the column from the listing. The diff is the part of this
command a user may paste into patch, and its numbers had no test. What looked like coverage was
assert "[budget]" in out — satisfied by the diff body's own context line, whatever the listing
said. The range check is now a property test against difflib.unified_diff rather than literals,
so it cannot drift with the fixture.
MEDIUM — one message, two copies, nothing watching. cannot compare was byte-identical in
doctor.py and upgrade.py, with a docstring in the second saying "deliberately the same message
pnk doctor prints" — a convention with no gate, which is the shape F1 already is in this same
plan. It now lives in template.py beside the archive it describes, with a test that calls both
surfaces and compares.
METHOD — a scratch copy of this repo that includes .venv runs the original worktree's code.
Reported by the reviewer against its own first attempt: rsyncing the tree and running pytest in
the copy reported all 29 mutants surviving, including "always return CLEAN". uv run python3
-c resolved the copy's source correctly while pytest did not, so a spot-check of one mutant
would not have exposed it. rm -rf .venv && uv sync --frozen --all-extras in the copy is what makes
the harness honest. Together with the __pycache__ finding above, the rule is one rule: a
mutation harness must prove it can kill something before its silence means anything — run a known
mutant first, and treat a run with no kills as a broken harness rather than a clean bill.
MEDIUM — three of the first review's own fixes shipped with no test, in a commit whose message
said each had one. The second pass reverted them one at a time and the suite stayed green: the
summary line's nouns, the cannot compare: prefix on the no-template path — which docs/CLI.md
publishes as a scriptable contract, "every one of them opens with cannot compare:" — and the
conflict trailer that had just been rewritten to stop asserting a cause it cannot know. A fix
applied under review inherits the confidence of the review and none of its scrutiny. The rule
that follows: a review's fixes get the same treatment as the code they fix, mutation included.
The same pass found the wrapper's glue restore untested — dropping .replace(_GLUE, " ") printed a
private-use codepoint into the middle of run `pnk init` on the one message every KB in
existence receives, with 31 tests green.
MEDIUM — a test written to hold a fix could not fail. _TABLE was tightened to stop a
multi-line array's continuation line being reported as a table, and the test written with it
wrapped include = [ over three lines. A wrapped array of strings has continuation lines opening
with ", so both the loose and the tight pattern answered [sources] and the test passed under
either. It is now a unit test whose fixture is an array of arrays, which is the only shape that
discriminates — and the tightening turned out to have lost [[links.kb]] and [budget] # caps,
two legitimate headers, which nothing had noticed either.
MEDIUM — a new test loaded real model weights, and CI could never have told us. The test
comparing pnk doctor and pnk upgrade called the whole diagnose() on a fixture naming the real
sentence-transformers provider. On a checkout with pinakes[st] it downloads weights, takes
three seconds and dies on a FutureWarning that filterwarnings = ["error"] turns into a failure.
CI's matrix is [light], [light,pdf] and [light,pdf,claude] — it never installs [st], so
this would have been green on every run and red on the machine of anyone who had the default extra
the README recommends. Naming a provider nothing registers keeps the report offline and instant.
And the plan is a document a later increment builds from, so its errors are not cosmetic. The second pass found T3's own specification still carrying the whole-file predicate this increment had just proved wrong, and the exit-criteria block still instructing "compare bytes and the path set, not mtimes" — the inverted sentence whose implementation let this increment's named mutation survive. T4 reads that page next. Both now carry a dated correction rather than a silent edit, because the wrong version had already been built once from the words as written.
MEDIUM — the remedy named the newest archived version where the sentence means the oldest, and
one archived version is why nobody saw it. "A KB stamped from X or later is compared
automatically" printed archived[-1]. With a single archived version [-1] and [0] are the same
string, so the message read correctly on every KB that exists — and would have started telling
covered users they are not covered at the next template bump, on both surfaces, in the message
this project calls the path 100% of KBs take. A one-element collection makes two different
intentions indistinguishable, which is the same reason T1's gate leg (ii) is vacuous today and
says so out loud. The test that pins it uses the fixture that archives two.
MEDIUM — the correction broke the thing it was correcting. Pass 2's ⚠ blockquote about the placement predicate was inserted between rows 1 and 2 of the predicate table. In Markdown a table block ends at the first non-row line, so rows clean and conflict rendered as literal text inside the blockquote: the rule T4 must build appeared on the published site as a one-row table. It was caught by rendering the file, not by reading the diff — and the diff looked right, because the words were all correct and in a sensible order. A correction to a document is a change to that document and needs the same reading; the fact that it is a correction is why it gets less.
LOW — three assertions compared a payload against the enum that produced it.
payload["outcome"] == Outcome.NO_BASELINE.value is green under any rename of both sides, which is
exactly the rename that breaks a consumer's parser. Eight JSON strings lived in that gap. The
replacement writes the keys and values out as literals, once, so a wire change is a visible diff in
a test rather than a silent break in somebody's script. A test that derives its expectation from
the code under test is a tautology wearing an assertion's clothes — the same shape as the
"[budget]" in out assertion two passes earlier, which the diff body satisfied.
HIGH — three edit batches aborted partway and I reported all three as landed. The fixes were
applied by scripts that walked a list of (file, old, new) and wrote each file as they went. When
an anchor failed to match — because an earlier edit in the same run had already changed the text —
the script raised, leaving the files before the failure written and the files after it
untouched. Six edits were lost that way: the assertion pinning the oldest-archived-version fix, the
up to date branch assertions, three of the four cannot compare: prefix assertions, a
docs/KB-UPDATES.md contradiction and a docs/RETROSPECTIVES.md heading. Two of them were the
headline fixes of the commit that claimed them, and the commit message, the retro fragment and
docs/VERIFICATION.md all said they were pinned by a named test. The next pass found the test
asserted nothing of the kind.
Three rules came out of it, and the third is the one that would have caught this on its own.
- An edit batch is all-or-nothing. Resolve every anchor first, write nothing until all of them match, and name the file and the anchor when one does not.
- Never let one batch depend on text an earlier edit in the same batch produced.
- A claim that a fix is pinned is a claim about a failing test. Revert the fix and watch the suite go red, or do not write the word "pinned". Four separate fixes across three passes were described as pinned by tests that were green without them — and each was found by the next reviewer doing the revert I had not.
MEDIUM — an assertion can be true because the current wording is kind. "pnk init appears
unbroken in the output" passed with the code-span protection removed, because at this width the
remedy's spans happen not to straddle the wrap column. It was a real assertion of a real property
that simply could not fail on this input. The property belongs to the wrapper, so it is now tested
against the wrapper, with a fixture built so that plain textwrap provably splits the span —
and a second assertion that checks the fixture still has that property, because otherwise the test
degrades into the one it replaced the moment someone edits the string.
Where the review loop stopped, and what each pass cost. Five adversarial passes, finding 30 → 22 → 13 → 6 → 1. The fifth found no defect in shipped behaviour — one documentation overstatement and seven coverage gaps in code that was correct — which is the signal the loop was waiting for. What the shape of that curve says is worth more than any single finding: the first pass over an increment is not the expensive one. Passes 2 and 3 each found that a previous pass's fixes were wrong or untested, and pass 4 found the tooling that had lost them. A review that stops at one pass stops before it has reviewed anything it changed.
The classes, in the order they cost the most:
| Class | Instances | Why it survives a careful reading |
|---|---|---|
| A fix claimed as "pinned" whose test is green without it | 4 | The claim is about a failing test, and nobody runs the failure |
| A test that cannot fail on its own fixture | 3 | It asserts a real property of a real surface; only the input is wrong |
| A correction that broke what it corrected | 2 | The words are right, so the diff reads right |
| A whole-file question standing in for a per-hunk one | 1 | Both are true on the fixture that motivated the feature |
T4 — pnk upgrade --apply (20260808 05:14)¶
MEDIUM — a plan can specify a test that cannot discriminate, and the name is what hides it.
T4's plan named ::test_a_key_carried_only_by_a_conflicting_hunk_is_not_recommended as the
load-bearing operand test: the one that separates parse(base + applied) from parse(ours). It
cannot. Under the same plan's all-or-nothing rule a conflicting run refuses, writes nothing and
prints no recommendation at all — so both operand choices produce an identical observable, and the
test would have passed under the wrong implementation while reading as the proof that it was
right. The case that actually discriminates is an already applied hunk: skipped, its key
already in the file, and therefore not something this run introduced. Written as
::test_a_key_carried_only_by_a_skipped_hunk_is_not_recommended. The plan's own ground rules say
every test name in it is a prediction and not binding; what this adds is that the scenario can be
a prediction too, and a scenario nobody re-derives is a test that measures nothing under a name
that says otherwise.
MEDIUM — the consent predicate had a fourth near-miss the plan did not enumerate. D-10
requirement 2 lists three cases where a spending-cap heading must not appear — an already-applied
budget hunk, a conflicting run, and a bump touching no [budget] line — and specifies the
predicate positionally: a clean hunk falls inside [budget]. The shipped template's only real
[budget] drift (M3) rewrote three comment lines as well as two caps, so a hunk that moves
only comments is inside [budget], applies cleanly, and moves no money. Positionally it would
print a spending-cap heading with nothing under it — the fourth near-miss, and the one most likely
to recur, because comment churn is what template drift mostly is. The predicate shipped as a
clean hunk inside [budget] that changes at least one key, still structural and still with no
key-name list, plus its own negative control. The plan's requirement 2 argues for exactly this and
its three-case list is what fell short of its own reasoning.
LOW — an open correction that names a fork resolves to both, and saying which matters. The CRLF item required T4 to "either preserve each line's ending or refuse a manifest whose endings are not uniform, and say which". The answer is both, and the split is not a hedge: a uniform CRLF file is preserved, because that is an ordinary Windows manifest and rewriting it would be a change nobody asked for; a mixed file is refused, because it is already the product of two tools disagreeing and picking a winner silently rewrites lines the user never touched. The report path is unaffected either way, since reporting reads.
LOW — a refusal ordering decision that only shows up on the second run. Content refusals (a
conflict) are checked before environmental ones (a stray .orig, a held lock). The other order is
defensible until you follow it through: it tells a user to clear a backup file so that the retry
can tell them their manifest conflicts anyway. Two round-trips to deliver one permanent fact.
The mutation record, because "pinned by test X" is a claim about a failing test. Eleven mutants run 20260808 05:14; each is the code broken on purpose and the tests that noticed, and two of them are here because the first attempt measured nothing:
| Mutant | What went red | The control that had to stay green |
|---|---|---|
| remove the conflict refusal | …refuses_entirely_when_any_hunk_conflicts (+2) |
— |
write the .orig before the conflict check |
the same test, on assert not _backup(root).exists() — checked by reading the assertion that fired, not the test name |
it must not fail on byte-identity instead, or the ordering is not what is pinned |
| remove the re-parse | …unloadable_manifest_is_rolled_back, alone |
— |
| drop the rebuild-naming line | …names_the_rebuild…, alone |
…does_not_run_a_sync stayed green — that pair is the whole point of finding it |
| a key-level writer instead of the hunk applier | …the_comment_the_template_added_is_present_after_apply (+2) |
— |
| suppress the spending-cap heading | both-values ×2, ordering | …budget_hunk_is_applied_like_any_other_hunk stayed green; if it goes red the heading tests are entangled with the write |
| print the heading unconditionally | all four negative controls (+4) | — |
| print the new value only | both-values ×2, on the old value's absence | — |
| write before any printing | ordering | both-values stayed green — which is exactly why ordering needs its own test |
write requires_pinakes |
…is_never_written (+2) |
— |
| recommendation over every hunk, not the applied ones | …carried_only_by_a_skipped_hunk…, alone |
the other three requires_pinakes tests pass under the wrong operands, which is what makes that one load-bearing |
Two of the eleven were measured wrong on the first attempt, and both failures looked like
success. The syntactically-invalid mutant made pytest print ERROR, not FAILED, and a harness
counting FAILED lines read that as nothing noticed — the mutant survives: a false negative
that would have sent someone to strengthen a test that was already correct. And the .orig
ordering mutant turns twenty-two tests red, so the run "failing the right test" says nothing until
you read which assertion fired. A mutation harness needs its own controls for the same reason
the tests do.
MEDIUM — the first adversarial pass found five defects, and four of them were invisible from the tests because every one of them is a property of the write, not of the diff. T4's whole test surface was built from a plan concerned with placement, consent and refusal; none of those notice what happens to the file's inode.
| Found | Why it matters |
|---|---|
str.splitlines() breaks on \u2028, \u2029 and \x85; split("\n") does not |
the report and the writer would disagree about which lines the file has, so a hunk the report called unique could match somewhere else, or nowhere. All three are non-ascii under TOML's own comment grammar, so a manifest can carry one and still load. Refused, because rejoining on \n would silently turn it into a newline in a file the user owns |
os.replace onto a symlinked pinakes.toml destroys the link |
the real manifest is left elsewhere holding the old text, and the KB now has a regular file. sidecar.write had already learned this and resolves the same way — the lesson was in the repository and the new writer did not inherit it, which is the argument for the write helper being shared rather than re-written |
mkstemp creates 0600, so the rename narrowed the manifest's permissions |
a user who had made it group-readable loses that, silently, on an unrelated command |
| a dotted key parsed to its first segment | budget.monthly_eur = 30.00 would be announced as a key called budget — in the consent line, which is the one output that must name what it is asking about |
| the rollback wrote non-atomically | it restores the file after a failed write; doing that non-atomically reintroduces the failure mode it exists to recover from |
All four fixes were then re-mutated, and each turns exactly one test red. The fifth has no
test and is not claimed to have one: a crash mid-restore cannot be staged in-process, and a row
in docs/VERIFICATION.md says none rather than pointing at a test that would pass either way.
HIGH — the second pass found the one defect that would have printed a false spending-cap
heading, and it was a correct function used one scope too wide. Hunk.section is read out of
base by scanning backwards from the hunk's first changed line, so it is the table the hunk
starts in. That is right for the placement listing, which labels a region. It is wrong for
attributing a key, because a hunk that adds a whole new table carries keys belonging to a table
base does not contain — and every one of them was credited to the preceding table. A template
adding a table directly after [budget] would have had each of its keys announced under a
spending cap changes, and one added after [chunking] would have invented an index rebuild.
Two things are worth keeping about how it was missed. It is not a bug in _section; it is a
value being reused at a scope where its definition no longer holds, which no test of _section
could ever catch. And every consent test passed, because each fixture's hunk stays inside one
table — the negative controls guard against a heading printed when nothing moved, and this prints
a heading naming the wrong thing, which is a different failure they were never shaped to see.
LOW — two refusals in splices were unreachable from every fixture, so they shipped unseen.
One is unreachable by construction (_placement established uniqueness over the same text moments
earlier) and the other needs a manifest repeating a region in a shape difflib still calls two
hunks. The function is public now, for the reason fill and restamp are, and both guards have a
test that reverting them turns red — including a control on the overlap guard, since a refusal
that fires on everything passes the same assertion as one that fires correctly.
MEDIUM — and committed in this increment, in the file that documents the mistake. Pass 2 was
committed with ./check.sh 2>&1 | tail -2 && git commit. In a pipeline the shell reports the
last command's status, so tail succeeding made a failing ruff invisible and the commit
landed over a red gate. check.sh's own header comment describes this exact shape as the reason
the script exists — "uv run pyright | tail -1 && git commit reports the tail exit status, so a
failing checker looks green" — and it was reproduced by a reader of that comment, one line below
it, while running that script. A gate is only a gate when its status is the one the next command
reads: write ./check.sh > log 2>&1; echo $?, never ./check.sh | tail.
LOW — pass 3, and both findings are the first two fixes not being finished. Writing through a
symlink (pass 1) puts the backup beside the file it backs up, in another directory — and the output
still printed its bare filename, which is true of every ordinary KB and misleading in the one case
where finding the file takes work. A fix that changes where something lands has to be followed into
whatever names it. And docs/CLI.md's refusal row still listed five causes when the code had
eight, because passes 1 and 2 each added a refusal and neither went back to the table that
enumerates them.
The same-manifest gap is stated rather than closed, deliberately. A version bump that leaves
the manifest byte-identical produces no hunks, so --apply writes nothing — the [kb] template
restamp included — and that KB keeps reporting drift with no way to record the new reference.
T4 specifies --apply in terms of hunks and there are none, so writing anyway would be behaviour
the plan does not describe. It is a test asserting today's behaviour, a row in
docs/VERIFICATION.md, and an item proposed for plans/20260731_1202-open-corrections.md — not a
decision taken quietly inside an increment.
MEDIUM — pass 4 found nothing in the code and three falsehoods in documents the increment never
opened. docs/README.md still said "what is still a proposal is --apply"; docs/KB-UPDATES.md
§1 said adoption "is still deferred" and its drift-axis table said "Adopting it is still
absent: nothing writes the change into a user's manifest". All three were true when written and
false the moment T4 compiled, and none is in any file T4 touched — which is exactly why the
audit-the-neighbourhood convention exists and why a diff-scoped review cannot satisfy it. The
search that found them was two greps over docs/ for the claim being falsified ("writes
nothing", "--apply" near planned/deferred/absent), not a re-read of the change.
And the counterpart worth stating: docs/ROADMAP.md's ## 0.19.0 section still says "It writes
nothing" and was left alone. That section is a record of what 0.19.0 shipped, and it was true of
0.19.0. A release-notes entry rewritten to match a later release stops being a record. The line
that does need the sweep is the page's own "Where things stand right now", which is
current-state and is the release's job, not the increment's.
Every documented exit code was then run rather than read — report 0, --apply 0, a
conflicting report 0, a conflicting --apply 1, cannot compare under --apply 3 against
the shipped notes, and an up-to-date --apply 0. Six rows, six commands, matching the table in
docs/CLI.md.
Pass 5 found no new defect and is recorded anyway, because what it did is the part usually
skipped. It checked the two factual claims the increment writes into three documents and one
runtime message — that upgrade.apply is the only thing rewriting a pinakes.toml after
pnk init (init.py:88 is the other write; nothing else in src/ writes it), and that init's
.gitignore covers .pinakes/ and nothing else. Both hold. Then it mutated the seven T4 tests no
earlier mutant had killed. Every T4 test is now killed by at least one mutant — which is a
weaker claim than "every test asserts its property", and it is the strongest one a suite can
actually make about itself.
T5 — The plan asked for a decision the file next to the code had already taken (20260808 06:28)¶
HIGH — D-4 was open in the plan and settled in manifest.py, three lines below the tuple it was
about. The plan spent a four-option table, a recommendation and a paragraph of D-12 cross-reference
on "what happens to vector_tier = "sqlite-vec" before the tier exists", and framed the choice as
turning on a judgement about docs/MANIFEST.md: is its row a promise or a disclosure? Meanwhile
GRAPH_CHANNELS' docstring, at manifest.py:52 against VECTOR_TIERS at :51, already stated the
answer as a rule — "a manifest that can ask for a mode the code does not implement is a manifest
whose setting silently does nothing, and table.choice refusing the name is how a user finds that
out at load time" — and applied it to "ppr", a value in the very next row of the same
documentation table. The plan cites neither.
What that changes about reading a plan. A plan's decision table is a list of questions its author could not answer from the plan, which is not the same as questions the repository has not answered. Two of the plan's four open recommendations here were about consistency with existing behaviour, and the cheapest evidence for both was adjacent to the line being changed. The habit worth keeping: before weighing a plan's options, look at what the sibling key does — the file is often more decided than the document about it.
MEDIUM — the plan's own two halves disagreed about what T5 could deliver. It asked for
resolve_tier to be called by both sync and search, "so meta's claim and the code path
cannot disagree", and then admitted two paragraphs later, correctly, that "with exactly one real
tier there is nothing else to discriminate". Both cannot hold: if there is nothing to discriminate,
search has no dispatch to make, and a tier parameter threaded into _vector that can hold one
value behind an unreachable branch buys a shape that looks like a shared decision while being
decoration. Built the resolver with one caller and said so in its docstring. This is the eighth of
the template-release plan's own measurements or specs to be wrong, and the second found by building
rather than by reading it.
A smaller one, on honesty in a one-tier world. The first draft of resolve_tier was
return "numpy" — correct, and a function that ignores its only argument. It became
return "numpy" if tier == "auto" else tier, which reads the manifest and honours an explicit
tier. Today both arms return the same string, so no test can tell them apart; what the second form
buys is that the increment restoring "sqlite-vec" gets it honoured by that line and owes only
auto's side of the choice. Worth the branch; not worth pretending a test covers it.
T7 — pnk templates, and a template declares its files (20260808 09:54)¶
HIGH — a test with two cases in one body only holds the case that runs first.
test_a_template_file_entry_that_escapes_the_target_is_refused covered both write-side escapes:
../../evil.md and a symlinked directory in the target. It was green, and the mutation pass showed
it was green for the wrong reason. Removing the destination containment check turned it red on its
first assertion — ../../evil.md escapes the template as well as the target, so the other
layer caught it with a different message — and pytest stopped there. The symlink case, the one
only the removed check can catch, never executed under the mutation it existed to detect. Split into
two tests; both now go red, and the source-side test stays green. The general shape: when two
guards can both catch one input, a test that exercises that input proves nothing about either of
them, and a shared test body hides which one fired. This is the same family as T4's
…by_a_conflicting_hunk_… — a test that cannot discriminate — but arrived from the opposite
direction: not a case that looks identical under both implementations, but a case caught twice.
HIGH — a plan's rule sentence and its test list can specify different things, and the test list
was right. T7 states that each files entry "is validated to land inside the target KB", then
lists a test refusing "a symlinked directory inside the template tree". Those are two layers: a
symlink in the template resolves outside the template while its destination stays inside the
target, so the target rule cannot catch it and the named test could never have passed. Both are
built. The write side stops an entry writing into a directory the user never pointed Pinakes at; the
read side stops it copying something the template does not own into a KB that is then committed
and published — which for a public repo is the more expensive direction. Read a plan's test list as
part of its specification, not as an illustration of it. Where the two disagree, the test list has
been forced to be concrete and the sentence has not.
MEDIUM — a coverage row that cannot fail is worse than no row. T7's free-path deliverable asked
for main(["templates"]) and a module added to test_paid_path.py's surface list. But pnk init
already runs in the same fixture, so pinakes.template is in the import graph regardless, and
run_templates lives in pinakes.cli — no row would have failed if the new call were deleted. The
call was added and the row was not, with the reason written at the call site so the omission is not
"fixed" later. Eight of this plan's measurements were already wrong; this is the ninth and the
tenth, and both were found by trying to satisfy its own test list rather than by reading its prose.
MEDIUM — a new listing command inherits every failure mode of the thing it lists, multiplied.
template.describe raising a bare OSError on a damaged install is an open correction from T3, and
it reaches init, doctor and upgrade. Before pnk templates, that cost you the run that
named the damaged template. A listing iterates all of them, so one bad directory produced a
traceback and reported nothing about the templates that read perfectly. The general fix stays open —
it is not this increment's to settle — but the blast radius this command introduced is contained
here: the failure is caught per template, shown as a row, and the exit code is non-zero. Adding a
"list everything" surface converts a per-item defect into a whole-command defect, and that
conversion is the new increment's to own even when the defect is not.
LOW — the runtime agreed with a bug the linter caught. A missing Callable import passed the
whole test run and failed ruff. The worktree's uv environment is Python 3.14, where PEP 649
defers annotation evaluation, so an annotation naming an undefined type never evaluates. On the 3.13
this project requires, it is a NameError at import. A green test run on a newer interpreter is
not evidence about the one in requires-python — which is what the linter is for, and why
./check.sh runs it before pytest rather than after.
HIGH — a new key can defeat an existing invariant by living in the file that invariant excludes.
tools/template_drift_gate.py hashes every file in a template except template.toml, and that
exclusion is load-bearing: hashing the file that declares the version would make every bump change
the hash by construction, so "a version bumped with no content change" could never be detected. The
gate's own limit (b) recorded the cost as the description could be edited unnoticed — true, and
cheap, while a description was the only editable thing in there. T7 added files = [...] to that
same file, and files decides which files a KB is stamped with. So the key that determines a
template's stamped content landed in the one file excluded from the check that exists to stop
stamped content changing without a version bump. Closed by folding only the list into the digest:
name, version and description stay out, so leg (ii) can still fail. An absent key contributes
nothing, which is what makes the change backward-compatible — the shipped notes hash is
byte-identical before and after, so no published _versions.toml row needed migrating. When an
increment adds a key, ask which existing invariants were scoped by the assumption that the file
holding it did not matter. Nothing in T7's plan connected the two; the link is only visible from
the gate's side.
MEDIUM — git checkout -- <file> restores to the last commit, not to the state before the
mutation. The mutation pass ran fine against template.py, whose changes were already committed,
then silently destroyed the uncommitted drift-gate work when the same restore step ran against it.
The mutation's result was valid — both target tests went red, the negative control stayed green —
but the code came back as main's, and the next full run showed two failures that looked like the
fix was wrong when it was simply gone. Commit before mutating, always: the restore step is only
as precise as the thing it restores to, and a mutation harness that quietly reverts real work
reports a false failure rather than a false pass, which is the better direction but still costs a
debugging cycle.
Open corrections 1 and 3 — a guard can route a failure into a wrong message (20260810 01:29)¶
HIGH — turning a traceback into a PinakesError moved the failure into a handler that already
existed and said the opposite thing. Item 3's whole content is stop these five reads escaping as
tracebacks. Doing it made describe raise TemplateError — and doctor.py and upgrade.py both
already wrapped their describe call in except PinakesError, answering it with "is not
installed here" and a remedy about installing the template. That arm was correct while the only
thing reaching it was a template genuinely absent; a damaged install had been going straight past
it as a traceback. So the fix silently recruited a handler written for the opposite case, and a user
whose template.toml was unreadable would have been told to install a template sitting right there.
A traceback is loud and a wrong sentence is quiet, so this is a downgrade that reads as an
upgrade. Both surfaces reported WARN, both exited 3, every test stayed green — the increment's
own tests included, because they assert the new messages and the pre-existing ones assert the
absent-template case that still works. Nothing was red. It was found by asking who else catches
what this function now raises, which is a different question from does my change work.
The correction is TemplateNotInstalledError, a subclass so that every existing except
TemplateError keeps working, with _unknown as its only raiser and the two callers splitting the
arms. Both surfaces get their own test, because the wording is a fact with one home but the routing
is a decision each caller takes for itself — one test would leave the other free to merge the cases
back.
Generalises past exceptions: widening a type is an interface change on every except upstream.
The grep that finds it is not for the function being changed but for the type it starts raising,
and the question is whether each catcher's answer is still true for the new cause. PinakesError is
caught in 30-odd places here precisely because it is the type that means print this and stop, so
anything newly raised as one inherits whatever those handlers already say.
Second, smaller, and mechanical: git checkout -- <file> during a mutation pass deletes the fix
being verified. The pass ran before the commit, so restoring after each mutation restored to
main — three mutations in, both source files were back to their unfixed state and the tests were
"failing correctly" against no fix at all. The evidence was still true and the work had to be typed
again. Commit, then mutate: BUILDING.md orders the steps that way, and this is the reason
rather than a convention.
A release sweep is table-shaped, and a narrative is not a row — 20260811 12:18 UTC¶
What happened. With every plan built out and the open-corrections list empty, the first check of
the next session was what does the repo say about itself. docs/ROADMAP.md said 0.21.0. Its
release table carried a 0.22.0 row, its per-release section carried the full 0.22.0 write-up,
and its open-corrections section said none live, all four shipped in 0.22.0 — but the two prose
blocks that state the project's position, ## Where things stand right now (stamped 20260808
06:41) and ## The template release, were three releases behind. One of them still said "T4 and
T7 are still to come" about increments that had shipped on 20260808 and 20260808.
Why it survived five sweeps. The landing checklist asks which file to edit, and the file was edited — every time. What a sweep naturally finds is the row it is adding: a table has one line per release, so the release being cut points straight at its own row. A paragraph that summarises all releases has no row to add, so nothing in the act of cutting a release makes it obvious. The per-release sections and the tables were correct at every commit; only the summaries were wrong — which is the worst arrangement, because a reader checking one against the other finds agreement in five places out of six.
The second instance is sharper. docs/README.md's plan-routing table — the table whose whole
job is to tell a session which plan is live — had no row at all for
plans/20260811_0720-decisions-gates-and-corrections.md, the plan CLAUDE.md names as the live
build order and the authority for eight decisions. The plan was written, its six increments were
built and landed, and the index of plans never learned it existed. A missing row is invisible to
every check that reads rows; only asking "is everything that exists listed here?" finds it, and
that question is not part of landing an increment.
What generalises. "Update the doc" and "update the doc's summary of itself" are different actions, and only the first is prompted by the work. A document that both enumerates and summarises will drift at the summary, in the direction of the last release that bothered to rewrite prose. Two checks are worth adding to a landing:
- Grep the docs for the previous version number after a release, not just for the fields the
checklist names.
grep -rn "0\.21\.0" docs/would have found both ROADMAP blocks in seconds. - Ask what is missing, not only what is wrong. The routing table's defect had no wrong text to
find. Reading
ls plans/against the table is a ten-second check that no diff review performs, because nothing in a diff is absent.
And the check found more than it was looking for. Auditing the neighbourhood surfaced that the
20260807 audit's 40 documentation corrections have never been worked — the file has one commit,
the one that created it — and that the same audit deferred a full review of docs/ROADMAP.md until
after T2, which shipped in 0.18.0. Neither is visible from any release's own sweep either.
A row can be complete, correct, and in the wrong place (20260811 13:27)¶
HIGH — five release rows were out of order in three sequences, and nothing could see it.
docs/ROADMAP.md's release table and its per-release sections both read
0.20.0, 0.22.0, 0.22.1, 0.21.1, 0.21.0, 0.20.1; docs/STATUS.md's roadmap table put 0.15.1
after 0.16.0 and 0.20.1 after 0.22.1. Every misplacement is wrong on both readings —
SemVer and release time — so no ordering convention made them right. CHANGELOG.md was checked
and is clean, headings and link definitions both.
Ordering is a property of the sequence, not of any row in it. That is why nothing caught it:
the tables are complete, every anchor link resolves, mkdocs build --strict is green, and a reader
checking any single row finds it correct. Every check this project owns reads rows.
How it happened, recovered from the six release commits. 0.20.1 was appended correctly
(2da0e07). 0.21.0 (96b3b35) then inserted its section one position too early — after
0.20.0's rather than after 0.20.1's — and the next three sweeps (c83e877, df832fe,
93c20ab) each used that same slot.
| Commit | Release | Section tail after it |
|---|---|---|
d429a2c |
0.20.0 | … 0.19.0 0.20.0 |
2da0e07 |
0.20.1 | … 0.20.0 0.20.1 ✅ |
96b3b35 |
0.21.0 | … 0.20.0 0.21.0 0.20.1 ← the first error |
c83e877 |
0.21.1 | … 0.20.0 0.21.1 0.21.0 0.20.1 |
df832fe |
0.22.0 | … 0.20.0 0.22.0 0.21.1 0.21.0 0.20.1 |
93c20ab |
0.22.1 | … 0.20.0 0.22.1 0.22.0 0.21.1 0.21.0 0.20.1 |
The tail was locally self-consistent at every step, which is the whole lesson. After the first error it read strictly newest-first, so each following sweep saw a coherent pattern around its own edit and matched it — reproducing the error is what reading the neighbourhood carefully produced here. Only the join between the ascending head and the descending tail was wrong, and that join is a single line no sweep's diff ever touched. Read the sequence, not the neighbourhood.
It had already been found and left. The 20260807 documentation audit's finding
docs/STATUS.md:303 names the 0.15.1 row exactly, with PyPI upload times as evidence. It sat
unworked for four days, and in that window three more sweeps added three more misplaced rows. A
verified finding nobody schedules is worth what an unverified one is.
The fix is a gate, not a checklist item — tools/release_order_gate.py, in check.sh and CI,
on the threshold this project already applies (status_header_gate.py: a checklist missed it four
times). Two design points are the reason it will still work in a year:
- Direction is declared per sequence, never inferred. Inferring it from whichever way most adjacent pairs agree would let a badly scrambled file elect its own answer and pass.
- A sequence shorter than a floor is a failure, not a pass. An empty sequence is sorted by definition, so a pattern that silently stops matching — a reformatted table, a changed heading style — is exactly how this class of gate dies quietly. A test asserts the real documents clear that floor, so a reformat goes red in the commit that does it.
Watched failing before it was trusted: run against the pre-fix tree it names all nine misorderings and exits 1; CI scrambles a copy and asserts both the exit and the stated reason.
E1 — pnk ask, and the output nobody could check (20260811 15:17)¶
MEDIUM — a doc that pastes command output goes stale silently, and nothing in this repo can see
it. docs/GUIDE.md § Searching showed a pnk search result whose last two lines were the
escalation notice — Paid synthesis (pnk ask --deep) is planned for the deep release. E1 changed
that sentence in cli.py, and the pasted block kept printing the old one. It would have shipped:
./check.sh is green on it, mkdocs build --strict is green on it, and every link in the file
resolves. The conventions already say verify a doc by running the commands it shows
(docs/README.md) —
this is the first recorded instance of the rule
catching something, and it caught it because the increment happened to edit the very sentence, not
because anything checked.
What makes this class different from a stale sentence. Prose describing behaviour is written by
someone who read the code; pasted output is a measurement, and a measurement has no author to
notice it aged. There are three such blocks in GUIDE.md and two in CLI.md. Nothing pairs any of
them with the code that produces it.
Not turned into a gate here, and the reason is worth recording. A gate would have to run each
documented command against a fixture KB and diff the output — which is a golden-output test on
formatting, the most brittle kind, and it would fail on every deliberate wording change with no way
to tell those from regressions. What is cheap and was done instead: both new blocks in this
increment were pasted from a real run (tests/demo-kb for the calibrated one,
tests/partner-kb for the uncalibrated one), so at least the version now on main was true once,
with the KB that produced it named beside it.
LOW — a test that can only fail in a later increment must say so in its own docstring.
test_a_confident_kb_gets_cited_evidence_and_the_price_of_one_call asserts no ledger.jsonl exists
after pnk ask. Nothing in this build can spend, so the assertion cannot fail today; it is a
tripwire for E4, which adds a paid loop to this same command through the shared _retrieve. Left
unexplained it reads as dead weight and the next reader deletes it — one increment before the one
it was laid for.
LOW — the vocabulary was spelled twice in one file. run_search compared confidence against
the string literals ("low", "unknown") while _escalation, forty lines below, compared against
search.py's LOW/HIGH/MEDIUM constants. Both correct today. The renderer also asked
if escalation.branch != "none" to decide whether to name the deep release, putting the branch
vocabulary in a third place — and the decision that actually mattered (never offer to answer a
question nothing matched) lived in none of them. Both fixed: the constants throughout, and the
notice carried on the value rather than re-derived at the print site.
E2 — The deep release's round estimator (20260811 16:15)¶
HIGH — the worst case left out the one input the user controls: the question itself. The plan's
formula prices carried memory, final_k passages and a prompt constant, and the first draft
implemented exactly that, with the question folded into PROMPT_TOKENS. But a question arrives as
an argv string — pnk ask "<question>" — with no length limit anywhere in the CLI, and it is
carried into every call of a run, so a 50,000-token question would have been reserved for at
1,500 tokens. Fixed by pricing it separately (QUESTION_TOKENS) against a stated ceiling
(QUESTION_CHAR_CEILING) that E4 must enforce, since this module refuses nothing but a stale price
table and an oversized request. Lesson: when a worked example pins an estimate to the cent, it
also silently fixes the size of every input the example did not vary. The question was the one term
with no bound anywhere in the system, and the arithmetic looked right because the example never
made it big. Ask what is in a call's input that the formula does not name — not whether the formula
was implemented correctly.
HIGH — the second review pass found a ceiling below its own measurement, in a module written to
refuse exactly that. PASSAGE_ENVELOPE_TOKENS carried the comment "the longest path —
heading_path pair is under 120 characters", asserted without running anything, and 100 tokens was
sized from it. Running it — by extending tools/measure_passage_tokens.py to report the envelope
as well as the chunk — returned 220 characters, about 110 vendor tokens at the pessimistic
conversion the module's other constants use. The constant was under-reserving against a number
nobody had taken; it is now 250, with the measurement and its date beside it. Lesson: the review
question that found it was "which of these numbers did I measure, and which did I merely write?" —
asked of a file whose every other constant carries a command. A measured neighbour makes an
asserted one look measured too, and prose like "measured from the corpora above" is how the two
become indistinguishable.
MEDIUM — a total divided by its call count and multiplied back was not the total. Decimal
division is exact to 28 significant digits and no further, so per_call_eur * calls landed one
digit above total_eur at the shipped defaults — meaning, in the other direction, a per-call
reservation could sum to less than the operation it belongs to. Caught by a test asserting the two
were equal. Fixed by deriving the total from the per-call price rather than the per-call price
from the total, which removes the class rather than the instance: no set of constants can
reintroduce it. Lesson: Decimal fixes base-10 representation, not associativity — money code
should multiply a unit price up, never divide a total down, and the direction of the last digit is
chosen by nothing.
MEDIUM — a "measured" docstring falsified by the fix in its own increment. The docstring above cited the two 28-digit values it was measured at; the fix changed the arithmetic that produced them, so re-running the same comparison now round-trips exactly and the cited numbers describe code that no longer exists. Rewritten to say what was observed and when it stopped being reproducible, rather than left reading as a claim about the shipped module. Lesson: the measurement most likely to go stale in a docstring is the one that motivated the change in the same diff — grep your own increment's fixed defects for the numbers that justified them.
LOW — a purity test written as a denylist, in a repository that had already learned not to. The
first version asserted deep/estimate.py imports no anthropic, sqlite3, httpx, requests —
a list that is never finished, and whose next omission is always the one that matters.
check.sh's NUL scan records the same lesson about file suffixes ("a denylist of binary formats is
never finished"), one file away. Rewritten as an allowlist of what the module may import, so a new
import has to be argued for in the test. Lesson: for "this module stays pure", enumerate what is
allowed. The repository's existing gates are worth reading for the shape of the assertion, not only
for the rule.
A finding handed to E4 rather than solved here. At the shipped defaults (final_k = 8,
[chunking] max_tokens = 510, [budget] per_operation_eur = 0.30) the cheap branch prices at
EUR 0.2627 and fits inside the cap; a five-round loop prices at EUR 2.81 and is 9.4x it. So on a
stock KB, pnk ask --deep would answer a confident question and refuse an uncalibrated one at
round 0 — which is precisely the combination D-22 option E was chosen to avoid, arrived at through
the caps instead of through the signal. E2 declines to fix it by lowering a ceiling: that is the
trade PAGE_TOKEN_CEILING refused, and the numbers are conservative by design until E6 measures
them. It is pinned as
tests/test_deep_estimate.py::test_the_shipped_defaults_leave_the_loop_outside_the_default_operation_cap,
reading both sides out of the manifest defaults so it tracks them rather than restating them.
E3 — The deep client, and four rules that were about to be copied (20260811 17:12)¶
HIGH — a second paid entry point is where a "one home" rule quietly becomes two. E3's section of
the plan says the key is PINAKES_ANTHROPIC_API_KEY and never an ANTHROPIC_API_KEY fallback,
"enforced the same way here" — a sentence that reads equally as reuse it and as write it again.
Writing it again would have been the smaller diff, and it would have produced two copies of four
rules that each fail silently when they drift: the key's name, max_retries=0, whether a failed
call billed, and how a reconciliation is computed. CLAUDE.md calls an ANTHROPIC_API_KEY fallback
"the same defect, one layer apart"; a second copy of the rule forbidding it is the same defect one
file apart. So src/pinakes/paid.py exists, holding all four with no client in it, and
extract/claude.py now imports what it used to declare. What made the move safe rather than brave
was that its 1,606-line suite is unchanged: the only test that failed was the allowlist count, which
is the gate refusing the widening until it was declared.
The module is deliberately not allowlisted, and that is the interesting part: classify is
handed the caller's already-imported SDK module rather than importing one, so gate 2 scans it like
any other file and would refuse an import anthropic added to it. The allowlist stays two entries —
extract/claude.py and deep/client.py — which is the complete list DESIGN §1 describes.
HIGH — the shared classifier had no direct test, and it decides void versus unknown outcome.
Moving it surfaced that every branch of _classify had only ever been reached through a fixture
that raised an already classified error: the replayer constructs TransportError itself, so the
mapping from an SDK exception to a billability had never been executed by a test in either suite.
The relationship it turns on is the one stubs/anthropic.pyi warns is easy to get wrong from
memory — APITimeoutError is a subclass of APIConnectionError, so checking the parent first
classifies every timeout as not-billed and voids a reservation for a call that may have been
charged. It now has five tests against a fake SDK whose hierarchy is held against the stub's, and
against the real package where it happens to be installed.
MEDIUM — the renderer was spending 90% of a ceiling on an identifier the model never emits. The
first draft of render_passages mirrored cli.py's printed block exactly, citation line included —
which carries the path and the heading a second time. E2 measured PASSAGE_ENVELOPE_TOKENS
against one copy (220 characters, the widest real passage in the corpora) and set it at 250 with
2.3x headroom; two copies came to 452 characters, 226 of the 250 reserved tokens, leaving
nothing for a KB whose headings run deeper than the two corpora measured. Found by writing the test
that pins the arithmetic, not by reading the code. The fix was to drop the citation line rather than
raise the constant — E2's own rule, and PAGE_TOKEN_CEILING's: a ceiling that close to its own
measurement is not a ceiling. The test now asserts the envelope stays under half the constant,
because "inside it" was true of the draft too.
MEDIUM — three smaller findings from the same pass, each a disagreement between two halves of one
bound. The subproblem cap was computed twice, once for the request's schema and once for the
parser that re-checks the response — the shape where the second check silently becomes about a
different limit than the first, at which point one of them is decoration (now subproblem_cap, one
function, driven through both users by one test). answer_schema wrote max(passages, 1), so a
call over zero passages would have declared citation [1] legal to the API and had every index
refused by the parser — prose with nothing behind it, paid for; both halves now refuse
(NoEvidenceError), which is E2's reason for not pricing the none branch, arriving one layer
down. And billed_call voided on any exception the transport did not classify — but a defect is
not proof the call never billed, and proof is what INVARIANTS requires before a void, so an
unclassified failure is now left unresolved instead.
LOW — a test that asserted its own base class. The first version of "every error carries a
remedy" checked issubclass(..., PinakesError), which is true by construction and would have passed
for a subclass that omitted the keyword entirely. It reads the super().__init__ call out of the
parsed source now. Worth recording because it is the third time in this repo a safety check has been
written in the shape that cannot fail; the tell each time was that it passed the moment it was
written.
What E4 inherits from this increment, beyond what the plan already names: the three bounds E2
prices against are enforced in the client — the question ceiling, the carried-memory ceiling
(CARRIED_MEMORY_CHAR_CEILING, added beside the token constant it derives from), and final_k
passages per answering call — and each refuses rather than trimming. E4 should refuse earlier
and more kindly, with a sentence about the command; what it must not do is assume the client will
quietly cope. Citations arrive as passage numbers, so E4 owns the mapping back to documents, and
E7's rule that a suggestion's endpoints must be documents this run retrieved is already a property
of the wire format rather than a check to add.
Second review pass — the knob nobody would have passed, and a version nothing checked. Two more, both found by reading the module as a stranger would rather than as its author:
max_tokenswas a parameter on both request builders, defaulting to the estimator's constant. Harmless as written and a hole by construction: output bills at five times the input rate and is two thirds of a round's whole price, so a caller passing a larger ceiling is billed for output no reservation covered — the same hole the question, memory and passage bounds had just been closed for, sitting one line above them. It is gone;_requestalways sendsMAX_TOKENS. Unlike the extractor, this client never re-asks a truncation at a raised ceiling, so nothing wanted it.PROMPT_VERSIONandSCHEMA_VERSIONwere decoration. They existed for E5's transcript and E6's measurement, and nothing read them — which is exactly T1's failure with a template version:pnk doctorcompared a version against the installed one for ten releases while the files that version denoted changed underneath it, and every KB recorded a reference that matched and meant something different. So the prompts and both schemas are now hashed and pinned to their version, and a reworded prompt fails the suite until the digest and the version move with it. E6's numbers are about a specific wording; without this, the record of which wording would be a guess.
The pass also promoted DeepCallFailedError's three kinds to constants, for the reason E1 gives for
carrying its escalation value rather than re-deriving it: E4 has to tell a refusal from a truncation
in order to label a run, and a caller matching a string literal puts the vocabulary in two places.
And a note on the mutation testing itself. The first attempt at mutating max_tokens back into
_request reported "54 passed" and I nearly recorded it as a test gap — the edit had silently
matched nothing, because ruff format had collapsed the signature onto one line after the change.
A str.replace that matches nothing returns the string unchanged and tells no one, which is the
defect conftest._rewrite exists to prevent, reappearing in a throwaway script. Every mutation in
this increment now asserts its target matched exactly once before running the suite; the retried
mutation failed the right test immediately.
Third review pass — two findings, both about what a reader would look for and not find. The
shared-rule tests had landed inside tests/test_deep_client.py, so src/pinakes/paid.py — a module
both paid entry points depend on — had no test file bearing its name, and the nearest match,
tests/test_paid_path.py, is about something else entirely (which modules may import a client, not
what they must then do). Split into tests/test_paid.py, with each file's docstring saying which
question it answers, and VERIFICATION gains a section of its own for the rules rather than filing
them under the client that happens to use them.
The second: AnthropicTransport.max_retries was a property nothing asserted. build_client_kwargs()
== {"max_retries": 0} is tested, and says nothing about this transport passing it — the same gap
in a smaller frame as the classifier's. Constructing the client is offline, so the test is not
paid-marked; it skips with a reason where [claude] is absent.
Fourth review pass — the sentence this increment falsified was in the file it edited.
extract/claude.py's first line read "the only module in src/ permitted to import anthropic",
and AnthropicTransport's docstring said "the only place anthropic is constructed". Both were
true when written, both became false in the same commit that added the second entry to the
allowlist, and neither is reachable by grepping for what changed — the words deep, client and
allowlist do not appear in either sentence. Found by grepping for the claim shape ("only
module", "the only place") rather than for the diff, which is the same move that turned up
docs/ROADMAP.md:139 at E2's handover: a stale pointer has no wrong text to search for, only a
claim that is no longer true.
A Ctrl-C voided a call that may have billed — in both paid clients, for as long as either existed¶
Found by working an exit criterion nobody had tested. E4's plan asks that "interrupting
mid-loop leaves a reservation pnk budget reports as unknown outcome, never a lost record".
Nothing asserted it, so it was probed with a transport that raises KeyboardInterrupt, and the
ledger came back voided — EUR 0 recorded for a request that had already been sent.
Why it was invisible. Every deliberate branch was right. billed_call classifies a timeout as
billable-unknown, voids a 429 that never billed, and — in the deep client — catches Exception for
anything unclassified and leaves it unresolved. A KeyboardInterrupt is not an Exception, so it
fell past all of it into ledger.paid_call's finally, whose job is to close an unfinished call
and whose default is to void. Every layer behaved as written. The extractor was worse and had
been since I7b: no catch-all at all, so an ordinary defect voided too.
Three things worth keeping:
- The likely interrupt is the one nobody models. A paid run is slow, visible and cancellable; Ctrl-C during one is the normal way it ends when a user changes their mind. It was the only exit path with no test.
- A safe default one layer down is not a safe default.
close_unfinishedvoids because most unclosed calls never billed. That is correct there and wrong here, and the caller is the only place that knows which.except BaseExceptionis what says so. - The sibling had it too, and fixing one would have been the defect surviving. One invariant,
two call sites, two identical clauses — so both moved in the same change, and both tests raise a
BaseExceptionrather than aRuntimeError, because a narrower one passes against broken code.
The same GUIDE block quoted a retired sentence twice — so the second time it became a gate¶
E1 rewrote pnk search's escalation notice and left docs/GUIDE.md displaying the sentence it had
just replaced. Its retrospective recorded that nothing in the repo could have caught it. E4
rewrote the same sentence and left the same block stale again, caught only because someone grepped
for the old wording before shipping.
Why every existing check is blind to it. The prose is well-formed. Every link resolves.
mkdocs build --strict is green. tests/test_verification.py checks that named tests exist, not
that quoted output is current. A fenced block showing a previous build's output is correct
Markdown describing a program that no longer exists, and nothing in this repo reads it as anything
else.
The checkable half is the negative one. Diffing a fenced block against real output would need
the command, its models and a corpus. "Every printed constant must appear in the docs" is simply
false — most should not. But a sentence this build can no longer print must appear nowhere, and
that is a grep. tests/test_docs_quote_the_shipped_sentences.py holds the retirement list, one row
per retired sentence with what replaced it; retiring a sentence is a deliberate act, so adding the
row is part of it.
Two things that make the gate honest rather than decorative. It searches src/ as well as
docs/, because a retired sentence surviving in a docstring is the same defect one layer in — and
this project's docstrings are where its reasoning lives. And it was run against the pre-E4 tree,
where all four rows fail: a gate never observed failing is a gate nobody has tested.
E5 — The run transcript (20260812 05:11)¶
MEDIUM — a mutation restored a file and silently took a real edit with it. The mutation pass
works by editing a source file, running the suite, then git checkout <file>. Between the first
commit and mutation 5 there was exactly one source edit — a comment in cli.py correcting a claim
about clear_cache_paid — and git checkout src/pinakes/cli.py reverted it along with the
mutation. Nothing failed: the tests pass either way, ./check.sh is green, and the reverted text
was a comment, so no gate could see it. It was found by reading the increment's own diff in the
adversarial pass and noticing the comment said what the earlier draft said.
The lesson is narrow and mechanical: git checkout restores to the last commit, not to the state
before the mutation. Either commit before mutating, or restore with git stash/a copy. This
increment did the former for the code and got caught by the one edit that landed in between.
And it happened a second time, two passes later — git checkout tests/free_path_run.py, run to
undo a deliberate deletion that was proving a gate row discriminates, reverted the uncommitted
addition of that same call. Knowing the trap was not enough to avoid it; the only thing that caught
it the second time was checking for it immediately afterwards, because it was now expected. Write
the check, not the resolution: after any git checkout <file> in a mutation loop, grep for the
thing that was supposed to survive.
MEDIUM — a test that asserted the wrong half of its own claim. "The temp file is .tmp, not
.json, so a killed write leaves nothing the readers count" was tested by planting a
.tmp-abcdef.tmp file and checking the glob ignored it. That proves the glob ignores .tmp files.
It says nothing about what the writer names its temporaries, and it kept passing when the suffix was
mutated to .json. It now spies on os.replace, whose source argument is the file a kill one
instruction earlier would have left behind, and asserts on that name.
Generalisable: when a test plants the input it then checks, ask which half of the claim it actually reaches. The planted value came from the test's understanding of the code rather than from the code, so the two could disagree without the test noticing — which is the whole failure mode the mutation pass exists to find, and it found it.
LOW — the confirm-then-re-call path had never been tested, on either store.
sys.stdin.isatty() is False under pytest, so every --clear-cache test since I4 took the
--yes route straight past the prompt, the y/n branch and the second sync() call. Three tests
now walk it. Worth recording because the gap was invisible in the ordinary way: the flag had
coverage, the interactive flag did not, and no coverage number distinguishes them.
A decision worth writing down: --clear-cache=transcripts names a store, and the two values
before it name authorisations. --clear-cache and --clear-cache=paid both clear the whole
extraction cache and differ only in what they permit — a documented distinction, with a comment in
cli.py explaining why the bare form is not called =free. Layering transcripts onto that axis
would have meant --clear-cache=transcripts also emptying the extraction cache, which destroys more
than the flag names. Mixing the two axes in one flag is a real cost, and it was paid deliberately
rather than by accident: D-26 asked for a --clear-cache target, and a target is what it is.
What E6 inherits. The transcript is the record the measurement run reports out of: it carries
call_ids, the estimate and the reconciled spend per run, so the over-reservation factor E6 must
publish can be computed from the files a measured run leaves behind rather than from a spreadsheet
kept beside it. transcript.call_ids() plus sync.ledger_spend() is the join, already written for
the confirmation prompt.
E6 — A seam the tests never crossed (20260812 06:41)¶
CRITICAL — pnk ask --deep has never worked against the live API, and 0.22.0 through 0.25.0 all
ship it. The first real --deep call E6 made returned a 400 before it billed anything:
output_config.format.schema: For 'integer' type, properties maximum, minimum are not supported
deep/client.py: answer_schema emits {"type": "integer", "minimum": 1, "maximum": passages} on
every answer call, and structured outputs does not accept numerical constraints. A second probe
found subproblems_schema's maxItems is refused the same way, so the decompose call was broken
too — fixing the answer call alone would not have produced a working loop.
The lesson is about the seam, not the schema. E3 introduced Transport so the whole loop could
be driven from recorded fixtures with anthropic absent, and E4 and E5 were both tested that way,
green throughout. That is a good seam and it bought real things. What it also did was guarantee that
no test in the suite has ever sent a schema to the API — the one field the API validates and the
fixtures cannot. A fixture asserts what we believe a response looks like; it cannot assert that the
request was acceptable. Every layer above the seam was correct, which is exactly why nothing went
red: the defect lived in the only inch of the path the seam removes.
The generalisation: a seam introduced for testability defines a region the tests cannot reach, and that region needs its own gate. For this one the gate is cheap and fixture-free — assert the two schema builders emit no keyword on structured outputs' documented unsupported list. It would have failed at E4, on a branch that was green.
Three things behaved exactly as designed and are worth recording, because a bad failure is where
you find out. The accountant reserved, refused and voided: the failed call billed €0.0000, so a
run of 400s cannot consume a budget. The error surfaced the API's own sentence rather than a
paraphrase, which is what made the cause obvious in one read. And the blast radius was genuinely
bounded to the one path — pnk search, pnk ask without --deep, and the paid extractor were all
untouched, because none of them shares this client.
Running the instrument found two bugs in the instrument. tools/deep_reservation.py count
filtered its token-count payload to model/system/messages, dropping output_config — so it
measured PROMPT_TOKENS with the schema excluded, a fraction of the real figure. And
QUESTION_TOKENS differenced an arbitrary 200-word probe, so it returned ~200 by construction: it
measured the probe, not the question ceiling. Both would have published a wrong constant with no
symptom. A measurement probe is code and earns the same adversarial pass as anything else — two
of five constants were measured wrong on the first run, and reading the script is what found
neither; running it is.
E6 — The measurement run (20260821 07:53)¶
HIGH — the instrument that publishes the numbers had no tests, and its --json had never
run. vars() on a slots=True dataclass raises TypeError, so tools/deep_reservation.py
count --json and report --json both failed on their first row, in every version that ever
existed. It survived because every measurement session read the printed table instead. The
general shape is worth keeping: a tool written to be run by hand has exactly one exercised path
— the one the author happened to type — and the flag nobody typed is not a lesser-tested path,
it is an untested one. The fix is cheap; noticing was the whole cost, and what noticed was sitting
down to write the tests rather than any run of the tool.
HIGH — the published factor was not reproducible, and nothing said so. The handover recorded
19.0× on synthesis and 16.5× on the loop. Those came from measurement KBs under /tmp, which the
operating system reaped after nine days, taking every transcript and every ledger row with them.
The money was spent and the numbers were real, but by the time anyone re-ran report the evidence
for them was gone — and report cheerfully printed a different factor over the surviving
records without any indication that it was answering a smaller question. A measurement whose
substrate lives in /tmp has a shelf life, and the number outlived it. What made this
recoverable rather than merely lost is that the runbook's rebuild step is free: the KBs come from
tests/demo-kb and tests/partner-kb, so the run could simply be done again.
HIGH — the runbook's own step (c) measures the wrong branch. It names three no-answer
questions to exercise the loop. On the calibrated KB one of them — "Which software does the
catalogue run on?" — scores medium, which takes the cheap branch: running the runbook as
written buys a synthesis call and records it as a loop measurement. The free pre-flight the same
document prescribes is what caught it, one paragraph after the document tells you to run it. Step
(b) already warns that "a 2 means the branch was mis-selected and the run measured the wrong
thing"; the inverse case had no such check, and the questions were never re-verified after the
thresholds were fitted.
HIGH — and the reason step (c) chose those questions is itself false. It argues that
no-answer questions are the right instrument because "nothing in the corpus answers them, so the
sufficiency gate cannot stop the run early and it goes to the round cap — which is the worst case
the reservation was sized for, and therefore the only case that measures it." Measured: both
decomposition runs stopped at sufficiency, after 2 rounds and after 1 round of 3. A
sufficiency gate reading a calibrated signal is perfectly willing to conclude that enough has been
established about a question the corpus cannot answer. So on a calibrated KB the loop's worst case
is not reachable by choosing a hard question, and the branch that actually reaches the round cap
is unknown, on a KB with no thresholds at all — the branch the runbook does not mention.
MEDIUM — the more calibrated the KB, the more over-reserved its loop is. The three branches
came out 29.75× (synthesis), 50.92× (calibrated loop) and 22.35× (uncalibrated loop). The
ordering is not noise and it is not a defect: a reservation must cover max_rounds, and
calibration is exactly what lets a run stop before it gets there. The uncalibrated branch is the
least over-reserved because it has no early stop and spends the rounds it reserved. Reporting a
single blended figure would have hidden this entirely, which is the argument D-28 made before any
of it was measured.
MEDIUM — MAX_TOKENS carries most of the over-reservation, and is the constant least safe to
lower. 8,000 reserved against a widest-observed 660 across 22 reconciled calls (mean 241) —
12.12×, against 1.50× to 8.93× for the five input constants. It dominates because output bills at
five times input and is two thirds of a round's price under the shipped defaults — and four
fifths at the narrower geometry these runs actually used. It is also the only one of the six that is
a truncation rather than a bill: an input ceiling set too low over-reserves, while an output
ceiling set too low cuts an answer off mid-sentence. The temptation to lower the one constant that
would visibly improve the headline figure is therefore precisely inverted from where it is safe to
do so, which is worth stating plainly next to a 12× ratio.
LOW — the [budget] block the runbook tells you to append is now two-thirds the shipped
default. It prescribes per_operation_eur = 2.00 and daily_eur = 6.00; D-30 raised the
defaults to exactly those values in 0.24.0. Both measurement KBs ran the whole plan with no
[budget] section at all. Harmless, but it is a hand-editing step the document still asks for and
no longer needs — and the two keys that do still differ (confirm_above_eur, monthly_eur) are
the two it does not explain.
LOW — the refusal path had never been run, and works exactly as specified. Step (d),
untouched through two measurement sessions. With per_operation_eur = 1.00 against the loop
branch's €1.38 estimate: refused before the first call, exit 1, no ledger row (22 lines before
and after) and no transcript (7 files before and after), with a message naming the cap, the
headroom, the branch, the call and round count, the complete manifest edit that would admit the
run, and the two cheaper routes before raising a cap. D-23 and E5's "a run that never returned
writes none" both hold.
A prose tool pointed at a comment run containing load-bearing whitespace (20260821 22:48)¶
Found by the E6-close adversarial pass, after ./check.sh was green — twice. While annotating
estimate.py's constants, textwrap was used to reflow the affected comment runs to fix
line-length errors, and it flattened an indented \-continued shell command into
... \ tests/demo-kb/docs on one line — a literal backslash argument for anyone who pastes it.
Caught before the tag, fixed in 4d5debf.
The class, and why no gate can see it: the result is legal, correctly-lengthed comment text, so
ruff accepts it; it is a comment, so pyright never reads it; and a diff review reads the new
prose rather than the old command. No assertion anywhere could have failed — which makes it a
clean example of what the mutation battery cannot reach, now noted in
docs/BUILDING.md § 4. The rule: a prose tool's output over text containing
load-bearing whitespace is re-read as the thing it is — a command, a table, an indent — never as
prose.
The sibling case, from the same day's release sweeps: a script that navigates Markdown by a
heading prefix steps over every shallower heading. 0.25.3's sweep inserted its ROADMAP section
with a startswith("## ") scan for the next section — and # Part 5 · What is not built starts
with "# ", so the scan stepped straight over the divider and the Part heading and placed a
finished release section inside the unbuilt-work part. release_order_gate could not see it: it
checks order among release sections, never which part they sit in. Diagnosed by the sweep's own
author from the script, not guessed; the other three insertions from that sweep were each anchored
immediately after a located line and are correctly placed — the one that navigated forward to
find its slot is the one that crossed a boundary it could not represent. Moved in 0.25.4. The rule
both halves of this entry share: a prose-shaped tool applied to a file whose structure carries
meaning it does not model needs its output re-read against that structure — heading level,
load-bearing whitespace, a divider — never against the text alone.
The same pass also found a shorthand ("output is two thirds of a round's price") that is correct beside the shipped defaults and wrong at the measurement KB's geometry (four fifths there); it was qualified at its five new uses only, deliberately — the pre-existing uses sit beside default-geometry arithmetic, where the shorthand is exact, and a blanket qualifier would cost every reader to protect none.
E7 — Printed suggestions, and the mutant that proved the battery blind (20260822 01:24)¶
The increment that closes the deep release. A --deep run ends by printing the links[]
entries its own citations propose. Small — one module, one CLI seam — and it turned up four defects
the tests could not see, three of them found by mutating rather than by reading.
HIGH — the control mutant survived, and it was the first one run. docs/BUILDING.md § 4 says
to kill a known-catchable mutant first, because a run with no kills is a broken harness rather than
a clean bill. This run had seven kills and lost the control: REL = "co-cited" → "related" left
all 71 tests green. Every assertion about the relation imported REL and compared it with itself,
so the constant and the expectation moved together. The shipped value of the thing a user pastes was
pinned by nothing.
The generalisation is worth more than the fix: a constant imported into both sides of an assertion
is not tested, it is restated. test_cli_ask.py's FINGERPRINT comment says exactly this one
file away — "a test that derives the expected value from the same object it checks would still pass
if fingerprint() started returning the empty string" — and this suite was written without
noticing it applied. One literal test now names ("co-cited", "deep"). Half of it was already
caught by accident: rel = "" went red, because sidecar._links refuses an entry without one. Only
the plausible wrong value survived, which is the direction that matters.
HIGH — a test that certified containment was satisfied by absence. The escaping-path test cited
../outside.md, a path with nothing at the end of it, so the read failed because the file was
missing and the assertion passed with no containment check at all. The mutation pass is what
separated them: the bypass mutant killed the neighbouring deleted-document test and left this one
green, on the same code. It now cites a real document in a second KB next door, with a sidecar
carrying the id the citation claims — so only containment can refuse it, and what it prevents is
nameable: a fragment carrying pnk://<this KB>/<that KB's document>.
MEDIUM — three direction tests could not observe direction. ULIDs are monotonic, and the fixture
built its documents alpha, beta, gamma, so the ids ascended in the same order as the paths. Every
assertion about which sidecar an entry lands in was green whether the code ordered by path or by
id. Minting the fixture backwards made the two orders disagree — and immediately paid: the entry
order inside a sidecar was by URI, which is mint order, which is arbitrary to a reader. A
fixture whose two orderings agree cannot tell them apart, and the agreement is usually accidental.
MEDIUM — a newline in a filename would have broken the fragment, silently. The module builds
YAML as text, which is defensible: every value in it is a ULID URI or one of two constants, and a
test pins that against sidecar.needs_quoting. But the document paths go into YAML comments,
and a POSIX filename may contain a newline — one ends the comment and turns the rest of the path
into a node. needs_quoting cannot see it, because it answers about a scalar and a comment is not
one. The lesson is the sibling of 0.25.3's reflowed shell command: a value that is safe as a
scalar is not thereby safe as a comment, and the check that certifies the first says nothing about
the second.
Two more, from reading the diff as a stranger. propose is public and re-checks every endpoint
it is handed, but would have proposed a document linked to itself — which pnk link refuses
outright. And resolution was quadratic in disk reads: a block citing n documents makes n(n-1)/2
pairs, each resolving both endpoints, so every sidecar was read and YAML-parsed n-1 times, with
[retrieval] final_k having no ceiling and the money already spent.
The design decision worth keeping: observing and proposing are two functions, and the split exists
so a test can exist. § 5's rule is that a suggestion's endpoints must be documents this run
retrieved. Enforced inside one function, the rule is unfalsifiable — every candidate comes from the
same expression that validates it, so a working guard and a missing one produce identical output on
every input a test could construct. co_citations observes; propose re-checks what it is handed;
the refusal test calls propose with a pair no run would produce. A guard whose input is built by
its own validator is not a guard, and its test is a tautology in test clothing.
What the battery still cannot reach. The prompt-injection test — a retrieved passage instructing
the model to add a link, obeyed in prose, producing nothing — asserts the absence of a behaviour.
No mutation makes it fail, because the code that would fail it was never written: nothing in the
module reads AnswerBlock.text. It is a real test of a real property and it is not mutation-backed,
which is the same class docs/BUILDING.md § 4 names as a defect with no assertion anywhere, seen
from the other side.
And an old row that had been false for four releases. docs/DESIGN.md §9 still bounded --deep
with "no orchestration the free path doesn't have" — written before the loop existed, and
contradicted by the loop the moment it shipped. docs/graph/PINAKES_APPROACH.md § 6 had asked for
that exact row to be amended in the increment that shipped the design, and named the replacement
bound. E4 shipped and the row did not move. Found here by auditing the neighbourhood rather than the
diff — the increment that closes a release is the last cheap chance to fix what the release made
false, because after it nobody is reading those rows for a while.
Ten of ten mutants killed after the fixes, control included, with the containment mutant now caught by the containment test rather than by its neighbour.
The mutation harness — turning the last unguarded step into a tool (20260822 06:14)¶
plans/20260821_0745-mutation-harness.md, in one increment. The precedent is tools/land.py: when
prose has failed repeatedly against a class of mistake that fails silently, the rule stops being a
rule and becomes a tool.
HIGH — a skipped test and a passing test are the same exit code, and this repository skips
constantly. Not in the plan; found by measuring pytest before writing any code. A selector whose
tests all skip exits 0, which is byte for byte the SURVIVED signal, and Pinakes skips on a missing
extra by design (pdf, paid, model, the three-leg CI matrix). A battery aimed at one of those
on a [light] checkout would have reported every mutant in it unpinned — a confident, wrong claim
about exactly the assertions a mutation pass exists to check. The mirror case is worse and equally
invisible: a selector that is already red reports KILLED for every mutant aimed at it. One
pre-flight run per selector closes both, before any file is written.
HIGH — SIGTERM skips finally, so a try/finally restore is not a restore. Measured, 1 of 1.
SIGINT already raises KeyboardInterrupt, which is why the hazard is invisible to anyone who only
tests with Ctrl-C — and why the first version handled only SIGTERM until the harness's own battery
showed that dropping SIGHUP and SIGQUIT was a one-word edit no test could see. SIGKILL cannot
be handled at all, which is the real reason the plan's refuse if the target differs from HEAD
step is not a formality: after a hard kill the only recovery is git checkout <file>, and the
refusal is what makes that recovery correct rather than the seventh instance of the trap.
MEDIUM — the T3 trap reproduces 6 times out of 6, and its test must not race the clock. A
same-length mutant (min(value, MAX) → max(value, MAX)) written in the same wall-clock second as
the previous compile passes every test, because CPython validates a .pyc on
(mtime-to-the-second, size). Reproducing it first is what made the test honest: the obvious test
— assert the same-length mutant is KILLED end to end — goes green on a slow machine with the
invalidation deleted, because the second boundary is crossed anyway. Two tests replace it. One
asserts no bytecode cache exists during a mutation, which has no clock in it at all; the other
forges the stale condition with os.utime and watches the mutant vanish, so the clearing has a
control rather than being ceremony.
MEDIUM — pytest's <error> tag covers two opposite events, and conflating them threw away real
kills. A collection error is the invalid mutant: nothing ran, no assertion was tested. A setup
or teardown error is a real node the mutant broke on the way in or out — and in this repository
fixtures build indexes, manifests and KBs out of src/, so fixture-mediated detection is the common
shape rather than a corner. Treating both as ERRORED, and testing that before failures, reported a
mutant that tripped a fixture and failed a plain assertion as "the mutant did not run", tallied
0 killed. The two are now told apart structurally — a collection failure carries no line
attribute — rather than by matching pytest's message text.
The correction that mattered more than the finding. The reviewer who filed that one also proposed the fix: route setup and teardown errors into KILLED. An independent skeptic, asked only to refute the finding, confirmed the defect and rejected the remedy — routing them into KILLED would manufacture the false green the tool exists to prevent, because no assertion fired and nobody may write "pinned by test X" for a fixture noticing something. The shipped fix keeps the conservative direction and repairs only the sentence. An adversarial pass is worth more when the verifier is allowed to disagree with the finder's remedy as well as with the finding.
The shape that recurred three times: a guard the CLI cannot reach. Pointing the tool at itself
found, in three separate rounds, four clauses that no battery-driven test could kill — the second
anchor check inside applied(), classify's timed_out and setup_errors branches, and the
report's only-a-KILLED-row-may-name-a-killer guard. Each is redundant against the code as it
stands today and each guards a state one line of drift away. Every one surfaced as a SURVIVED
row, which is the row the tool exists to print; none would have been visible to a reviewer reading
the diff. They are kept, and each gained a direct test on the constructed state rather than being
deleted as dead code — redundancy nothing tests is indistinguishable from redundancy that has
quietly stopped working.
Twice, the battery's own selectors were the thing that was stale. Both times a guard read SURVIVED because the battery still named the test that existed before the fix, not the one written for it. The lesson is narrow and mechanical, and it is about the battery rather than the code: a battery is source that goes stale like any other, and a SURVIVED row is a claim about a pair — the mutant and the selector — either half of which can be wrong. The third time, the anchor pre-flight caught it before a single mutation ran, which is the whole argument for hoisting that check.
What the tool still cannot see. A survivor is a claim that wants checking by hand — the record holds two increments where a mutant that did not reproduce the real prior logic was briefly taken for a result, and the E7 session's own control mutant survived because every assertion imported the constant it compared against. A mutation result implicating a file the mutant never touched is a result about the harness, not the code, and nothing here detects that.
The exit criterion, met the only way it could be. The tool was run against itself: 25 mutants,
each disarming one of its own guards, 25 killed, each by the test named beside it. Before that,
two mutants of src/pinakes/graph/traverse.py's caps, both killed, tree clean afterwards — because
a tool that has only ever been run on its own fixtures has not been run.
A gate cited by a procedure it cannot read (20260822 06:35)¶
docs/RELEASING.md's sweep table names five places a release stales. One is STATUS's Published on
PyPI prose. Its "where the new entry goes" row answers: python3 tools/release_order_gate.py
decides it. No pattern in that gate matched the list. So the procedure delegated a placement
decision to a check that could not read the document, and the green line 5 sequences in release
order was read as covering a list it had never opened.
It drifted, as delegated-to-nobody things do: 0.25.1 → 0.25.3 → 0.25.2 → 0.25.4, wrong on SemVer
and on verification time, surviving every green run from 20260821 to 20260822.
The generalisable shape is not "a missing pattern". It is a citation nobody checked. A document naming a tool as the authority for something is a claim about that tool's coverage, and it is exactly the kind of claim that is written once and never re-read. Grep the other direction occasionally: for each gate, what do the documents say it covers, and does it?
Three things that fell out of building it, each worth more than the fix:
- A gate reads an order; it cannot see a count. Landed the same day, from the other side: a
concurrent session appended "thirty-seven" to a sentence that still said "thirty-six", one line
apart, through a green
check.sh, a greenmake docsand a greenrelease_order_gate. Caught by grepping the neighbourhood, not by reading the diff — the wrong half was context, so it never appeared in the diff at all. Counts stay a documentation rule for that reason. - "Tolerate it" needs a direction, or it is a hole. This list is legitimately short between a release landing and its verification, because a claim about the index is held back until it is verified from the index. Exempting it from agreement is right; exempting it from direction is not. It may lag every other sequence and may never lead one — a paragraph about a release the CHANGELOG has never heard of is a claim nothing else records. The first draft had the exemption and no direction, and nothing could have told the two apart.
- Say what the exemption costs, in the gate. Because a missing newest entry is legal, an entry written in a shape the pattern does not match is indistinguishable from one not yet written — silently unchecked. The floor catches wholesale rot; it does not catch one mis-shaped newest entry. Undocumented limits get trusted past.
And one environmental trap, found before it could produce a false green: this repo has no
.python-version, so uv gave a fresh worktree CPython 3.14.7 while the primary checkout and CI
run 3.13. A green ./check.sh in a new worktree is therefore not evidence about the interpreter
anything else uses. Pinned by hand here; the root fix is a proposal, not this increment's.
Verify the remedy, not only the finding (20260822 07:25)¶
docs/BUILDING.md § 5 already says a fix applied under review inherits the review's confidence and
none of its scrutiny. This is its sibling, and it is about the review pass rather than the commit
after it: a correct finding arrives with a proposed remedy attached, and the remedy is the half
nobody re-checks. The finding survives scrutiny; the remedy rides in behind it.
Two instances the same night, in two sessions working concurrently, each caught only because the second pass argued with the fix rather than stopping at the defect:
- A remedy that would have manufactured a false green. A classify bug was real and confirmed. Its proposed fix would have made the check report success on input it had never examined — a repair that removed the symptom by removing the observation.
- A remedy that was right and unbounded. STATUS's Published on PyPI list is legitimately short between a release landing and its verification, because a claim about the index is held back until it is verified from the index. Exempting that sequence from the release-order gate's agreement check was correct. Exempting it without a direction was a hole with a docstring: no input could distinguish a working exemption from a missing one. It became may lag, never lead — the list may fall behind every other sequence and may never name a release the CHANGELOG has not heard of.
A review culture that re-checks only findings ships both of these at full confidence. Both were defensible, both were proposed by the agent that had just been right about something, and that is exactly what makes a remedy hard to doubt.
The cheap test: state what input would look different if the remedy were absent. If none exists, the remedy is unfalsifiable and the test written for it will be a tautology — the same failure E7 recorded, where a guard whose input is built by its own validator cannot be shown to guard anything.
The cheap structural version, if only one thing survives from this: when an adversarial reviewer is asked to refute a finding, ask it to refute the proposed fix as a separate verdict. Both instances above were caught that way and neither was caught any other way — one by accident, when a skeptic exceeded its brief, and one by design, when the second pass asked tolerant in which direction?. It costs one more question per finding. The mechanism and the first instance came from the concurrent session; this fragment shipped without them and had to be amended, which is the argument in miniature.
And it worked because reviewing was somebody's assigned job. That amendment was found by a session whose remaining task was to look at work other people had finished — development split to one agent, planning and review to another. A review that happens as a courtesy after the work is done is the one that gets skipped when the work runs long; the catch here depended on it being nobody's optional extra.
A coda from the concurrent session, worth keeping beside this. The night's most serious defect —
pnk serve raising ModuleNotFoundError on every fresh install of every published version, from a
lower-bound-only dependency pin that 37 --frozen CI invocations never resolve — was not found by a
test, a gate or a review. It was found by someone asking what should we build next and installing
the product to answer it. docs/BUILDING.md warns that a test seam defines a region no test reaches;
here the region no test reached was the product itself. Reproduced independently in both
sessions before it was written down.
Capping mcp, and the leg that had to be run against the defect (20260822 08:20)¶
HIGH — a CI leg written to catch a defect is a claim until it is run against the defect, and the
mutation battery cannot reach this one. No mutant of any tracked file makes uv resolve mcp
2.0.0: the thing under test is a dependency resolve, and tools/mutate.py mutates bytes in the
tree. So the leg was verified the only way left — git archive HEAD | tar -x into /tmp, the cap
deleted from the copy, uv build, and both new steps pointed at the resulting wheel:
wheel-import: 1 module(s) did not import against the resolved dependency set:
pinakes.serve: ModuleNotFoundError: No module named 'mcp.server.fastmcp'
and pnk serve exited 1 with no "serverInfo" anywhere in its output. (That block is the run's
real stdout, pasted. The first draft of this fragment paraphrased it into two module lines — in
the fragment whose whole thesis is that the artifact has to be run. Caught by review; a quoted
output that was retyped is a claim wearing evidence's clothes.)
Without that, "the leg would have caught it" is a statement about a check that has only ever
been run against a fixed tree — the shape docs/BUILDING.md already refuses for a
test ("pinned by test X is a claim about a failing test"). The generalisation: when the battery
cannot reach a guard, build the broken artifact by hand and point the guard at it. It cost about
four minutes.
HIGH — the review's own remedies were where the remaining defects were, exactly as verify the remedy, not only the finding predicted the night it landed. Two of the three worst findings in this increment were not in the first draft's logic but in the mechanisms written to guard it:
- An allowance keyed on the library forgave the module the gate exists for.
--allow-missing pypdfium2excused any module failing onpypdfium2— includingpinakes.serve, had anything on its import chain ever reached it. Demonstrated against a synthetic package:servedid not import and the gate printed a clean pass. An allowance now names the module and the library, and a--required module may never appear in one. - The "the gate can still fail" step was satisfied by an environment where the gate never ran.
It grepped the tool's generic failure headline — which the tool also printed when the package
itself was not importable, i.e. when
--withinstalled nothing (a mistyped extras syntax does exactly that; measured). The two branches now print different headlines and the step names the one failure the bare wheel genuinely has. The defect was inside the step written to prevent that class, and a test pinned the collision rather than catching it.
The fragment's cheap test applied to this increment's own thesis — what input would look different if the remedy were absent? — is the uncapped wheel above. It exists, and it is the answer to the question this increment turns on: without it, the cap is the only real change and the CI leg is decoration.
HIGH — and then the remedies for those had defects, in the same shape, found by a second review that was asked only to refute them. Three rounds, each finding less than the last, and the third still finding four things that mattered:
- The guard against a missing wheel failed open.
set -- dist/*.whl; test -f "$1" && test $# -eq 1— underset -e, a failing first command of an&&list does not abort, so an emptydist/fell through towheel="$PWD/dist/*.whl"and carried on. The two-wheel case aborted, which is why it looked right. It is anifnow. Two sessions found this independently within minutes of each other; it came in as a review suggestion and was implemented verbatim. make smokeexited 0 while printing a traceback.pnk serve … | grep -q '"serverInfo"'— grep matches, closes the pipe, the server dies onBrokenPipeError, and the pipeline's status is grep's. The target printed "answers an MCP handshake" over a crashed server, and a changelog entry claimed it ran the same checks as CI. Output to a file, then grep it twice.continue-on-error: truedefeats an assertion written againstif:. The test said a gate that can be switched off is not a gate and then checked one of the two ways to switch one off. On the step in front ofuv publish, where being wrong is a version number PyPI will not take back.- The source-tree refusal was scoped to the checkout the script lives in. This project mandates
a worktree per change, so another checkout's editable install is a path the gate has never
heard of — demonstrated with one checkout's interpreter and another's gate: a clean green pass
over
src/pinakes. A negative check has to enumerate every wrong answer; the positive one — the package must be inside asite-packagesordist-packagesdirectory — has one right answer, and that is the difference between the two shapes.
And a declaration test only pins the file the last edit touched. --min-modules 50 sat at
four call sites across three files; deleting it from any one of them survived every test, and the
tool then falls back to a floor of 20 against a package of 57 modules. The assertion now iterates
the invocations rather than grepping the tree, which is the same lesson as read the sequence, not
the neighbourhood in docs/RELEASING.md: a property of a set is not a property of
any member of it.
MEDIUM — a gate that reads a range of lines reads the prose inside the range. check.sh's
extras-not-core gate is awk '/^dependencies = \[/,/^\]/' pyproject.toml | grep -qiE
'pypdfium2|anthropic'. The comment added above mcp — explaining that anthropic was measured
and deliberately not capped — turned it red and reported anthropic as a core dependency. The gate
had been correct for a year only because nobody had written a comment in that block. Same class as
0.25.3's textwrap reflow of a \-continued shell command: a line-shaped tool applied to
structure it does not model. The parsed-side test never had the problem, because it reads the
requirement list rather than the lines.
MEDIUM — nothing resolves a test path written in a comment. check.sh and
tests/test_paid_path.py both pointed readers at
test_packaging.py::test_paid_and_pdf_clients_stay_out_of_core. That test has never existed under
either file's history; the real name is test_extractors_stay_extras. docs/VERIFICATION.md is
gated for precisely this reason — a table of test paths is prose until something executes it —
and a comment is prose that nothing gates at all. Both fixed; the class is open everywhere else.
MEDIUM — measure the sibling before capping it, and measure the surface you actually call.
Capping all three lower-bound-only requirements was the obvious response and would have been
wrong. Both siblings were measured: anthropic 1.0.0 and sentence-transformers 6.0.0 keep every
constructor parameter, response-model field (Message, Usage, MessageTokensCount) and method
signature (encode, predict, get_sentence_embedding_dimension, max_seq_length) that src/
touches. The first pass measured only the constructors, and review caught that. The gap mattered:
extract/claude.py consumes response.model_dump() as a dict, so a renamed Usage field would
compute a cost of 0 — the spending guard silently disabled, with no exception anywhere. A
constructor signature says nothing about that. The remedy for the class is testing the resolve,
not capping on reflex; a project that caps everywhere buys the same silence with a different
cause.
The battery is what turned four rounds of prose into four rounds of evidence, and the survivor count went the wrong way twice before it went the right way.
| Round | Mutants | Survived | Where |
|---|---|---|---|
| 1 — the increment | 11 | 0 | — |
| 2 — after review 1's fixes | 21 | 3 | tests written from the fix's own description |
| 3 — after review 2's fixes | 26 (reviewer's) → 33 (mine) | 12 → then 2 | the remedies' own surface |
| 4 — after review 3's fixes | 25 (reviewer's) → 40 (mine) | 19 → then 0 | the remedies' remedies |
Round one said 11/11 and meant it about eleven assertions, which is all a battery ever means. Every
later round found survivors in the surface the previous round's fixes had added — exit 1
changed to exit 0 in a step that then printed ::error:: and reported success; a continue-on-error
on the job under tests that guarded the steps; --min-modules 1 under a test that asserted the
flag was present; a leading - in a Makefile recipe, which is make's own way of ignoring an exit
status. A remedy is new code, and new code nothing has tried to break is a claim. The count that
matters is not 40/40, it is that three consecutive reviews each found something and the fourth
round's mutants were written by someone who had not written the fixes.
What the new legs still cannot see, named so nobody reads them as more. Import-time breaks
only, on the install states CI runs. A dependency that keeps its module layout and changes a
signature passes every step added here and fails on a user's machine. [st] — the default
backend — is resolved fresh by nothing, because it is ~2GB of torch. And ci.yml runs on push and
pull_request, so a major published on a quiet day is caught at the next push or at the tag, never
before: release.yml is where that matters, and it now carries both checks in front of
uv publish.
A gate must not derive a constant from the document it polices (20260822 17:52)¶
Membership's first draft derived each sequence's start version from the sequence itself — "the
oldest release it contains" — and justified it in the commit message: the starts are observable and
monotonic; they never move backwards, because releases are never deleted. Every clause of that is
true and the conclusion is still wrong. The gate does not observe releases. It observes the
document. Delete STATUS's 0.2.0 row and the derived start becomes 0.2.1: the sequence is still
sorted, still contiguous, still internally consistent, and the gate reports green on precisely the
deletion it was built to catch.
It is the same failure the gate's own docstring already refuses one paragraph earlier — the direction is declared per sequence, never inferred, because a badly scrambled file would otherwise elect its own answer. I read that sentence while editing the file and then wrote the inferred version of a different constant. A rule stated about one field does not defend the next field somebody adds. Four declared constants cost four lines and cannot be argued into being wrong.
The general shape, worth checking on any gate here: for each constant the gate uses, ask where it came from. If the answer is "the thing being checked", it is not a constant, it is an echo.
A status read is not a status gated on¶
CLAUDE.md names this trap in its piped costume: check | tail && git commit reports tail's
status, so a failing checker looks green. I hit the unpiped one and it is worth naming separately,
because the written rule did not cover it and I had read that rule the same day:
./check.sh > log 2>&1; echo "exit=$?" # status printed, and read, and correct
git add -A && git commit ... # runs regardless, on the next line
The status was captured, printed, and true. Nothing consumed it. A commit landed over a red tree — a lint error, caught later — and the log shows a green-looking sequence of steps. The pipe was never the mechanism; the mechanism is that the exit status must be what the next command reads, and a human reading it off the screen is not the next command. The fix is one wrapper:
./check.sh > log 2>&1; RC=$?
if [ $RC -ne 0 ]; then …report…; else …commit…; fi
And a third instance of verify the remedy, not only the finding¶
The exception mechanism for 0.11.0 was agreed with the planner as a loose match — find the
version anywhere in a ## heading. Implementing it meant first finding where that heading actually
is: docs/ROADMAP.md:1721, inside # Part 5, with the release table linking to it on purpose.
0.11.0 has no Part 4 section by design. The loose match would have taken that heading as its
release section and failed twice on a correct document — placement, because Part 5 declares no
range, and ordering, because it follows 0.27.2.
The finding was right: membership needs an exception mechanism. The remedy attached to it was wrong, and the risk that killed it had been named in the same message that proposed it — "matching a version anywhere widens what counts as a release section" — and read by both of us as hypothetical. It was not hypothetical; the widened set already had a legitimate member. A risk stated beside a remedy is not a risk that has been checked.
The mcp 2.x port — the port was four lines, the gate was not (20260822 18:23)¶
HIGH — A gate whose passing depends on a dependency's teardown timing is not a gate. The
handshake in both workflows wrote three JSON-RPC lines into pnk serve and closed stdin. Measured
on the same three lines, same KB, ten runs each:
initialize answered |
tools/list answered |
|
|---|---|---|
mcp 1.28.1 |
10/10 | 10/10 |
mcp 2.0.0 |
10/10 | 2/10 |
a real ClientSession, 2.0.0 |
8/8 | 8/8 |
1.x drained the queue before shutting down on EOF; 2.x does not. Nothing in the step named that dependency, and nothing could have: it is a property of a library's shutdown path, not of its API. The generalisation is worth more than the instance — if a check's passing depends on a race nobody in this repository wrote, it is not a check — and the remedy is not a longer sleep but removing the race's cause, which here meant driving the session with a client that holds the connection open until it has its answers.
The timing is the uncomfortable part. That handshake was written the same morning, in
0.27.2, to catch the mcp outage — and it was written against the behaviour of the version it
was about to lose. A gate built for a dependency major was itself a hostage to that major. git
log -S '"method":"initialize"' returns exactly two commits: the one that added it and the one that
removed it, four hours apart.
HIGH — The third copy is the one nobody looks at. Two workflows were fixed; make smoke — the
pre-tag check a maintainer actually runs — kept the dead shape and was red on every run against
a healthy wheel. Review found it, and found the second half too: test_make_smoke_exercises_the_wheel_rather_than_only_its_version
asserted '"method":"initialize"' in body and passed, certifying "drives a handshake" against a
recipe that no longer produced one. When a shape is replaced because it is wrong, grep the whole
tree for that shape before believing it is gone — and check what the tests pinning the old copies
now assert, because a test written against an implementation outlives the implementation.
HIGH — There is no independent source of a project's own version inside its own checkout. The
version test compared what the server advertised against importlib.metadata.version("pinakes"),
reaching for independence from pinakes.__version__. [tool.hatch.version] reads that same file,
so the metadata is the same constant copied into .dist-info at install time — and uv run
does not refresh it when the constant changes. Bumping __version__ to cut the release diverged
them at once and reddened two tests, with a message blaming the serverInfo defect the increment
had just fixed. A landmine the release commit would have stepped on twenty minutes later.
The lesson is not "use the other source". It is that a longer route to the same origin is not a second opinion, and saying so in the docstring is worth more than the appearance of rigour. The genuine cross-check exists, but only in CI: the expected value comes from the built wheel's filename and the advertised one from a separately installed copy of it.
MEDIUM — An error message is part of the assertion surface. assert EXPECTED in result.stderr
looked like it checked that a failure names what was expected. It was satisfied by the gate's own
sentence, "Until 0.27.2 this field carried the mcp library's version" — because EXPECTED was
0.27.2. The assertion held with the fix deleted. A version literal in a diagnostic is a value a
test may accidentally be reading; the message now names no version.
MEDIUM — The negative form of a rule is not the rule. Two exit-status pins asserted the absence
of one literal string, out=$(timeout — the shape the step used to have. | tee, || true, if
…; then and a backticked capture all stayed green, and | tee is the likely edit, since the
shape it replaced existed precisely to print the gate's output. The positive form —
this invocation is a simple command under timeout — is one assertion instead of an
ever-growing list of forbidden ones. Generally: when a rule says "the status must reach set
-e", assert the shape that makes it true, never the shapes that made it false last time.
MEDIUM — step["run"] is a YAML literal block, comments included. release.yml's pins were
bare substring searches over it, so commenting out the commands left every assertion satisfied by
the prose above them. Its ci.yml twin had required command lines since 0.27.2 for exactly this
reason and the release side never got it — a fix applied to one of two twins is half a fix, and
the release path is the half where being wrong is irreversible.
MEDIUM — The only untested branch was the one the gate exists for. A server dead on import —
the 0.27.2 outage itself — reached the gate's generic session-failure path, and no test drove it.
Also untested: the timeout ceiling and serverInfo.name. All three now have one, and --timeout
became a real flag so the timeout test costs a second rather than thirty. Ask which branch the
tool was built for, and check that one has a test before counting the others.
MEDIUM — A test can pin a message and leave the behaviour free. The timeout test asserted
"no complete session in 1s" and nothing about the clock. The mutation battery wired timeout= to
a constant while the message still read the flag, and reported ERRORED rather than a kill — the
harness refusing to call a run that never finished either a kill or a survivor, which is exactly
what tools/mutate.py was built to do. The test now asserts elapsed time as well, with a loose
bound: what must be true is seconds rather than the job.
LOW, and a pattern rather than an item — four false statements, all in prose written to explain
why the code was careful. SESSION_TIMEOUT_SECONDS was commented "Ten" beside a value of 30; a
docstring said comparing __version__ with itself "would hold with the version= argument
deleted", which is simply untrue; tools/wheel_import_gate.py still said uv.lock pins 1.28.1;
and a changelog fragment called the flaky handshake "a coin flip that landed the same way for a
year" when git log -S puts it at four hours old in a repository 28 days old. Explanatory prose
is where false claims accumulate, because it is written once, at the moment of most confidence, and
never re-read against the code it explains.
LOW — Two run-the-real-thing checks that no unit test could replace. The three CI legs were run
verbatim against a built wheel resolved fresh onto mcp 2.0.0, which is the only way to learn that
uv run --isolated --no-project --with <wheel> puts pnk on PATH for a child process the gate
spawns. And the gate's timeout was driven with a shim that spawns and stays silent: it exits 1 after
32s and leaves no orphan behind it.
Process — the primary checkout's tree can change under a running gate. A peer session ran
tools/land.py while ./check.sh was running in that same directory, and three tests failed with
"matched 0 # Part heading(s)" — loud, plausible, and pointed at the file the peer had just
touched. Nothing was wrong; a re-run at the new sha was green. Run gates in your own worktree.
The primary checkout is where landings happen and its tree can change under a running pytest at any
moment; a red run there may be about a tree that no longer exists.
Review economics, recorded because the numbers are the argument. Round one: four lenses over the
diff, each finding verified by an adversarial refuter. 31 findings raised, 19 confirmed and 12
refuted — and the refutations were substantive, one of them disproving a claimed process-group
escape by reading multiprocessing.util.spawnv_passfds on the machine. Two of the confirmed
findings were defects I had already found myself; the other seventeen I had not. The mutation
battery grew from 15 to 24 mutants over the same increment, all killed. The refutation stage
earned its cost twice over: it is what makes 19 findings worth reading rather than 31 worth
arguing with.
When a constant cannot be declared, constrain it (20260823)¶
The echo class — a gate reading a constant out of the document it polices — turned up four times in one gate, and the interesting part is that the same remedy did not work every time.
| Echo | Exploit | Remedy |
|---|---|---|
| sequence start = its own oldest entry | delete the oldest row; the start moves and hides it | declare it |
| lagging ceiling = its own newest entry | delete the newest entry; the ceiling drops with it | bound how far it may lag |
| Part range = its own heading | append a range to # Part 5 and a misfiled section is legal |
constrain it |
| Part count floor = one below the truth | demote # Part 5; the floor passes exactly |
raise it to the real count |
The third could not be declared, and that is the lesson. Reading Part ranges out of the headings
is why the mapping cannot drift from the document — replacing them with a table in the tool would
reintroduce the drift the gate exists to catch. So the echo had to stay and be made unexploitable
instead: two Parts may not claim the same version, and the Parts must ascend. # Part 4 declaring
`0.8.0` onward is then precisely what stops # Part 5 declaring it.
Declare, bound, constrain — in that order of preference. Declaring is strongest and cheapest when the value is genuinely external. Bounding admits the echo and limits its travel. Constraining leaves the echo and removes the freedom that made it exploitable. Reaching for the first when only the third is available produces a table nobody updates.
A floor one below the truth is a floor with a bypass. PARTS_MINIMUM was 4 against 5 real
Parts, so demoting the last heading passed it exactly while handing every section beneath to the
open-ended Part above. Floors here are written as "this only ever holds, because things are never
removed" — which is an argument for setting them at the count, not below it. A floor with slack
is a floor someone can stand in.
And a test that asserts a sentence asserts only that something went wrong. Three instances in one increment, each found by mutation and none by reading:
- a range-form test asserting a failure — satisfied by breaking the form entirely
- placement fixtures guarded by
assert "reads ascending" not in stderr— satisfied by a reworded message, and by a second failure appearing beside the one under test - an ordering test asserting
"must ascend with the document"— satisfied with the comparison reversed, because a different, correctly-ordered pair then fires and prints the same words
The positive form in each case: assert exactly one failure and which one — the pair, the
Parts, the versions. failures_of() exists for that.
On the audit itself. These were found by four independent lenses over the landed gate, each finding then handed to a separate agent told to refute it — not by re-reading the diff. Two of the four lenses found nothing. The two exploits came from the lens that was asked one question only: for every constant this gate uses, where does its value come from?
A correction is a claim like any other (20260823 02:05)¶
Two sessions spent 20260822–23 finding one defect shape from four directions, and the fourth instance landed on the correction rather than on the code.
A handoff file said the release-order gate's placement and membership halves shipped in 0.27.1 at
ba4d7ae. That was wrong. The reviewing session corrected it to "placement and membership landed
after 0.28.0 and are unreleased" — which was also wrong. git describe --contains settled it in
one command:
09f5449 (placement) -> v0.28.0~2
0a8bd38 (membership) -> v0.28.0~1
ba4d7ae (lag + Parts) -> in no tag
The reviewer's own diagnosis is the thing worth keeping: one true fact was established —
ba4d7ae is tonight's merge, not 0.27.1 — and extended to a second that did not follow. The true
half made the false half feel checked. The evidence to settle it was one command away and was run
only after pushback.
This is the same shape as a fix applied under review inherits the review's confidence and none of its scrutiny (BUILDING.md § 5) and its sibling verify the remedy, not only the finding — one level further out. The correction is the least-reviewed statement in any review, because it arrives attached to a finding that has just been demonstrated, and demonstrating the finding feels like demonstrating the fix.
And a second rule fell out of the same exchange, stronger than the one it replaced. The file's
ownership table had a held last by column naming sessions. The reviewer said "put the right name
in". The right name could not be kept: the writing session was renamed mid-file, and the two
sessions' ListAgents views disagreed about which of them existed. So the column was deleted, not
corrected — ownership is by role, with ListAgents for who is live.
When a field cannot be kept true, remove the field rather than maintaining it.
That is the end of the ladder this repository has been climbing all week: declare a constant rather than deriving it; bound it when it must be derived; constrain it when it must be read from the thing it polices; and delete it when none of those can hold it honest. A field nobody can keep true is a lie with a maintenance schedule.
A document's version stamp is the one claim in it nothing can check¶
docs/GUIDE.md opened with "Every command here was run against 0.2.0 (20260728 16:40)" and was
published, unchanged, for twenty-six releases. Nothing was broken by it and no gate could see it:
./check.sh proves the release sequences agree across five documents, mkdocs --strict proves
every link resolves, and tools/template_drift_gate.py proves the template matches its archive —
none of them reads a sentence claiming a command was run.
The stamp is not a small lie. It is the sentence a reader uses to decide whether to trust the other
eight hundred lines, and it was wrong in the direction that costs most: it claimed more
verification than had happened. Nine output blocks had drifted behind it, and the two most
misleading were the ones a reader would act on — a euro estimate quoted 30% high, and three separate
statements that only one surface in Pinakes can spend money when pnk ask --deep had been the
second for four weeks.
The pattern is that a stale doc rots fastest exactly where it was most specific. Prose survives a release; a quoted output block does not, because it pins a number, a version string and a wording all at once, and any one of the three moving falsifies it. Nine blocks drifted while the paragraphs around them stayed true. That is an argument for keeping worked examples — they are what catch the drift — and against ever writing one without a way to re-run it.
Two things this pass could not do, and said so rather than faking. The paid --deep transcript
would cost real money to reproduce, so it is kept and labelled as a 0.24.0 run with the one figure
that has since moved named explicitly. And re-running the two-KB link walkthrough would change every
ULID in the section without making a sentence truer. The distinction that matters is between an
output nobody re-ran and an output nobody re-ran and did not say so. Only the second is a defect.
A near miss worth recording. The first draft of the label said the paid block's command "is now
quoted at €0.20" — the figure measured at -k 2. The block's own command passes no -k, and at the
default passage count it quotes €0.21. One cent, and the wrong kind of wrong: two invocations
collapsed into one number, which is precisely the invented precision the label existed to prevent.
Caught by re-reading the command the block actually shows instead of the one measured beside it.
A gate can be green because it is reading the list next door¶
The Published versions row drifted four releases while a check named after it ran green on every commit. The check was real, the row was wrong, and both statements were true at once: the gate's sixth sequence reads the Published on PyPI prose, and the row is a different list forty lines down in the same file, under the same heading.
What made it invisible was the naming, not the code. A reader auditing "is the PyPI list checked?" finds a sequence called the Published on PyPI prose, sees it green, and stops. Nothing in that report says which of the two lists under that heading it read. The lesson generalises past this file: when one document holds two lists of the same thing, a gate covering one of them reads as covering both, and the coverage claim is what needs the test — not the ordering.
The corollary for within. Scoping a pattern to a region introduces a failure the line-anchored
sequences never had: the anchor can match somewhere else. Zero matches is safe, because an empty
sequence trips the floor and the floor's message fits. Two matches is not, because reading the first
is a silent choice — the anchor deciding for itself which region it meant, which is the
"derived, never declared" mistake this module refuses everywhere else, one layer down. So it is a
refusal, not a heuristic.
And a fixture proves scoping only if it would break without it. The tree these tests build
writes version numbers after the enumeration and in descending order, mirroring the real cell. A
within that stopped scoping reads them into the sequence and the tests go red. Without those
trailing numbers every test here would pass over a bare unscoped pattern — the fixture would be
asserting the thing it was built to assume.
On the floor: an exact count is declared, and still wrong here. Setting the floor to the 41
versions present would be a literal, not a derivation — but check() skips a sequence whose floor
failed, and check_membership then skips it too, so deleting one release would report "the pattern
has stopped matching what it names" and never say which release went missing. The precise
diagnostic is worth more than the redundant one. Declared-not-derived is about where a number comes
from; it does not settle what the number should be.
A constant bounds the damage; a relation catches the start¶
The first fix made the row a sequence with permission to lag, bounded by MAX_VERIFICATION_LAG.
That is a real check and it would have caught both drifts — eventually. Counting the history says
how late: 29 commits sat in the window where the row was behind and the bound was silent, and both
drifts passed through it on the way to being caught.
The bound is a constant, and the thing it approximates is a relation. The row may lag the release documents because an entry waits for index verification; it may not lag the other list recording that same verification, because there is no interval during which one is true and the other is not. Written that way the rule needs no tolerance at all, and the measurement bears it out: zero false positives in 67 commits, red at the first commit of the drift rather than the eleventh.
The general form: when a bound needs a tolerance, ask what the tolerance is standing in for. A number chosen to be loose enough for the legitimate case is by construction also loose enough for some illegitimate ones. If the legitimate case can be named — here, "both lists are waiting on the same verification" — the relation that names it is both tighter and simpler than the number.
Keep the constant anyway. It is not redundant: it catches a drift in which both lists are forgotten together, which the relation cannot see. Two checks over one sequence, catching different failures at different moments, is the correct outcome — and the test that exercises the deep drift now asserts both fire, so neither can be deleted while it stays green.
Committing the mutation batteries — a design statement nobody had measured (20260823 07:34)¶
HIGH — the sentence that decided this was an assumption wearing a docstring. tools/mutate.py
shipped in 0.27.0 saying "a battery is a per-increment working file, not a portable artifact", in
a paragraph whose actual subject was where the pytest key lives. Nothing had measured it, and it
then decided the question for two months by being read rather than by being right.
plans/20260821_0745-mutation-harness.md enumerates what it deliberately left open and persistence
is not among them — checked, not assumed. So this fills a gap rather than overturning a position.
The measurement took twenty minutes. Of 81 mutants across six increments, 78 anchors still
resolved exactly once a day to a week later, and every one that broke was one whose own code had
changed. The failure mode of keeping a battery is a refusal, not a wrong answer — mutate.py
resolves every anchor before it writes anything and refuses on 0 matches and on 2. That asymmetry is
what made the question decidable. An argument about whether a claim outlives its proof would not
have resolved; what does being wrong cost? did.
HIGH — and the measurement was aimed at the half that had never broken. docs/RETROSPECTIVES.md
already recorded which half rots, twice in one increment: "a battery is source that goes stale like
any other, and a SURVIVED row is a claim about a pair — the mutant and the selector — either half of
which can be wrong." Both my 81-mutant recovery and a peer's five refactor scenarios stress-tested
anchors. The selector is what historically went stale. The gate resolves both, and the half
that matters is the one the headline number says nothing about. Check the record for which half
failed before designing a measurement of the other.
HIGH — the repair loop produced two broken mutants under best-case conditions, and that is the
strongest evidence against the model, not for it. Two anchors were repaired here: same day, by the
author, with full context. Both widened old from one line to two and left new at the one line
it used to replace:
| Repaired mutant | What the edit actually did |
|---|---|
| the prose list loses its exemption | deleted minimum=15, and flipped the flag — two changes under a name promising one, and the floor it fell back to sits on the boundary of that sequence's length, so the test may have gone red for the floor |
| the prose sequence is read in the wrong direction | deleted starts_at and duplicated minimum=15, — keyword argument repeated, a SyntaxError at import |
The second read KILLED, in a batch reporting 0 errored. classify treats a collection error
as the invalid mutant, and this module is imported inside the test rather than at module scope,
so the SyntaxError arrived as an ordinary assertion failure. A confident row about a question nobody
asked — the one thing the tool exists to refuse — produced by the tool, in its own corpus, on the
first day. n=1 in the direction an adversary predicted, and it is the honest cost of the model.
refuse_a_mutant_that_cannot_compile now catches the syntax half, before the first write, and it
earned itself within the hour: restructuring check_anchors staled two mutants in this
increment's own battery and it caught both. ast.parse would not have — it accepts f(a=1, a=2)
and compile() does not, which is exactly the case that got through, so a test pins the
discrimination rather than leaving the choice of compile looking like a detail. The other half
has no guard: an edit that still compiles and changes two things under a name promising one is
invisible to everything here. Read the diff a mutant produces, never its anchor.
MEDIUM — a corpus with no inventory can shrink to nothing with every gate green. None of the
first draft's checks was a count, and the sanctioned repair for a property that has genuinely gone
is to delete its mutant — which makes deletion the cheapest path to green during a refactor. Split
a 739-line gate six weeks from now, watch thirteen anchors resolve zero times, delete thirteen
blocks: ./check.sh green, the battery reads 28 of 28 killed, and nothing anywhere recorded
that it was 41. mutants = N per battery, validated in load_battery, makes deleting a row a
two-line edit that says so.
MEDIUM — the argument for keeping them was weaker than it first looked, and saying so changed
which argument to make. The first draft argued the claim outlives its proof. It does not:
re-deriving 41 mutants against a gate is an afternoon. What is unrecoverable is the reasoning
about which mutants were worth writing — and the durable channel for that already existed. A
per-mutant table can go in a retro.d/ fragment, and docs/RETROSPECTIVES.md has exactly one,
against 93 commits that speak of a mutation pass. The channel existed, worked, and was used once.
That is the case for a directory, and it is a better one than there was no channel.
MEDIUM — consolidation is a check that scattering cannot perform. Merging six scratchpad
batteries into three files surfaced what no individual battery could: one battery entirely
superseded by a later copy of itself, and two mutants byte-identical in file, old, new and
kills under different names — the same edit counted twice in a report reading 29 killed.
MEDIUM — a directory nothing reads is the failure this repository keeps recording. A template
version that meant different bytes in every commit; a verification table where 61 of 98 test paths
did not resolve; a docs/GUIDE.md whose commands no longer ran. So tests/test_batteries.py reads
them, in 0.05s. Its cost, stated rather than discovered later: a refactor that moves a mutated
line turns ./check.sh red until the battery is repaired — measured at 2 repairs per 81 mutants per
week, minutes each. The assertion names re-anchoring before deletion, because deleting a mutant
to green a gate is the silent damage this arrangement invites.
MEDIUM — the corrections a review pass made to this file's own claims. Each was a number stated with more confidence than the data carried:
- "the single comment-bearing anchor in the set was the one that went ambiguous" — wrong on both halves. Three of the 81 anchors carry a comment; the one that went ambiguous carries none. One comment-bearing anchor did rot, in the superseded battery. The generalisation drawn from it — that the fragile shape is knowable in advance — went with it.
- The mcp battery was described as spanning six files. It spans seven;
check.shwas in neither enumeration. - The section for
tools/fragments.pywas labelled 0.28.0; the property shipped in 0.27.1. - Its header was a comment that had migrated during consolidation, claiming "the two gates" and a control that had gone to the other file. A comment attached to the wrong mutant is silent, and the comments are the reason these files exist.
- A mutant named "ci.yml guards the gate with
|| true" appended&& echo ok, which does not swallow the failure the name promises. The name is the only guard against an anchor that still resolves while the semantics moved, so a name that misdescribes its own edit disarms it.
MEDIUM — "regression asset" is the one thing this cannot be, and the README now says so.
report() exits 0 when mutants SURVIVE, deliberately — "It is not a harness failure, so this exits
0 — read the rows." Nothing here changes that, and the committed gate never runs a battery: it
checks that strings resolve. Anything CI-shaped built on top needs its own check on the survivor
count. And the denominator belongs beside the claim: four batteries, four targets, all under
tools/; no module under src/ has one, and no invariant in docs/INVARIANTS.md is covered.
The covered files change 3–11 times a month, cli.py 58 and sync.py 40. A coverage index with a
hidden denominator is the exact defect this repository keeps catching in its own gates.
LOW — --check-anchors reads the working tree, not HEAD, and the difference is the point. A
run refuses an uncommitted target so git checkout <file> stays a safe recovery after a hard kill.
Nothing is written in check mode, so inheriting that refusal would have bought nothing and disarmed
the check in the one moment it is wanted. Two guards that look alike had different reasons, and
copying the reason across would have made the second useless.
LOW — three ways the first draft of a "report every failure" contract reported fewer. The test-file refusal raised inside the per-battery loop, discarding the problems already collected and never reading the batteries after it — which is the run's behaviour, and the thing this mode is defined as not being. The claim set keyed on the battery path as typed, so one battery named twice under two spellings was a false double-claim. And stdout was flushed only on the problem path, so a refusal printed above the summaries it contradicted.
LOW — the flagship test passed when half of what it pins did not happen. The two stale mutants
were named gone and also gone, and "gone: …" in stderr is satisfied by the also gone line
alone. Reporting only the last problem passed a test whose docstring calls reporting every
problem "the difference from a run that matters most". Two fixture names where one is a substring
of the other is a silent half-assertion, and the same shape hides anywhere a test asserts on
substrings it also chose.
LOW — the test helper that wrote both batteries to the same filename. battery() hard-coded
battery.toml, so the first "two batteries" test built the second by calling it again and copying
the result — silently overwriting the first. The overlap test passed anyway, because both files
claimed the same target either way; the control — two batteries claiming different files — is
what exposed it. A control earns its place by failing for a reason the pin cannot.
Extracting CLAUDE.md — four agents each edited correctly, and the set was wrong (20260823 13:52)¶
HIGH — every defect in this increment was a neighbourhood defect, and none was a content defect. Four agents wrote four destination sections in four files. Each one did its own job correctly: right heading, right anchor, every source fact carried, matching voice, no existing heading renamed. The review then found five defects, and all five were about what the surrounding text already claimed:
| Where | What it still said |
|---|---|
docs/RELEASING.md:3-5 |
"the traps … stay in CLAUDE.md" — one had just moved into this file |
docs/RELEASING.md:7 |
"Extracted … 20260801 … Nothing was dropped" — a provenance note now silently covering content added 20260823 |
docs/INVARIANTS.md:6-9 |
described the file's two structures; there were now three |
docs/README.md:83 |
the routing table still described the pre-extraction layout |
docs/BUILDING.md:168 |
pointed at CLAUDE.md's text, which is now the compressed version |
docs/README.md § Conventions has said audit the neighbourhood, not the diff since it was written.
A per-file agent cannot follow that rule, because the neighbourhood is the thing outside its
file — and the one file that most needed editing, the routing table, was assigned to nobody and so
was the only docs/ page absent from the diff. If work is fanned out per file, something has to
own the seams, and it is not any of the writers.
HIGH — the extraction nearly created a second home for a rule, which is the failure it exists to
prevent. A section was written into RELEASING.md for naming unbuilt work — and
docs/README.md § Conventions already owned that rule in full, including the same date, the same
STATUS.md pointer and the same historical-records carve-out. Worse than duplication: the pointers
formed a loop in which no file admitted README held it. README said the live names are in
CLAUDE.md; the new section said the rule and the table live in CLAUDE.md; CLAUDE.md pointed at
the new section. A reader following the pointer from CLAUDE.md would never reach the copy that
actually owns the rule, and the two could drift with every command reporting success. The section
was deleted and CLAUDE.md now points at README. Before writing a destination, grep for the fact —
a relocation into a fact's existing home is a duplication, not a move.
MEDIUM — and checking that duplicate turned up a stale claim nobody was looking for.
docs/README.md § Conventions still named the deep release as live unbuilt work. It left that
table at 0.26.0 — its final cut — three releases ago, and CLAUDE.md's own table had been correct
all along. The duplicate was not merely redundant; the two copies had already drifted, which is
the concrete form of the harm "one fact, one home" is stated to prevent.
MEDIUM — four dead links, written while creating pointers whose entire purpose is to be followed.
CLAUDE.md sits at the repository root, so a relative ](RELEASING.md#…) resolves to /RELEASING.md,
which does not exist. Four were written that way. mkdocs build --strict cannot catch this class:
CLAUDE.md is not part of the built site, so its outbound links are checked by nothing. The check
that found them is three lines of Python — parse every ](path.md#anchor) out of the file, slug
every heading in the target, compare — and it belongs in any future extraction.
LOW — the honest outcome is 220 lines, not 150. The guideline is ~150 and this file is at 220 after everything with a genuine home elsewhere was moved. Going further would mean deleting rules the user set, and rule 6 says explicitly that the guardrail is "the trigger to extract sections and leave pointers — not a hard cap that justifies deleting information." Reporting the shortfall is the correct outcome; compressing past it would have been the defect the rule warns about. What remains is dense by construction: a role rule, an ownership table, a naming table where every row is a breaking change, and four sections of user-set working mode.
The ungated link surface, and three instruments that lied (20260823 14:38)¶
HIGH — a regex cannot gate Markdown links, and both agents on this repo built one first. The
coder's first scan reported 82 broken links inside docs/, a surface mkdocs --strict guarantees
clean; it had blanked inline code with re.S, so one unbalanced backtick in a 2 616-backtick file
swallowed every heading after it. Mine then reported 18 where the true number was 11 — twice, for
two different reasons. Rendering answers the question directly: if a link is inside a code span
the parser emits no <a> at all. No stripping, no heuristics, no reimplementation of block
parsing. The final instrument is markdown.markdown() plus this repo's own _github_slugify, run
through the same uv run --no-project --with-requirements requirements-docs.txt invocation
make docs uses, so it cannot drift from the site.
HIGH — a control leg validates only the behaviours it exercises, and a green one is not a clean
bill. The coder's control caught its re.S bug. Mine did not catch either of mine: 485 links
across docs/, zero broken, green — while the instrument was wrong twice. docs/ contains no
links to a directory, so Path.is_file() rejecting docs/graph/ and ../plans/ was never
exercised; that produced six false positives. And docs/ has no indented code block containing a
link, so treating one as prose was never exercised either. The second false positive produced a
wrong edit before it was caught: retro.d/README.md:37 sits in a four-space block and is the
README teaching fragment authors what to write — a bare #anchor, which resolves only once the
fragment is spliced into docs/RETROSPECTIVES.md. It was repointed at ../docs/RETROSPECTIVES.md
before the surrounding prose was read, turning an instructional example into one that teaches the
wrong form. Reverted. Read what a line is for before fixing what a tool says about it.
MEDIUM — an adversarial pass that overturns nothing has said nothing. Re-verifying the
20260807 audit's 39 findings against c45ffa8 ran eleven agents plus an adversary per cluster,
each prompted to refute every already fixed verdict, since that is the direction that silently
drops a verified defect. The adversary overturned zero. Hand-checking all seven afterwards found
one false: docs/DESIGN.md:711 asked to drop two phrases and only one had gone — a manifest and
a template drift silently still stands while doctor.py prints the drift it denies. The adversary
was prompted for exactly that shape, corrected in one place while an equivalent claim survives,
and upheld it anyway. A zero from a checker is a claim about the checker. The same pass also
corrected two predictions made from reading the code without reading the document: DESIGN.md:715
and KB-UPDATES.md:24 were called more wrong than before on the grounds that pnk upgrade had
since shipped, when both had in fact been rewritten and are genuinely fixed.
MEDIUM — mkdocs --strict is a link gate, not a rendering gate. It resolved every link in
docs/RETROSPECTIVES.md for weeks while one of its lines rendered as a broken run of <code> tags
on the published site. The defect class — a backslash-escaped backtick inside a code span, which
CommonMark does not honour — produces valid HTML that is simply not the HTML anyone intended, so
nothing that checks structure can see it. Catching it needs a rendering comparison, which is a
different check from link resolution and was found only because a link fix forced the line through
a parser.
The ungated Markdown surface, and two checkers that were confidently wrong (20260823 14:36)¶
HIGH — a checker with no control leg reports plausible nonsense, and reads as authoritative.
The first draft of tools/markdown_link_gate.py reported 82 broken links inside docs/ — a
surface mkdocs build --strict guarantees is clean. The cause was a one-flag mistake: inline code
spans were blanked with re.S, so in docs/ROADMAP.md (2616 backticks, zero fences) a single
unbalanced backtick swallowed every heading after it. Nothing about the output looked wrong —
missing anchors, real filenames, plausible slugs. It was caught only because the scan was run
over a corpus whose correct answer is known independently of the checker. Run a new checker against
a surface something else already guarantees, before believing anything it says about the surface
nothing guarantees.
HIGH — a control leg validates only the behaviours its corpus exercises. A peer wrote an
independent scanner over the same surface and got 18 where this one got 12. All six extras were
its bug: it resolved targets with Path.is_file(), so every link to a directory failed —
docs/graph/, ../plans/, ../retro.d/ — each correct as authored, since GitHub renders a
directory link as a listing. Its control leg over docs/ stayed green throughout, because
that corpus contains no directory links. So the two lessons are halves of one: a checker with no
control reports nonsense, and a green control certifies only what it covered. Both instruments
were wrong, in different directions, and each was found by something the other did not do.
HIGH — a false positive gets acted on, so the skips matter as much as the catches. The count
was reported as twelve and was eleven. retro.d/README.md:37 sits in a four-space indented
code block — neither this checker nor the peer's stripped those — and it is the README's own
example teaching fragment authors which anchor form to write. The peer repointed it before reading
what the block was for, turning an instructional example into one that teaches the wrong form,
then reverted. Nobody argues with a false positive the way they argue with a false negative; they
fix it, and the fix damages the document. A link checker's skip list is a correctness surface.
HIGH — when a cheap instrument is suspect, do not argue about it: render, and measure the gap.
The right response to "a regex cannot parse Markdown" was not to defend the regex or to take the
dependency, but to build the renderer once as an oracle and compare. Python-Markdown over all
114 tracked files emits 894 links; the extractor now finds every one of them and invents zero,
the single difference being a bare <https://…> autolink that is external and never resolved
anyway. That measurement is what licenses staying stdlib-only — ./check.sh and the CI job need no
install and no network — and it is kept as a test that runs wherever the docs toolchain is present
and skips with its reason where it is not. The argument was unresolvable; the measurement took one
command.
MEDIUM — a false negative is the failure mode a link checker actually has. The second bug in
this one forbade newlines in link text, which silently skipped every link whose text wraps — in a
repository that wraps prose at 100 columns, and where CLAUDE.md wraps seven of them. A false
positive gets argued with; a false negative is a clean bill nobody earned. The control leg cannot
see it, because a control proves the absence of false positives only. The gate therefore counts the
link syntax it saw and fails on anything it could not parse, rather than skipping it.
MEDIUM — the two documents most worth gating are the two the gate cannot fix. All eleven
findings sat in planner-owned files (CHANGELOG.md, plans/, retro.d/README.md), so the gate
was buildable and the defects it found were not — by the same ownership rule that makes the split
work. Coordination, not escalation: the findings went over as a list with evidence and line
numbers, and the fixes landed on the other side of the boundary while this branch built the gate.
LOW — six of the eleven were quotations, and that is a design constraint rather than noise.
plans/20260807_2143-docs-audit-findings.md quotes other documents' links verbatim; they are
correct relative to docs/ and dead relative to plans/, and correcting the path would falsify
the quotation. A gate that flagged them could only be satisfied by corrupting the quote. Code-span
a quoted link and it is inert — which the audit file already did in one place before anyone asked.
Landing beside a peer — the overlap tool answers a different question (20260823 14:57)¶
MEDIUM — a check named for a problem is not a check for that problem.
tools/shared_file_overlap.py --fetch --strict reads as the collision check between concurrent
sessions, and two sessions ran it that way for a full afternoon. It compares a branch to
origin/main. It never looks at another branch, so it cannot see a peer at all: it reported
none while a coder held 20260823_1424-markdown-link-gate. It was not wrong — the file
intersection genuinely was empty — but it could not have been right, because it was answering
"will this merge cleanly" and being read as "is anyone else in these files". The remedy is a
comm -12 over the two branches' file lists, which is three lines and answers the actual
question.
MEDIUM — the landing order can be forced, and asking your peer will not reveal it. The
convention is that complete, gated work lands first and the other rebases, which frames order as
something two sessions agree. Here it was determined by a gate. The coder's new
tools/markdown_link_gate.py reported 11 broken links and exit non-zero against main, so
its own ./check.sh could not go green until the planner's link fixes landed — its branch was
unable to land, not merely second in a queue. Neither session knew. The coder was running the
gate on its own tree, where it passed; the planner found it by copying the gate out of the peer's
branch and running it against main and against its own worktree. Run the peer's gate, in both
places. A peer's report of its own state is honest and still blind, because the failing
configuration is the one it never runs.
LOW — the same window bites twice, so it is a property of the procedure and not of one tool.
Both sessions' link checkers crashed with FileNotFoundError on a file git ls-files lists and
the disk does not have. That state is not exotic: docs/RELEASING.md step 1 splices fragments and
deletes them, and ./check.sh runs before the commit that removes them from the index. Any gate
enumerating tracked files and opening each one meets it on every release. A tracked-but-absent file
is a normal mid-release state, and the handling is a skip.
The gate that read everything except its own output (20260823 15:11)¶
tools/fragments.py --check had run in check.sh since 0.24.0. It parsed every pending fragment —
category, slug, emptiness, a leading --- fence — and asserted nothing about the document
--apply produces. Three malformed regions sat in CHANGELOG.md through every green run, and the
one that names the class was found by reading a release precedent, not by any gate.
The shape is worth naming, because it is not a missing check — it is a checker pointed one step
short of the thing it exists to protect. Every other gate here reads rows: mkdocs --strict
resolves links, release_order_gate reads sequences, markdown_link_gate reads link targets. A
duplicated heading is a property of adjacency, so all of them walk straight past two identical
headings in a row. Nothing in this repository had ever read the assembled file at all.
The bug this increment shipped, and what caught it¶
The first draft anchored its fence detector at column zero. CHANGELOG.md contains no
column-zero fence; its only fenced block is indented two spaces inside a bullet. So the scanner
skipped nothing in the one document it most needs to read, and a changelog entry demonstrating a
Markdown heading would have been refused as document structure.
It was caught by the test written for it, before landing — the false-positive test, not the
true-positive one. Both were written in the same pass; only one of them found anything. A passing
true-positive says nothing about what the checker is failing to look at. That is the argument for
writing the over-firing case even when the under-firing case is the one the item describes: a gate
refusing a correct document is the failure that gets acted on, and markdown_link_gate's own
docstring records a peer rewriting an example into the wrong form to satisfy a false positive
before disbelieving it.
What the false-positive probe found instead¶
Probing the - rule against Markdown a changelog author might plausibly write turned up a defect
that has nothing to do with this gate. The 0.24.0 front-matter residue — --- / category: added
/ ---, spliced verbatim before check refused that shape — is not inert text. A --- after
a text line underlines it into a setext H2, so each residue renders as a heading titled with
its own metadata. There are five, not the three this module recorded: three in CHANGELOG.md
under ## [0.24.0] and two in docs/RETROSPECTIVES.md, which the original count missed because it
looked only at the stream the refusal was written for. The published site carried
<h2 id="category-lesson">category: lesson</h2> twice — page, permalinks and search index. All
five were removed at 9718aaa, in the same afternoon and by the agent that owns those documents.
Every instrument is green on it. mkdocs build --strict resolves links and a spurious heading is
not a broken link; markdown_link_gate reads link targets; and this increment's own gate misses
it, because it reads ATX headings and this is setext. Which is the same sentence as the item it
closes, one level up: a defect that exists only in the assembled document is invisible to every
instrument pointed somewhere else in it. The documents are the planner's to repair and a setext
rule is a plan decision, so this increment recorded the measurement and changed no behaviour.
The mutation pass, and the survivor that was not mine¶
Twelve mutants, and the first run reported ten killed and two survived. Both survivors were real.
| Mutant | First run | Why |
|---|---|---|
the front-matter refusal no longer reaches --apply |
SURVIVED | 0.27.1's row, de-fanged by this increment. The document gate gave --apply a second reason to refuse that same fragment: a spliced --- is not a - list item either. Every assertion in the test stayed true through the mutant |
| the bullet rule reaches the free-form stream | SURVIVED | my own discriminating row. The fixture was built from ## headings and the bullet rule only ever reads a ###, so the scoping guard could be deleted with the test still green |
The first is the one to remember. Adding a guard can silently un-pin a different guard, when both refuse the same input and the test between them asserts only that it was refused. Nothing about the front-matter check changed; a test that had killed its mutant for three releases stopped, because a second correct behaviour started covering for it. A test that cannot say which guard fired pins neither, the moment a second one exists. It now names the refusal.
The second says the ordinary thing about fixtures: a fixture that omits the construct the rule
reads exercises nothing. The real docs/RETROSPECTIVES.md carries thirty-four ### headings,
every one opening with prose — the fixture now carries one. Re-run: 12 killed, 0 survived.
What the adversarial pass found, and the shape all three share¶
Five lenses over the diff. Three real defects, none of them in the two rules the item decided —
all three in the machinery around them, and every one reproduced end to end (written, fragments
deleted, exit 0, follow-up --check green on the wreckage) before it was touched.
| Defect | Why it was invisible |
|---|---|
_merge_into_section spliced an entry inside a fenced code block |
The checker skips fences; the splicer did not. A column-zero ``` holding ### Added was a heading to one and not the other |
splice would take a quoted ## [Unreleased] as the insertion point |
Same disagreement, one function up — it would bury every future release inside a code block |
--apply was not atomic across streams |
Refusing on the second stream wrote the first and deleted its fragments, then exited 1 saying "Nothing written, no fragment deleted" |
The first two are one sentence: a checker and the code it checks must agree about what a heading
is. Before this increment nothing read the document, so the splicer's fence-blindness had no
second party and was merely latent — genuinely harmless, for eleven releases. Adding a gate that
claims --apply cannot leave the document malformed is what converted a latent asymmetry into a
false claim, and that is why closing it belonged in this change rather than in a new item.
The third is the plainer lesson and the one with teeth: a refusal message is an assertion about the world, and it was false. "Nothing written" was true of the stream that failed and false of the run. A release step is one step or none.
The check stopped being read-only, and that is a cost worth naming¶
The item's stated defect is that --check "asserts nothing about the result of --apply". The
first draft read the file on disk — which asserts something about the result of the previous
--apply, and nothing about the next one, the one the sentence names. So the narrow draft
satisfied a weaker item. --check now validates the assembly the pending fragments would produce.
The evidence is the replay. Both instances the item cites came from a fragment whose body opens
with its own ### Fixed. Run against the tree as it stood at each of those commits, the widened
check exits 1 at the commit that added the fragment — at 0.6.0, whose duplicate was
hand-repaired seven minutes after release, and at 0.28.3, twenty-two days before anyone noticed.
Both exited 0 before, and the failure surfaced at release time — which is exactly when
docs/RELEASING.md has somebody weighing whether to hand-edit a document to get a release out.
And the cost, which a peer named and this increment had not. --check is no longer a
read-only validator: it simulates the write. So --check and --apply must agree about assembly
forever, and a disagreement between them would be silent — --check green on an assembly
--apply would never produce. That is this increment's own failure mode, reintroduced one layer
up. It is not an argument against the widening; it is the argument for making the two the same
code rather than two paths that agree: main calls prospective, the function --check
validates through, and a test imports the module by path and compares the predicted bytes against
what the real subprocess writes. A refactor that gives either path its own assembly turns it red.
Every new guard buys a new invariant. Three of this increment's four defects were the previous instance of that: a checker and a splicer that disagreed about what a heading is, harmless for eleven releases because nothing read the document, and false the moment something did.
The hole that was pinned rather than closed¶
The decided rule is adjacency, because that is the shape the evidence had. A heading that repeats after intervening entries is not caught, and there is now a test asserting so, naming the plan item. Widening the rule fails that test, which reopens the decision instead of quietly changing the gate. A named hole is a decision; an unnamed one is a discovery waiting to happen.
The assembled document nobody reads, and an extent computed after it was destroyed (20260823 16:18)¶
HIGH — a defect can exist only in the assembled document, where no instrument is pointed. Five
blocks of --- / category: <x> / --- survived a splice into CHANGELOG.md and
docs/RETROSPECTIVES.md. They are not inert: --- after a text line is a setext underline,
not a thematic break, so each rendered as an H2 titled with its own metadata — and two of them
have been served from the published site with live permalink anchors. Every gate was green.
mkdocs build --strict resolves links and a spurious heading is not a broken link;
tools/markdown_link_gate.py reads link targets; and the new fragments document-gate reads ATX
headings, so setext is outside it by construction. Three instruments aimed at these two files, and
the defect sat between all of them. The same shape as the item closed hours earlier —
fragments.py --check validating what it reads and never what it writes — which is the tell that
the class is the assembled document, not any one checker.
MEDIUM — a bottom-up edit destroys the markers a top-down extent depends on. The repair had to
delete each residue and re-bullet the entry it preceded, so each entry's extent ran from the
residue to the next structural marker. Deleting bottom-up is the standard way to keep earlier line
numbers valid — and here it was wrong, because a later residue is an earlier entry's stop
marker. The first pass deleted it, the extent then ran on into the following entry, and the result
indented a top-level changelog entry into a nested sub-item of its predecessor. Nothing failed.
No assertion fired; the residues were gone and the spurious headings were gone, so every check the
repair had written for itself said yes. It was caught by reading the rendered structure afterwards
and seeing one NESTED! where a BULLET belonged. Compute every extent against the untouched
document first, then apply. And the older rule that would also have caught it: read the diff an
edit produces, never its anchor.
LOW — the count was five and the record said three, because the record counted one stream.
tools/fragments.py's docstring named three residues in CHANGELOG.md. There were also two in
docs/RETROSPECTIVES.md, missed because the note was written from the refusal that motivated it,
and that refusal is changelog-shaped. A count taken from the thing that prompted the search
inherits the search's scope.
A closing note credited a fix that predated the defect, and the defect recurred twice (20260824 00:23)¶
tools/fragments.py spliced two consecutive ### Fixed headings into CHANGELOG.md at 0.6.0,
and again at 0.28.3, twenty-two days later. Between the two, the item describing it was closed.
The closing note named a mechanism that could not have fixed it, and the evidence for that was
already in git log when the note was written.
| When | What | Verified by |
|---|---|---|
| 20260729 04:20 | _merge_into_section introduced (0.3.0) |
9f7ed5c, the only commit git log -S'_merge_into_section' returns |
| 20260801 10:53 | 0.6.0 ships with two ### Fixed — three days later |
68ca96b; two headings under ## [0.6.0] at that commit |
| 20260801 11:00 | hand-repaired, seven minutes after release | 5920f41 |
| 20260803 22:21 | item closed, crediting _merge_into_section |
5bef91f, into plans/open-corrections.md |
| 20260823 04:13 | 0.28.3 ships the identical defect | 2f633a8 |
The closing row reads: "Fixed with a test (tests/test_fragments.py). _merge_into_section reuses
an existing ### Category heading, bounded to the anchor's own section…" — true of that function,
and irrelevant to the defect, because the row conflates two mechanisms:
what _merge_into_section fixes the section's *prose* already carries the heading
what actually kept happening a *fragment body* opens with its own `### Fixed`,
which `render` then wraps in a second one
Both recurrences are the second mechanism, and it is checkable in one command:
$ git show 68ca96b^:changelog.d/fixed-two-frozen-yaml-behaviours.md | head -1
### Fixed
$ git show 2f633a8^:changelog.d/20260823_0233-fixed-published-versions-row-…md | head -1
### Fixed
Three instruments that reported the previous truth, in one exchange¶
The same afternoon produced three more, all of the same shape — an instrument reporting the previous truth is indistinguishable from one reporting the current truth, unless you know which it is. None of the three is exotic; all three are the everyday tool used the everyday way.
| Instrument | What it actually answers | How it misled |
|---|---|---|
git fetch then reading origin/main |
what did I last hear from the remote | Reported a peer's landing as absent. origin/main is a local cache; only git ls-remote origin refs/heads/main asks the remote |
git log -S <string> -- <path> |
history of this path, stopping at renames | Returned only the rename commit for a plan file, dating a closure 20260804 instead of 20260803. In a repo that renamed every plan to YYYYMMDD_HHMM- retrospectively, every -S in plans/ inherits this; --follow or the pre-rename path is the answer |
awk '…{print NR}' fileA fileB |
cumulative record number across all files | Reported residues in docs/RETROSPECTIVES.md at lines 9718/9748 — the real lines plus CHANGELOG.md's 3957. FNR is the per-file number, and a line number carrying another file's length in it looks exactly like a line number |
The third was mine, in a message asserting a peer's work had not landed. Both halves of that message were wrong, from two different instruments, in the same sentence — and the report read as confident because each number was real. A wrong number that was measured is more persuasive than a guess, which is what makes this class expensive.
The cheap defence is the same in all three: name what the instrument reads before trusting what
it says. A cache is not a remote, a path is not a file's history, and NR is not a line number.
The lesson, which is about closing notes rather than about splicing¶
A wrong closing note is worse than an open item. An open item is a standing invitation to look; a closed one with a plausible mechanism beside it is a reason not to. It spends the evidence — the instance, the diagnosis, the commit that would have shown the dates — and buys nothing, and the next occurrence has to be rediscovered from scratch. This one was: 0.28.3's duplicate was found by reading a release precedent, not by any gate and not by the note.
The check that would have caught it costs one command. Every closing note names a mechanism; a
mechanism has a commit; a commit has a date. Is the fix older than the defect it claims to fix?
git log -S answers it in seconds, and here it would have returned a date three days on the wrong
side. A fix that predates its defect is not a fix — it is a coincidence with a plausible name.
The generalisation, and it is the third instance of one shape in a single afternoon: a green signal is only evidence about the thing it actually measures. A closing note measures whether somebody was satisfied, not whether the defect is gone. A passing true-positive test measures the case it constructs, not what the checker fails to look at. And a test asserting that something was refused stops pinning why the moment a second guard can refuse it too — all three were found here, and all three had been green for weeks.
Two precedents that agree are not a pattern (20260825 00:11)¶
HIGH — a decision arrived pre-framed by two real precedents, and the framing was refuted by the
three it did not mention. The question was whether the owed release was 0.31.0 or 0.30.1. It
was handed over as a genuine fork with evidence on both sides: 0.27.0 was MINOR for a new tool
file; 0.28.3 was PATCH for extending an existing one; 0.27.0's precedent is a new file, this is
not. Every clause of that is true. It is also the two cases out of ten that agree with each
other, and the criterion it implies — new file versus existing file — appears nowhere in the
governing SemVer rule, which says MINOR = new skill, command, capability, or feature and
PATCH = bug fix, doc update, or dependency/component change.
Measured across the ten tooling-only releases (those whose src/ diff is __init__.py alone):
| Claim | Verdict |
|---|---|
| new tool file ⇒ MINOR | refuted. 0.22.2 shipped a brand-new tools/release_order_gate.py as PATCH; 0.27.2 shipped a brand-new tools/wheel_import_gate.py as PATCH, with an ### Added section |
an ### Added section ⇒ MINOR |
refuted. Seven PATCH releases carry one; 0.18.0 is a MINOR carrying none |
| existing tool ⇒ PATCH | 4 of 4 — and the set is not vacuous, which is the check that mattered |
The load-bearing check was whether that last row was an artefact of confounding. If all four had
been ordinary bug fixes to a tool, the axis would have been "fixes are PATCH" and would have said
nothing about adding a capability to an existing tool. It was not: 0.27.1 added a brand-new
refusal to tools/fragments.py — this same file — and was cut PATCH under ### Fixed, and
0.28.1 did the same to release_order_gate.py. The one apparent counterexample, 0.29.0 putting
--check-anchors on the existing mutate.py under a MINOR, is confounded: that release's headline
was five new battery files and --check-anchors is not among its Added bullets.
The generalisation is about the shape of the offer, not about versioning. A framing assembled from two agreeing examples is the most persuasive form a wrong answer takes here, because no sentence in it is false. The defence is not scepticism about the examples — they survive checking — it is enumerating the population they were drawn from. The test set is the one you did not choose.
MEDIUM — the categorisation was the real question, and the implementer is the worst-placed judge
of it. The fragment was named added-…, and changelog.d/README.md makes the filename
authoritative for the assembler. But it was chosen by the session that had just built the thing,
labelling its own increment — the position with the least distance on whether what it built is a
feature. The entry was re-filed under ### Fixed on grounds independent of the version choice, so
the reasoning does not close on itself: it closes an item from a corrections plan, its body is a
defect narrative, and it is replayed against history to show it would have caught two past defects,
which is a fix with a regression test. A PATCH carrying ### Added has happened once in the modern
convention (0.27.2); this does not make it twice.
MEDIUM — %an cannot distinguish the agents in this repository, and the commit body can. All
1029 commits are authored Luca Gattoni <…>, committer identical. Measuring those fields and
generalising to "git cannot answer who wrote this" was wrong, and it was wrong in the specific way
this repository keeps rediscovering: the instrument was named after what it was hoped to answer
rather than after what it reads. The trailers are not uniform — 20+ distinct Claude-Session ids
and four Co-Authored-By models — and they are in %B, which no identity-field query touches.
git show -s --format='%B' <sha> | grep Claude-Session
That turns "was this closing note written by the session that did the work, or by a later one reconstructing it?" into a checkable question, which is precisely the class behind the two defects closed the day before: a closing note crediting a mechanism three days older than the defect, and a message asserting a peer's work had not landed. Both were written by someone reconstructing, and nothing in the repository could say so.
MEDIUM — a section heading is a status claim, and no gate reads it. A ### item in
plans/20260731_1202-open-corrections.md sits under ## Live while its own body, 26 lines down,
carries **CLOSED 20260824 00:35**. grep '^## ' — how anyone triages that file — returns Live
and stops. It cost a real collision: a coder session read it as live and was minutes from
rebuilding a landed increment. The convention it should follow already exists two files away —
T6 and T8 in plans/20260804_1016-template-release.md carry · **CLOSED <date>,
<disposition>** in the ### heading itself — so the repair is conformance rather than design.
Not done yet, deliberately: the item is partial, not closed (the body-rule widening is
explicitly untaken), so moving it into ## Closed — recorded so nobody reopens them would file an
undecided design question under a heading promising the opposite.
LOW, and it recurred twice in one session — a gate is only a gate when its exit status is what
the next command reads. make docs 2>&1 | tail -12 followed by echo "DOCS_EXIT=${PIPESTATUS[0]}"
printed DOCS_EXIT= — empty. The value read was the pipeline's, not the build's. Both instances
were caught and re-run bare, so what actually gated the release was ./check.sh and make docs
redirected to a log with $? read directly. The rule was already written, in CLAUDE.md, and had
been read that same session. A rule that is known and still broken twice in four hours is a rule
whose failure mode is invisible at the moment of committing it, which is the argument for the
redirect-and-read-$? form being the only form ever typed, rather than the one used when it seems
to matter.
LOW — a count word and the list beside it drift independently. docs/STATUS.md held two
instances of forty-seven and exactly one was wrong: the other sits inside 0.30.0's own
published-verification entry, where it was accurate when written. A replace_all would have
silently falsified a historical record. Same family as the | head truncation that shipped a broken
anchor a day earlier — the edit whose anchor matches more than once is the one that needs the
count taken first.
LOW — a peer renaming itself is indistinguishable from a peer dying. This session's name changed
from pinakes-d1 to pinakes-7c mid-work. A coder session correctly observed that pinakes-d1 had
left ListAgents, correctly inferred a usage-limit kill, and wrote a careful handover describing a
release as stranded mid-cut — while it was still moving. Nothing was lost, because the branch had
been committed and pushed at every step, which is the actual defence. But ListAgents cannot
distinguish gone from renamed, so a peer's absence is evidence about the listing and not about
the peer.
LOW, and found by a number that failed to move — tools/markdown_link_gate.py reads
git ls-files, so a file that is written but not yet staged is invisible to it. A full
./check.sh run immediately after writing this very fragment reported 84 ungated file(s), 301
link(s) — byte-identical to the run before the fragment existed. Staging it and re-running gave
85. So the ordinary sequence — write a fragment, run the gate, commit — produces a green that
says nothing whatsoever about the new file, and the first thing to actually read it is CI, after
the push. It is the same sentence as the item this release is about, one directory over: a green
signal is only evidence about the thing it actually measures, and here the thing measured is the
index rather than the working tree. Nothing was wrong with the fragment; the gate simply had not
been shown it. git add before the last gate run, not after — which is also the only ordering
under which the gate's answer describes the commit about to be made.
HIGH — a sweep for one direction of error is blind to its mirror, and the mirror is where the
confident mistakes live. A peer session swept plans/ for work filed as done that is not. Its
single strongest-worded finding was the opposite kind and it was wrong: sidecar.py:125-127 really
does accept a uniformly non-string-keyed mapping and silently coerce it, and Decision 19 really is
marked shipped in 0.5.0 — but the decision's own sentence reads "Top level only", so a
nested mapping is outside its scope by construction, and the behaviour is separately recorded at
plans/20260731_1202-open-corrections.md:205 under ## Not to be fixed — recorded so nobody
tries. Behaviour filed as never-to-be-done was read as undone.
## Not to be fixed is exactly as load-bearing as ## Closed, and strictly easier to miss: a
sweep hunting false closure has no reason to open it, and a vocabulary grep has no token for it. The
confidence came from having read the code, which was accurate; what decided the question was the
decision's own sentence, which had not been read. Withdrawn by its author on being shown both lines.
HIGH — the filter that decides what gets examined is itself unexamined. That sweep classified
each ## section LIVE / CLOSED / MIXED from its heading, and sent only the first two for a
semantic read. Nine of twenty files were dropped unread, and at least one wrongly:
plans/20260804_1844-decision-parent-child-arity.md's ## The decision was classified CLOSED while
holding the file's only outstanding work — a measured ceiling required before G5's gate runs. A
section named for a decision is not a section whose decision has been discharged.
Correction, 20260825 13:12 — the lesson holds, the example was wrong. The full re-read (X3) confirmed
## The decisiondoes hold live work, so the finding above stands. But it is not the ceiling: that was measured the same day and is recorded far earlier in this file, under theparent-childarity entry — locate it by that heading, not by a line number — at 53.42parent-childrows per chunk, 94.7% of stored edges, +113.4% index growth. The live item is requirement 3 — if the ceiling is alarming, the immediate-parent variant is the arm to measure — and no such arm exists (tools/graph_matrix.pyLEGS has seven legs and none is one). The sweep that found a heading-level claim was wrong then made a body-level claim that was also wrong, and it survived into a retrospective, which is the same defect one layer down. The number worth carrying forward is therefore 11 of 20 files read, not the finding count; a report's coverage is a property of its filter, and a filter that reads headings inherits every defect that headings have. That is the same shape as the item three paragraphs up — one instance found and generalised — one level higher: the classifier looked at eleven files and silently declined nine.
MEDIUM — the stale-section-under-a-current-banner defect has a second, larger instance.
plans/20260729_0256-links-and-graph.md's entire ## Baseline asserts a world that ended at
0.11.0, while the file's own banner at line 8 already says so. A reader going top-down meets
"G3 is the next increment to build" (:69), "Do not start any of them" (:82), "One cut
remains … it is blocked, not pending" (:103) and "G3 is the increment to pick up" (:124)
before reaching the correction. Verified against the tree rather than against the plan:
store.py:28 reads SCHEMA_VERSION: Final = 3 and the schema carries both nodes and edges, so
G3, G5 and G6 all shipped. None of it matches any status vocabulary; a grep finds nothing here.
And the fourth class is now demonstrated rather than hypothesised — work silently overtaken.
:642 states "Its exit criterion is not discharged by any test." It is:
tests/test_manifest_compat.py::test_a_manifest_requiring_a_newer_pinakes_names_the_version exists
and asserts exactly it. Something closed the requirement and never came back to say so, which no
search for a status word can reach — the sentence is a claim about the test suite, and only the
test suite can refute it.
The check that was not git — and the mutant that was not a test (20260825 00:14)¶
HIGH — a substring test stood in for git's ignore semantics, and it was wrong in both
directions. pnk init decided whether a KB's .pinakes/ could reach a remote with
".pinakes/" not in gitignore.read_text(). Four failure modes, all reproduced by running rather
than by reading:
.gitignore contains |
git ignores .pinakes/? |
init said |
|
|---|---|---|---|
.pinakes (no slash) |
yes | ⚠️ warns | false positive |
.pin* |
yes | ⚠️ warns | false positive |
.pinakes/ then !.pinakes/ |
no | silent | false negative |
#.pinakes/ |
no | silent | false negative, with a consequence |
The two false positives train a user to disregard the warning. The false negatives are worse
because they are silent, and the commented-out line is the cheapest way to reach one: comment it
out to debug, re-run init, and the ledger and every transcript are tracked with nothing said.
The general shape: a cheap proxy for an authority, where the authority was one subprocess away.
git already knows what git ignores — it reads nested ignore files, .git/info/exclude, the user's
global excludes and a parent repository's rules, none of which any amount of reading one
.gitignore can see. The proxy was not a simplification of the rule; it was a different rule that
agreed with it on the common case.
MEDIUM — the measurement trap that makes this easy to get wrong twice.
git check-ignore .pinakes returns not ignored for the pattern .pinakes/ whenever the
directory does not exist on disk: a trailing slash only matches a path git can already see is a
directory. At init time .pinakes/ has never been created, so the obvious probe answers
"unprotected" for the very pattern init itself writes. A path inside the directory carries the
directory in its own name and answers correctly either way. Anyone re-testing this by hand must
mkdir .pinakes first, or a correct pattern will look broken.
MEDIUM — check-ignore exits 0 when any argument is ignored, not when all are. Probing three
paths and reading the exit status would have called a .gitignore naming only the ledger full
protection, while the index stayed tracked. The matched paths have to be counted.
HIGH — the survivor was the useful half of the mutation run, and it found redundant code rather
than a missing test. Seven mutants; six killed. The survivor deleted an explicit
startswith("#") comment-skip from the offline fallback, and every test stayed green. Not a
coverage gap: "#.pinakes/".rstrip("/") is "#.pinakes", which already fails a whole-line
equality test, so the skip never decided anything. The code claimed a care it did not exercise,
and the docstring said so in as many words. The loop became one any(...), and the row now
mutates the property that is really load-bearing — whole-line equality against
".pinakes" in text, which is the shipped defect. A SURVIVED row is a claim about a pair, and
this time the wrong half was the code.
MEDIUM — the tests answered from the developer's machine. check-ignore honours a global
core.excludesFile and a system config. A developer with .pinakes/ in their personal global
ignore file — a sensible thing to have — turned three of these tests red and made two others pass
for a reason the code had nothing to do with (measured: 3 failed, 9 passed). An autouse fixture
points GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull. The production code still
honours those files, because a global ignore really does protect the ledger; only the tests must
not depend on which machine runs them. A test that shells out inherits the whole environment,
and the environment is not part of the fixture unless it is made to be.
HIGH — the replacement's first draft was a regression, and only an adversarial pass found it.
It probed three named files: index.db, ledger.jsonl, deep/transcript.json. A .gitignore
carrying *.db and *.json — an ordinary thing to write — ignores all three, so the new check
reported protected while index.db-wal stayed tracked, and in WAL mode that file holds
megabytes of verbatim document text. The substring test being replaced warned there. The
general shape is worth more than the instance: a check that answers about three filenames cannot
answer a question about a directory, and the docstring's careful reasoning that every probe must
match made the answer sound complete when the probe set was the incomplete part. A sound argument over
an unstated domain — every clause true, the population unexamined, which is the same skeleton as
the version framing that opened this session and as the status-word grep that swept plans/. Three
instances in one night, in three unrelated places. The probes are now opaque random paths only a
directory-covering rule can match.
MEDIUM — a reviewer's fix is a hypothesis exactly like the code it corrects. The
suggestion was two opaque probes, flat and nested. Measured against ground truth — git add -A
then git ls-files --cached, so the oracle never goes through check-ignore — that set still
reports protected for .pinakes/* with !.pinakes/cache, which tracks the entire extraction
cache. Four probes, two of them under cache/extract/ and deep/, is what passes all eleven
configurations. The review was right about the defect and wrong about the fix, and only
measurement separated those two — a distinction that disappears if a reviewer's remedy is applied
because the reviewer was right about the problem.
MEDIUM — a hand-rolled fallback disagreed with the thing it was standing in for. Outside a
repository the text scan read .pinakes/ followed by !.pinakes/ as protection — silently, on the
very input the in-repo tests assert must warn — and warned about /.pinakes/, which git honours.
Two definitions of one answer will diverge; the fallback now copies the .gitignore into a
throwaway repository and runs the identical probes, and reading the file is reached only when there
is no git at all.
MEDIUM — the increment had quietly widened its own trigger, and justified it with something
false. The check was made to run whether the .gitignore was adopted or written, on the reasoning
that an ancestor repository could negate what init wrote. It cannot: git resolves ignore rules by
directory depth, so the file written in the KB wins over any ancestor. What the widening did
reach was a path already in the index, where check-ignore reports not-ignored and the warning
would tell the user to add a line their file already contains. Narrowed back, and the message that
case produces was fixed rather than left: the printed remedy must never instruct an action that
would not change the verdict, which the old text could not violate — under a substring test the
string's presence was the verdict, so a redundant remedy was impossible. Asking git split
"unprotected" from "the line is missing" into two facts and the message went on assuming they were
one. A capability arrives with the failure modes it makes reachable, and they are invisible from
inside the change that adds it. Scope creep that survives is usually scope creep with a
plausible comment attached.
HIGH — two mutation survivors, and both were about the test rather than the code. The first
deleted a startswith("#") comment-skip and nothing went red: "#.pinakes/".rstrip("/") already
fails whole-line equality, so the skip decided nothing and the docstring claimed a care it did not
exercise. The second was len(matched) > 0 surviving against
test_ignoring_only_part_of_pinakes_is_not_protection — a test whose name is that assertion. Its
input had been a .gitignore naming only the ledger, which discriminated while the probes were
named files and stopped discriminating the moment they became opaque: no probe matches, so "any"
and "all" agree. A test can stop pinning the property it is named for without changing a
character, when what changed is the code it points at.
MEDIUM — the tests answered from the developer's machine. check-ignore honours a global
core.excludesFile and a system config. A developer with .pinakes/ in their personal global
ignore file — a sensible thing to have — turned three of these tests red and made two others pass
for a reason the code had nothing to do with (measured: 3 failed, 9 passed). An autouse fixture
points GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull. The production code still
honours those files, because a global ignore really does protect the ledger; only the tests must
not depend on which machine runs them. A test that shells out inherits the whole environment, and
the environment is not part of the fixture unless it is made to be.
MEDIUM — shelling out brought six failure modes that had nothing to do with ignore rules. No
timeout, so a wedged core.fsmonitor hook blocks init forever — after pinakes.toml is
written, leaving a KB the next pnk init refuses as already a KB. An inherited GIT_DIR answering
for an unrelated repository, which git exports to every hook it runs. Exit 1 read as an
authoritative not ignored when git had in fact printed a warning. text=True decoding strictly
inside subprocess.run, past except OSError. An inherited stdin. And a non-UTF-8 .gitignore
aborting with a traceback. Each has a test. The cost of asking an authority is that you inherit
its whole process model, not just its answer.
HIGH — 7/7 killed is a ratio with an unstated denominator, and it was read as an answer. The
battery was green about the code that had been written, not about the code that should have been;
the regression above sat outside every mutant in it, because the mutants were derived from the same
understanding that produced the defect. It was reported as "7/7 killed, fragments written" and
read as completeness by both the implementer who wrote it and the planner who received it. Neither
asked what the battery covered. A green battery is a claim about the mutants someone thought of.
The defence is not that someone should have been more careful — it is that a ratio whose
denominator is unstated should read as a question rather than as an answer, and this one names the
same skeleton as everything else here: a sound argument over an unexamined domain.
This is the first mutation battery under src/. Every other one covers tools/, which
tools/batteries/README.md records as a limitation rather than a design — "no module under
src/ has one, and no invariant in INVARIANTS is covered." The decision that keeps an index, a
spend ledger and a set of verbatim questions off a remote is a reasonable place to stop being able
to say that. Updating that sentence surfaced that it was already stale: it said "Four batteries,
four primary targets" with five on disk. The paragraph that exists to prevent a hidden denominator
had one.
Scope, deliberately held. Only the existing check's correctness changed — and its trigger, once
widened, was put back. Whether pnk doctor should carry a recurring check, and whether it reports a
warning or a note, is a decision reserved to the planner and the user, and this measurement changes
it rather than settling it: a detector that misfires on .pinakes and .pin* is a poor candidate
for firing on every doctor run, and a correct one is a much better one.
The diagnostic that printed nothing, and a review of a moving target (20260825 00:53)¶
HIGH — the fix for "a remedy that changes nothing" was an instruction that shows nothing. The
message printed git check-ignore -v .pinakes/index.db. That command reports only positively
matched paths, and the branch printing it exists precisely because the path is not matched, so
it emitted nothing at all. One defect replaced by the same defect one step along, inside the commit
whose subject line forbade the first.
The test is why it shipped, and the shape is general. It asserted the message contained the
string git check-ignore -v. Asserting that a message mentions a command is not a test that the
command helps. The replacement extracts the suggested line out of the printed warning, runs it,
and asserts both that it produces output and that the output contains the negation the user has to
remove. A test that reads a string can only ever verify the string.
MEDIUM — and the sentence describing the replacement overclaimed in turn, which is the fourth
instance today. The proposed docs line said the command shows "the ! that defeats the rule".
It shows the lines that name .pinakes — so when the defeating pattern is spelled some other
way (!.pin*, !.pi?akes/, a broader !*, all of which reach the same branch and were verified
to) the line that actually causes the problem is not printed. Caught by a reviewer running a case
the author had not chosen, which is what caught the three before it. The typical case described
as though it were the only case — the same shape as the probe set, the version framing and the
status-word grep, in four unrelated places in one night.
Left deliberately, and named here so it is not rediscovered as a defect. A diagnostic that
always shows the conflicting rule is a larger design question — cat -n .gitignore? a
check-ignore -v against a path git does match? — and it belongs with the rest of the reserved
gitignore work rather than in a release being held. The shipped command prints something real in
every case the branch fires, which is the improvement; it is not guaranteed to print the cause.
MEDIUM — a review is a measurement with a timestamp. The adversarial pass took long enough that the branch moved five times under it, and reviewers measured different commits: several findings labelled critical were already fixed when they were written, and the adjudicator had to re-run two survivor claims to find both already killed. Its own caution is the lesson — a long review of a moving branch measures a tree nobody will ever ship — and the response is not to review faster but to re-verify every finding against the tree as it stands before acting on its severity.
MEDIUM — the same test-hygiene defect, one layer further out. An earlier fix in this line
pointed GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at os.devnull so a developer's global
excludes could not decide the answer. It closed the config leak and left the ancestor
repository open: with pytest --basetemp inside a checkout that ignores .pinakes/, three tests
failed and two of them are named outside a repository while running inside one. The fixture
covered the half that had been thought of — the same sentence as the mutation battery two hours
earlier, about a different artefact.
MEDIUM — two things were in one list because they share a prefix. GIT_DIR and
GIT_WORK_TREE redirect git at a different repository and must never be honoured here.
GIT_CEILING_DIRECTORIES only limits upward discovery, so honouring it makes this check agree
with the user's own git add and scrubbing it made the two disagree. Naming a constant for what
its members look like rather than for what they do is how the wrong one gets in.
And a note on the review arrangement itself, because it worked in both directions. The planner
amended a docs/CLI.md sentence specifically to stop it overclaiming what the code does — and
approved the correction by comparing the prose to the implementer's description of the code
rather than running the command. A description-versus-code check performed entirely on
descriptions. Caught within the hour by an independent pass, which is the argument for having one.
Planning the exposure question — a check that cannot see the case it is for (20260825 07:55)¶
HIGH — the 0.30.2 gitignore detector reports protected for a repository that is committing the
user's verbatim questions, and the reason is the fix that made it correct. The detector probes
opaque random tokens under .pinakes/, chosen on 20260825 so that no realistic pattern could
target them — which had been a real regression when three named files were covered by an ordinary
*.db/*.json pair. But a path that does not exist cannot be in the index, and
git check-ignore consults the index: on a tracked path it answers not ignored, and only
--no-index answers ignored. So the probes can ask exactly one question — would a new file
here be ignored? — and never the other one — is anything in there tracked right now?
Measured, not reasoned: a repository with .pinakes/{deep/op1.json,ledger.jsonl,index.db}
committed, then a correct .gitignore added, still lists all three in git ls-files, still
commits the edited transcript under git commit -a — and _ignored_by_git returns True.
The general shape, and it is the fourth instance in two days: a fix that removes a false positive can install a permanent blind spot, and nothing reports the trade. The opacity that made the detector right about patterns is exactly what makes it blind to the index. Neither property was written down, so the boundary was invisible to review.
MEDIUM — a correct .gitignore does not untrack an already-tracked file, and Pinakes says so
nowhere. grep -rniE "rm --cached|untrack|already[ -]tracked|already in the index" over
*.py *.md *.toml *.sh *.yaml returns only tools/template_drift_gate.py, tools/mutate.py
and a retrospective about the wheel. This matters because init.py already states the rule it
breaks — "the printed remedy must never instruct an action that would not change the verdict" —
and for a tracked-and-unignored KB, "add this line" flips the verdict to protected while changing
nothing about the exposure.
HIGH — a section heading is a status claim, and it produced the same collision twice in two days.
plans/20260731_1202-open-corrections.md carries an item under ## Live whose body says
CLOSED 20260824 00:35 twenty-six lines below its own **Decided.** line. On 20260824 a coder
session read it as live and was about to rebuild a landed increment. On 20260825 a second,
freshly-cleared coder session did the identical thing and offered to build it again. Both times
the save was a peer message; neither time was it a gate. grep '^## ' returns Live and stops.
MEDIUM — sed -i '' "s/X\bY/" on macOS silently matches nothing, and a success message computed
before the operation will not notice. Renumbering four colliding decision IDs, the loop printed
"7 line(s) rewritten" — which was grep's pre-count, not sed's result. BSD sed does
not implement \b. The file was unchanged and the log said otherwise. Reading the file back
afterwards is what caught it; perl -pi -e is what fixed it. A count taken before an operation
describes the input, never the outcome.
HIGH — the proposed fix for that blind spot failed in the blind spot's own direction, and two
sessions caught it only by running it. The first draft specified git ls-files -- .pinakes, with a
relative pathspec. Run from a subdirectory of a repository that is tracking three files under
.pinakes/, it returns rc=0 with zero rows — and a genuinely clean repository returns
rc=0 with zero rows as well. pnk init can be run from anywhere, so the two states are
reachable, identical at runtime, and the wrong one reads as safe. The absolute pathspec returns
three rows from the same directory. A check that answers clean because it was asked the wrong
question is exactly what the increment it corrects existed to remove, so the fix has to make the
pathspec structural — build it from the resolved root, and test it from a subdirectory, because
the failing case is invisible from the root.
MEDIUM — a remedy whose steps are right and whose order is wrong fails silently. In a repository
with no ignore line, git rm -r --cached .pinakes followed by an ordinary git add -A puts the file
straight back in the index (measured: one file, immediately). The ignore line has to come first.
The reverse order looks like it worked and reverts on the user's next add.
HIGH — knowing a hazard is not what avoids it; running the command bare is. The boundary table
above was first read through ... 2>&1 | head -3, which reported the outside-a-repository case as
rc=0 instead of 128 — the pipe's status, not git's. This repository had already recorded that
exact hazard, twice, and the session that walked into it had read the entry that morning, within the
hour. The entry is therefore restated in its stronger form: the rule does not fire on
recognition. The only thing that separates a real exit status from a plausible one is running the
gate bare and reading $?.
HIGH — the plan wrote a constraint from a measurement taken in the wrong place. § X1 required
the tracked-check's pathspec to be absolute and prescribed the test that would prove it: "run it
from a subdirectory — the case that fails is invisible from the root." The property is real. The
prescribed test cannot fail, because _ask_git passes cwd=root to subprocess.run
(init.py:94) and every call site passes root — so the process cwd never reaches git and a
relative .pinakes resolves against root regardless. The reproduction behind the constraint was a
shell fact, run with cwd=<subdir>, a state this code never produces. It was promoted to a
claim about a code path without anyone opening that code path's cwd.
The domain nobody examined was the instrument. Every other item here is a claim whose population went unstated; this one is a test whose ability to observe anything went unstated. The argument for the property was sound and stays sound — the absolute form survives as defence. What was wrong was the belief that a named test would pin it. A prescribed test is a claim about a failing test, and whoever prescribes it owes the same evidence as whoever writes it. The increment's own retrospective carries the mutation run that settled it, and the finding is recorded there rather than here.
MEDIUM — the planner produced the day's own lesson while writing the plan about it. I told a
coder that 36 unrowed test_fragments.py tests were a gap to fill. It counted the population
instead: 2023 tests defined, 1106 named in docs/VERIFICATION.md, 917 with no row — and the
document's own scope section says it maps promises to tests, naming six modules that predate the
table. A gap of 917 cannot all be holes. The coder declined, correctly, and cited a peer message is
coordination, never permission. Two honest counts of that same population differed by twelve
(1106/917 against 1094/929) purely because the matching rule differed, and neither number carries
its rule.
LOW — zsh does not word-split unquoted parameter expansions, so set -- $pair inside a
for loop passes one argument, not two. The anchor assertion caught it before any write. Worth
keeping only because the assertion is the reason: the guard that looked like ceremony was the thing
that turned a silent no-op into a stop.
X1 — building the tracked-KB check, and an instrument that could not fail (20260825 08:16)¶
HIGH — the specification asked for a test that cannot fail, and only writing it revealed that.
The plan bound the implementation to three constraints, the first being "the function takes the
resolved KB root and builds the absolute pathspec itself… its test must run it from a
subdirectory — the case that fails is invisible from the root." That constraint was written from
a real, reproduced measurement: git ls-files -- .pinakes run from a subdirectory exits 0 with no
rows against a repository tracking three files. Two sessions reproduced it independently.
It is still not reachable through this code, and the test it prescribes proves nothing.
_ask_git passes cwd=root to subprocess.run, so the process cwd never reaches git: a bare
.pinakes resolves against root and returns the identical verdict from a subdirectory, and with
a relative root. Measured rather than argued — with the pathspec mutated to ".pinakes", 68 of
the 69 tests in tests/test_init.py pass. A behavioural subdirectory test would have been green
against the exact form it existed to forbid.
So the constraint moved to where it can fail. The pathspec is built by its own function and the
test asserts the pathspec — :(literal) prefix, absolute, ending in .pinakes — which is the
one test the relative form kills. The absolute-and-literal form stays, because correctness of the
relative form depends entirely on _ask_git keeping cwd=root, a coupling no caller can see, and
because a KB root may legally contain * or [. It is defensive rather than a bug fix, and
the docstring says so instead of implying coverage it does not have.
The general form is worth more than the instance. "Pinned by test X" is a claim about a
failing test was already the rule here. This adds the case the rule does not obviously cover: a
constraint can name a real property, be derived from a real measurement, and still
prescribe an instrument that cannot observe it. The argument was sound; the domain it quantified
over — this call path, rather than the shell where the measurement was taken — was never
examined. Neither session checked it. It surfaced only because the implementer refused to write
test_…_from_a_subdirectory without first watching it go red.
A second gap the same discipline found. pinakes_tracked is a bool, so None and False
collapse into one field and no test through init can tell them apart — an implementation
returning False outside a repository (exit 128, fatal: on stderr) passed every test written.
The constraint that unknown must never read as clean is now asserted against _tracked_by_git
directly. A field's type can erase a distinction the specification depends on.
And the pipe hazard fired again, on the person who had just written it up. The full gate was
run as ./check.sh > log 2>&1; echo "EXIT=$?" | tee -a log; tail -20 log. The harness reported
exit code 0; the gate had exited 1, on two reportPrivateUsage errors. The $? was
captured correctly and then discarded by the commands after it — a gate is only a gate when its
exit status is what the next command reads, missed by someone who had recorded that sentence
twice the same evening. Recognition is not the mechanism. Writing the status to its own file,
and reading that file, is. Three instances of one class in one evening, each caught by running
something rather than by reading it — and this one caught only because the log was read after the
notification said the opposite.
MEDIUM — and the fourth instance of this increment's own shape: a CI watcher that was green for a
reason nobody had checked. Its exit condition was "all visible runs are complete". That is
unsound in general — with staggered run creation it exits before the remaining runs exist and
reports success for a run that was never created. Here it gave the right answer, because exactly one
run was ever going to be created: docs is path-filtered to docs/**, mkdocs.yml,
mkdocs_hooks.py, requirements-docs.txt and its own workflow, and this increment touched none of
them. But that fact was established after the watcher was armed, not before. The instrument was
sound by luck over a domain its author had not examined — written an hour after the same author
wrote that the instrument is the part nobody checks.
A second watcher, on the planner's side, failed the same way and was not saved by luck:
gh run list --commit <short-sha> returns empty, so an until [ ... = "completed" ] loop could
never terminate and would have polled in silence until timeout. --commit requires the full
sha. Silence from a watcher is indistinguishable from still running, which is why both were
rewritten to emit on every terminal non-success state rather than to wait for a success that may
never arrive.
Design review passes 1–7 (pre-implementation)¶
Seven adversarial passes over DESIGN.md before any code was written — 58 findings
resolved (11 HIGH, 32 MEDIUM, 15 LOW). Moved here 20260728 16:40 from DESIGN.md §10, so that all
project history lives in one file and the design document is specification only.
The headline lesson, visible only across the whole sequence: passes 2 and 4 fixed defects that passes 1 and 3 had themselves introduced. That is the argument for looping a review rather than running it once — and the same argument the per-increment retrospectives above rest on.
Pass 1 — 6 HIGH, 15 MEDIUM, 5 LOW resolved.
HIGH: sqlite-vec wrongly described as an ANN index (verified false upstream — §3.1 rewritten and
the tiering rationale corrected to bounded memory); reverse cross-KB links specified against the
other KB's gitignored index, impossible after clone (now scans committed sidecars, §6.2);
pnk:// URIs used local aliases, breaking on share (now KB ULIDs, §2.2); rename/orphan/duplicate-ID
sync semantics unspecified (§6.4 added); per-operation budget cap claimed a guarantee it could not
deliver post-hoc (now pre-call reservation, §5); v0.1 omitted pnk sync, pnk doctor and hooks
though every other section depended on them (§8).
MEDIUM: MCP tools renamed kb_* → pinakes_* for namespace safety; multi-hop scope stated as
single-KB in v0.1; "no network" qualified against first-use model download and weights moved to the
shared HF cache; embedding storage described two ways, unified on a float32 BLOB; confidence signal
recast as calibrated with term-coverage demoted to a tiebreak; token limits validated against the
model's own tokenizer; template versioning decoupled from package version; install line corrected to
uvx --from "pinakes[st]" pnk with core-only behaviour defined; sync partial-failure semantics and
failures table added; WAL/read-only/lock concurrency policy added (§6.5); orphaned-sidecar deletion
made opt-in; paths fixed as KB-root-relative; index migration policy stated as rebuild-only; ledger
privacy and append atomicity specified; pnk build unified into pnk sync --rebuild.
LOW: budget window timezone; FTS5 external-content triggers; RRF k=60; latency claim replaced with
a measured 2.25 ms at 50k×384; golden-set size and coverage targets.
Pass 2 — 1 HIGH, 7 MEDIUM, 5 LOW resolved. Several were introduced by pass 1's fixes, which is
the argument for looping rather than reviewing once.
HIGH: the --rebuild swap added in pass 1 renamed a WAL-mode database without checkpointing,
leaving a stale -wal beside a new index.db — a corrupt read. Now checkpoint-truncate, clean
close, then rename (§6.5).
MEDIUM: "operation" undefined for the per-op cap, letting an N-step --deep loop spend N× the
limit (§5); §4.2 referenced calibration thresholds the manifest had no field for (§2.1); the links
schema could not represent a reverse link, whose source doc lives in another KB (src_kb_id +
origin enum added); §3.1 presented three tiers as if all shipped, with v0.1 behaviour above 50k
chunks undefined; duplicate-content files made hash-based rename detection ambiguous with no
tie-break (§6.4); MCP server boundary and prompt-injection posture unstated (§4.7); FTS5 /
enable_load_extension treated as universally available — verified present on uv-managed CPython
3.13, now probed by pnk doctor.
LOW: a single top_k covered three different cut-offs (split into candidates_per_source /
fusion_top_k / final_k); max_tokens sat under [embedding] though §4.6 treats it as chunking;
[[links.kb]] present from v0.1 but unused until v0.3, now labelled; what publishing a KB repo
exposes; reverse-link origin provenance.
Pass 3 — 1 HIGH, 3 MEDIUM, 4 LOW resolved.
HIGH: §6.3 said --rebuild "discards .pinakes/", which would delete ledger.jsonl — the spend
history §5's rolling budget is computed from. A routine maintenance command would have silently reset
the budget. Rebuild now replaces index.db only; cache/ clearing is opt-in.
MEDIUM: the server's staleness check read meta.build_id through its own open connection, which
after a rename still points at the old inode and would report the old id forever — replaced with a
per-request stat() on the path (§6.5); per_operation_eur served as both the confirm threshold and
the hard ceiling, making the confirmation prompt unreachable (split into confirm_above_eur +
per_operation_eur, §2.1/§5); §6.4 framed pairing as ordered per-file rules, but rename and
duplicate detection require the whole before/after set — restated as an explicit two-phase algorithm.
LOW: v0.1's pnk doctor list omitted the environment probe §3.1 depends on, and pnk serve was
referenced in §4.5 but absent from the release list; "aliases … never stored" contradicted the
manifest that stores them (clarified: never inside a URI); the reservation formula reused the name
max_tokens, which [chunking] already claims; "not in v0.1 but present from day one" reworded.
Pass 4 — 2 MEDIUM resolved, both self-inflicted by pass 3.
The rebuild bullet still ended "readers detect the new build_id and reopen" — directly contradicting
the stat()-based detection added three lines above it in the same pass (§6.5, now reconciled;
build_id is retained for provenance only). And pnk://self/… was left unexpanded, so a sidecar
copied into another KB would silently retarget its link at the new KB — self is now expanded to
the owning KB's ULID on write, like every other alias (§2.2). A grep sweep confirmed no stale
kb_* tool names, pnk build, or bare top_k references survive outside the log.
Pass 5 — 0 findings. Verified by re-reading §§1–10 in full and grepping for every identifier
renamed across passes 1–4. No section contradicts another; every external claim (sqlite-vec is
exhaustive not ANN, FTS5 + extension loading on uv-managed CPython 3.13, pinakes free on PyPI,
2.25 ms at 50k×384) was measured or fetched in-session rather than recalled; every locked constraint
is honoured; every capability in §1 maps to a release in §8. Review complete.
Pass 6 (20260725 09:28, implementation-readiness review) — 2 HIGH, 2 MEDIUM, 1 LOW resolved; the two
product calls were decided by the user, not the review.
HIGH: the reranker was simultaneously a v0.1 default (rerank = "local" in §2.1, "on by default"
in §4.1, its scores the substrate of §4.2's confidence signal, "rerank precision" in §7's v0.1 CI)
and a v0.5 deliverable in §8 — a freshly-inited KB would have defaulted to a stage that didn't
exist, and v0.1 would have shipped with no defined confidence signal. Resolved: the reranker ships
in v0.1; default BAAI/bge-reranker-base (user decision — same id on both backends beats the
smaller ms-marco model's provider-specific ids), a [rerank] manifest block mirroring
[embedding], fitted_for added to [retrieval.confidence], and a CI HF_HOME cache so ~1.4GB
of weights download per cache key, not per job (§2.1, §4.5, §8). And §8's v0.1 had no CLI query
surface at all — pnk search existed in §4.2's escalation story, the CLI stub and the README, but
not in the release that claims "end to end". Added explicitly (§8).
MEDIUM: the post-commit hook wrote sidecars, dirtying the tree it had just committed — every
document commit would trail an untracked .pnk.yaml forever. Resolved with a three-hook split:
pre-commit mints and stages sidecars for staged documents only, post-commit/post-merge touch
the index only (§6.3). And a stale sync.lock from a killed sync silently disabled hook-driven
freshness forever ("a second sync exits immediately" had no liveness story). Resolved: the lock
records pid/host/start-time; dead-pid locks are reclaimed with a warning, cross-host locks refuse
with --force-unlock as the human path, pnk doctor reports held locks (§6.5).
LOW: the sidecar's content_hash duplicated documents.content_hash, was read by nothing, and
guaranteed a two-file diff on every document edit while going stale whenever sync hadn't run —
dropped from the sidecar (user decision); change detection is index-only, stated in §2.2.
Pass 7 (20260725 09:52, surfaced while adversarially reviewing plans/20260725_1317-v0.1.md — the implementation
plan's review loop reads the design fresh each pass, which is how these escaped passes 1–6).
HIGH: §4.5 claimed model weights go to the shared HF cache on both backends — false for fastembed,
which defaults to $TMPDIR/fastembed_cache (verified upstream): CI's HF_HOME cache would never
hit and pnk doctor's weights check would probe the wrong directory. The fastembed backend now
passes an explicit cache dir under HF_HOME, making the claim true by construction.
MEDIUM: a sidecar-only edit (tags/title/links changed, document untouched) fell through §6.4's
"path and hash unchanged → Skip" and was never re-indexed — documents.sidecar_hash added (§3) and
the sidecar-only change class stated (§6.4); soft delete left chunks and embeddings searchable —
removal on soft delete stated, identity row retained (§6.4); rename+edit in one sync had both the
adoption and deletion rows firing for the same ID with no stated winner — sidecar adoption now wins,
no soft delete emitted, and the sidecar-didn't-travel case is reported at sync time (§6.4).