Tensor-Grep Failure Archaeology
A chronicle of settled battles in tensor-grep: expensive fights that already reached a
verdict. Each entry is symptom -> root cause -> evidence -> status so a future engineer or
model does not burn a day re-discovering the same wall.
Facts below were verified against the repo on 2026-07-02 at v1.17.25, with a second pass on
2026-07-03 at v1.19.3 (Battles 9-14), a third pass adding Battle 15 (release-tag-smoke), a
fourth pass 2026-07-16 at v1.78.1 fixing the stale case_sensitive KNOWN_GAP claim in Battle 9 and
adding Battle 16 (the tg find Opus-gate catches), a fifth pass 2026-07-22 at v1.93.2 adding
Battles 17-22 (the CEO deep-research campaign's research retirements — cAST, dense int8, warm-session,
GPU-for-search, the many-pattern dedup bug, and the "5/5 mirage" meta-lesson), and a sixth pass
2026-07-24 at v1.96.0 adding Battles 23-24 (the language-expansion campaign's stale onboarding-brief
catch and its sequential multi-PR shared-file union-rebase discipline), a warm-hides-cold addendum to
Battle 12, and re-pointing Battle 16's evidence at its post-squash commit hashes (501dc26/6d79945)
plus fixing a stale AGENTS.md section-letter reference, and a seventh, same-day pass 2026-07-24 at
v1.98.2 adding Battle 25 (the banked C function-pointer fix hypothesis, falsified before any code was
written), and an eighth pass 2026-07-31 at v1.101.24 adding Battles 26-29 (the defaulted-scope
disclosure census undercount, the anonymous --claim sentinel-id retention, the MaxSim rerank-extra
deliberate hold, and the #868 GPU exit-2 investigation's two false-confirmation traps), a ninth pass
2026-08-01 at v1.101.27 adding Battle 30 (the -q false-zero shipped in the shared _build_cmd,
#876/#880), and a tenth retention pass 2026-08-12 that marked Battle 28 RETIRED (task F10, per the
retrieval_late.py module header), Battle 29 SETTLED/RETIRED (commit 50595ef — the exit-2 rule
itself was retired), scoped Battle 20's no-crossover claim to single-pattern + fair public baselines,
corrected Battle 21's #694 description (the guard pins the CORRECT behavior, not the bug) and Battle
23's lang_java.py claim, and reconciled Battle 22's steal-list count to its actual 6-candidate list.
Re-verify anything load-bearing with the commands in Provenance and maintenance before you act on it.
When to use this skill
Load this before you spend effort on any of these, because each has already been fought:
- Proposing to move directory traversal / file walking into Rust via a PyO3 extension "for speed".
- Re-enabling PyO3 free-threading (
gil_used = false, #[pyclass(frozen)]).
- Adding a self-test or health check that runs
tg search … --json ….
- Tightening a dependency upper cap (
typer<X, click<X, pydantic<X, …).
- Diagnosing a release that "didn't publish" or a green-CI-then-no-PyPI gap.
- Blaming a ranking / capsule / semantic-search behavior change on IDF, or "fixing" it by
widening a candidate pool.
- Trusting a green mock or FFI test as proof that a bridge works.
- Reacting to a wall of red governance tests after a README / docs change.
- Chasing a reported latency "regression" by guessing the hot path instead of profiling the
actual slow command at scale.
- Shipping a doc-drift / precision-heuristic feature because its fixture tests are green.
- Adding a "differs-from-default" runtime gate to decide native-delegation refusal.
- Reading
capfd/fd-level captured output from a CliRunner-invoked command.
- Micro-optimizing a hot loop (e.g. skip-unless-needed) without checking every consumer of the
skipped value first.
- Gating a second sequential merge-watcher on "did the tag change since I started" instead of
an absolute condition.
- Re-proposing cAST structural chunking as the default chunker — evaluated and REJECTED (Battle 17).
- Re-proposing dense-embedding int8/binary/PCA compression for speed — evaluated and DEFERRED, it's
slower in numpy (Battle 18).
- Proposing a warm-session/daemon search-index shortcut ("just serve search from the daemon's cached
state") — the daemon holds a symbol map, not a search index; this is a big refactor, not a quick win
(Battle 19).
- Proposing GPU-for-search, or describing the shipped GPU kernel as PFAC/Aho-Corasick — it is a
brute-force byte-compare with no single-pattern crossover at any scale vs the fair public baselines
(Battle 20); many-fixed-pattern native CUDA remains only the local candidate lane.
- Hitting a many-pattern
-e/-f count that looks off — a live native aho-corasick dedup
over-count bug is already diagnosed and guarded, not yet root-fixed (Battle 21).
- Trusting an unverified "cheap win" from a paper or research steal-list without checking it against
the real code first — every one of the 6 recent steals came back negative/big-refactor/secondary-path
once verified (Battle 22, the meta-lesson).
- Assuming a "clone language X's extractor" onboarding brief is still accurate without checking
which module is the CURRENT template — a brief that says "mirror inline
_rust_*" is stale once the
codebase has grown a lang_registry + lang_go.py-style module (Battle 23).
- Trusting a conflict-free git rebase across several PRs that all touch the same shared
registration file (a suffix dict, an extras list, a lockfile) as proof the merged file is still
complete (Battle 24).
- Trusting a cause-emitter census count ("N of N covered") that was reached by reasoning one
emitter transitively covers another, instead of counting each one directly — the defaulted-scope
disclosure census undercounted by one (Battle 26).
- Re-proposing an auto-derived agent-id for
tg ledger --claim to remove the explicit
--agent-id requirement — evaluated and REJECTED, it silently reproduces #845 through the
ledger's own overlap suppression (Battle 27).
- Treating MaxSim reranking's absence after
tg install-dense as a CUJ-coverage gap — MaxSim
late-rerank is RETIRED 2026-08-05 (task F10) as a validated dead end, not a paused hold or a
coverage gap (Battle 28).
- Chasing a GPU-path exit code 2 as a plain regression, or trusting a control/repro that
reproduces the failing output, before confirming which exact symbol it exercised and whether
that symbol's job runs in the real CI path (Battle 29).
- Adding a flag that suppresses or alters rg's output (e.g.
-q) to the shared _build_cmd
argv builder instead of search_passthrough — three of its four consumers PARSE stdout, and
suppression reads as a false no-match, not a quieter one (Battle 30).
When NOT to use this skill (use a sibling instead)
| Situation |
Use instead |
| A new, live bug / test failure / crash you are actively debugging |
tensor-grep-debugging-playbook |
| You want to re-attempt a settled battle and need the gates (council, dry-run, flag-flip) |
tensor-grep-change-control |
| You need the invariants that these battles hardened (front door, registration, fail-closed backend) |
tensor-grep-architecture-contract |
| You are running the release pipeline / semantic-release mechanics |
tensor-grep-release-and-positioning |
| Rust/uv/maturin build or env problems (off-PATH cargo, slow LTO) |
tensor-grep-build-and-env |
| The eval/oracle/grader version of the "green test lied" trap (pnpm exit-127, bidirectional oracle) |
trustworthy-cuj-scoring |
| Search-flag / config-axis defaults and semantics |
tensor-grep-config-and-flags |
This skill is read-only history. It does NOT authorize a change. Reopening any settled
battle still goes through tensor-grep-change-control (council-verify -> dry-run -> conscious
flag-flip). Nothing here routes around that.
How to read an entry
Every entry has four fields. Trust the Evidence column over the prose; if the evidence no
longer matches the code, the battle may have moved — flag it, do not silently act.
- Symptom — what you would observe that tempts the re-fight.
- Root cause — the mechanism, stated precisely (the naive guess is usually wrong).
- Evidence — the commit / PR / file:symbol that proves it.
- Status — SETTLED (do not re-fight without change-control) or OPEN (known debt).
Battle 1 — PyO3 for directory walking (FFI boundary loses to os.walk)
| Field |
Detail |
| Symptom |
"Python's os.walk is slow; wrap Andrew Gallant's Rust ignore crate in a PyO3 class and directory traversal will be much faster." |
| Root cause |
The bottleneck is the PyO3 FFI boundary, not the walk. A Rust iterator that yields paths back to Python must allocate and serialize tens of thousands of Rust String into PyString on the Python heap, acquiring and releasing the GIL (Global Interpreter Lock — CPython's per-interpreter mutex) on every yield. CPython's os.walk runs in C inside the interpreter and never crosses a language boundary until it yields native objects. |
| Evidence |
Benchmarked on the C:\dev monorepo: Rust PyO3 ignore extension = 48.818 s vs pure Python os.walk = 39.892 s. Documented in docs/PAPER.md §3.7 ("Why Pure Python Traversals Sometimes Win"); the Rust scanner.rs PyO3 path was deleted in commit b2f3fdd ("Document PyO3 FFI overhead limits and revert to native CPython directory scanning"). |
| Status |
SETTLED. Directory traversal stays pure-Python stdlib. |
Rule: FFI is the speed path only when it avoids the per-item boundary entirely — i.e. the
embedded tg.exe route that maps files with memmap2 + rayon and never yields per-path into
Python (docs/PAPER.md §3.6). A PyO3 extension that returns a large iterator of small Python
objects will lose to CPython C code. Do not re-propose "just wrap ignore in PyO3" for the walk.
Battle 2 — PyO3 free-threading (gil_used=false) broke Linux, blocked all releases
| Field |
Detail |
| Symptom |
"Free-threading (drop the GIL, mark #[pyclass(frozen)]) should speed up the extension — turn it on." |
| Root cause |
PR #266 flipped gil_used = false + #[pyclass(frozen)]. The extension then failed to import on Linux (agent-readiness red), while Windows and local import stayed green — so the break was invisible where it was developed. Worse: #266 merged with its CI cancelled by a force-push, so no green run ever gated it. The release job needs agent-readiness, so this blocked every release from v1.13.43 onward. |
| Evidence |
Revert commit a90595f ("revert #266 free-threading (gil_used=false + frozen) — broke Linux agent-readiness"); reverted to the known-green #265 config (gil_used=true, no frozen). |
| Status |
SETTLED / re-enable only behind full green CI. Free-threading is marginal-value here; it may be re-attempted, but only via tensor-grep-change-control with a complete green multi-OS CI run — never merged with cancelled CI. |
Rule: A merge whose CI was cancelled by a force-push is not verified. Two independent
traps compounded here (Linux-only import break + ungated merge). Do not re-flip free-threading
casually, and never let a force-push swallow the gating CI run.
Battle 3 — the --json fork-bomb (mutual native<->Python delegation)
| Field |
Detail |
| Symptom |
A tg invocation with --json plus a passthrough flag (--json -b, --json --debug, --stats, …) hangs, then spawns processes without bound — historically it disabled both native tg.exe binaries (audit item "C3"). |
| Root cause |
The native front door delegates --json+passthrough combos to the Python sidecar. If the sidecar's launcher lacks the guard (a stale/guard-less Python), the Python launcher re-invokes the native binary, which delegates back to Python — a self-invocation loop that deadlocks and fork-bombs. A self-test that ran tg search … --json --debug against a guard-less Python would re-trigger it. |
| Evidence |
Guard is a re-exec marker enforced across three files: src/tensor_grep/cli/bootstrap.py (search "fork-bomb"), rust_core/src/main.rs, rust_core/src/python_sidecar.rs; behavioral test tests/unit/test_launcher_no_respawn.py. See CHANGELOG entries for the C3 close ("close the C3 fork-bomb for ALL --json + passthrough-flag combos (re-exec marker)"). |
| Status |
SETTLED / guarded. The immediate mitigation disabled the native binaries; the durable fix is the re-exec marker guard. Current native-binary availability is nuanced — check tg doctor --json (skipped_native_tg_binaries) and tensor-grep-run-and-operate for live status. |
Rule: Never add a health check / self-test that runs tg search … --json … — that is the
exact shape that reopens C3. If you touch the native<->Python delegation path, run
tests/unit/test_launcher_no_respawn.py and dogfood the real binary, not CliRunner.
Battle 4 — README rewrite: 14 red governance tests + 4 wasted CI cycles
| Field |
Detail |
| Symptom |
A force-pushed marketing README turned main red on ~11–14 governance tests and also tripped a release-blocker gate. The team then burned ~4 CI cycles trying fixes that did not address the failing check. |
| Root cause |
Two layers. (1) The README rewrite dropped enterprise-doc links + pinned content that test_public_docs_governance / test_enterprise_docs_governance / validate_readme_contract assert. (2) The 4-cycle waste came from theorizing from tracebacks — a free-threading revert and uv run --no-sync were tried first — instead of decoding the structured failing check. The real release-gate failer was validate_docs_claims (a docs-claim staleness check), not the theories. |
| Evidence |
PR #269 / v1.13.44 ("green main after the README rewrite — restore enterprise-doc links + relax redundant README governance"); commits fc1f4b9, and the validate_readme_contract / test_public_docs_governance relaxations in CHANGELOG v1.13.44. |
| Status |
SETTLED. README stays governed by validate_docs_claims for version-staleness, but is exempted from technical-fragment pins; substance moved to dedicated docs (SKILL.md / AGENTS.md / CONTRACTS.md). |
Rule (the load-bearing lesson): Decode the structured failing check FIRST. Run
gh run view <id> --log-failed (or read the specific failing test's assertion) and fix that
check. Do not theorize a root cause from a traceback and start reverting unrelated things — that
is what cost 4 cycles. This same "decode first" rule is why Battle 6's push-race is diagnosable in
one step.
Battle 5 — dependency upper-cap silently downgraded the whole install on Python 3.14
| Field |
Detail |
| Symptom |
uv tool install tensor-grep (or pipx/uvx) on Python 3.14 silently installed stale 1.13.35 instead of the latest — with no error. |
| Root cause |
The pin was typer>=0.12,<0.25. On py3.14 there was no py3.14-compatible click inside the typer<0.25 range, so the resolver could not satisfy the newest tensor-grep's constraints and silently resolved the WHOLE package DOWN to the last version that fit. requires-python >= X has no upper bound, so nothing surfaced the mismatch as an error — a downgrade reads as success. |
| Evidence |
PR #310 / v1.17.16 ("Allow typer 0.25 to unblock Python 3.14 installs"); commit 20d22c8. Current pin in pyproject.toml is typer>=0.12,<0.26. The <0.25 floor existed because typer 0.26 dropped CliRunner.isolated_filesystem() that 49 CLI tests use; typer 0.25.1 threads the needle (keeps that API AND supports py3.14). |
| Status |
SETTLED at typer>=0.12,<0.26. |
Rule: When a new Python version yields a stale install, suspect a transitive
dependency upper cap (typer / click / pydantic), not requires-python. An upper cap that
is unsatisfiable on a newer Python degrades silently — always validate an unpinned install on the
newest supported Python end-to-end before assuming a cap is safe. Tightening any <X cap is a
change-control event (tensor-grep-change-control).
Battle 6 — release "didn't publish": the push-race (non-fast-forward)
| Field |
Detail |
| Symptom |
PR CI is green, the fix:/feat: code is on main, but no chore(release): vX commit appears and PyPI does not update — the version "didn't publish". |
| Root cause |
The real publish is the Semantic Release job inside .github/workflows/ci.yml (gated github.ref=='refs/heads/main' && github.event_name=='push'). It compiles native assets first, so it runs ~6 min — a race window. If any merge lands on main during that window — including a no-release docs:/chore: PR — main advances and the in-flight release's final git push origin main (the version-bump commit) is rejected non-fast-forward (! [rejected] main -> main). The CI concurrency group serializes runs, not the human/agent act of clicking merge. |
| Evidence |
AGENTS.md "Release publish is not instant — the push-race (hard-won, re-confirmed 2026-07-02)". Receipt: v1.17.23 (#318, a security batch) failed to publish because the GPU-pause docs: PR #319 was merged while #318's release job was still compiling assets. release.yml is workflow_dispatch-only (a manual tag cannot bypass semantic-release). |
| Status |
SETTLED discipline = one-merge-per-tick. Merge ONE release-bearing PR -> wait for its chore(release): vX commit on main AND the new PyPI version -> then merge the next. Applies even to no-release docs/chore PRs. |
Recovery — do NOT panic-rerun. The failure self-heals: the next push-to-main re-runs
Semantic Release, and because the version is derived from git tags (not the failed run's
state), it recomputes the correct next version and covers the orphaned commit. Just confirm that
next run's Semantic Release job succeeds. Diagnose with the structured result first:
gh run view <id> --json jobs -> find Semantic Release -> gh run view <id> --log-failed. A
! [rejected] main -> main line is the push-race signature; anything else is a different problem.
Battle 7 — the IDF-ranking-fragility flip (the IDF root-cause guess was DISPROVEN)
| Field |
Detail |
| Symptom |
A change that added zero ranking terms (a GPU-code diff) silently flipped the agent capsule's bridge-binary choice from "tie, ask the user" to "confidently pick a marker no-op" — i.e. a ranking surface (tg search --rank, agent capsule, semantic) silently changed a safety behavior on corpus change, and the blast radius was invisible to the call graph. |
| Root cause |
The first guess — "missing IDF weighting" — was DISPROVEN by a thinktank, and the minimal "just widen the candidate pool" fix was proven insufficient. The real cause is a stack: a flat, no-IDF scorer + a hard top-5 candidate cap + a file_score flip + an alphabetical path tie-break. (IDF = inverse document frequency, the term-rarity weighting BM25 uses; its absence makes common and rare terms score alike.) |
| Evidence |
PR #302 shipped a degrade-to-ask safety floor (force ask_user when the post-swap primary is still an unrequested marker), de-fragilized the self-referential live-test into a safety-floor assertion, and moved the exact-identity contract to a deterministic fixture. See CHANGELOG #302 and AGENTS.md ranking notes. |
| Status |
PARTIALLY SETTLED — OPEN DEBT. The safety floor shipped. The underlying flat no-IDF scorer still exists and remains fragile; a proper fix is deferred to a separate benchmarked repo_map PR. |
Rule: Do not ship your first root-cause guess for a ranking flip (IDF was the wrong
guess here). Ranking surfaces can silently degrade a safety behavior on corpus change — treat
any capsule/--rank/semantic change as safety-relevant, keep the degrade-to-ask floor, and gate
scorer changes on a measured benchmark (tensor-grep-benchmark-and-proof-toolkit), never a
clean-looking diff. No speed/quality claim without numbers vs the accepted baseline.
Battle 8 — the broken-oracle trap: green mock/FFI tests while the real bridge was DEAD
| Field |
Detail |
| Symptom |
Mock-based FFI tests were green, yet the real PyO3 passthrough bridge dropped every forwarded flag — the passthrough was effectively dead and users got wrong/unaccelerated results. |
| Root cause |
The bridge passed None for glob/file_type into an rg Vec<String> -> TypeError -> a broad except swallowed it into a silent fallback that dropped all flags. The mocks never exercised the real extension, so the tests validated nothing about the live boundary — a broken oracle (a test that passes regardless of whether the thing works). |
| Evidence |
PR #309 / v1.17.15 ("Forward dropped rg flags through PyO3 bridge + revive the passthrough (audit #3)"); commit fd30e6d. The fix forwards the hardcoded flags and guards (… or []). |
| Status |
SETTLED. |
Rule: Verify FFI/bridge changes against the REAL extension (a live maturin develop
runtime call), never mocks alone. A green mock is a hypothesis, not proof (see the unwritten rule
"never trust a self-report"). For the eval/grader version of this trap — a whole lane failing
uniformly, a grader that passes an empty answer, pnpm.cmd exit-127 on Windows — load
trustworthy-cuj-scoring, which enforces bidirectional oracle validation (a correct
answer must PASS and a wrong/empty answer must FAIL before any batch is trusted).
Battle 9 — native delegation silently dropped --rank/--sort-files (+ the query_pattern landmine)
| Field |
Detail |
| Symptom |
tg search --rank --cpu (also --rank --json/--ndjson, --sort-files --cpu) silently returned unranked / unsorted results — no error, output just looked plausible and wrong. |
| Root cause |
Native-tg delegation sys.exit()s before the Python-side BM25 rerank and the in-backend sort ever run. rank_bm25 and sort_files were SearchConfig fields that were neither forwarded into the native argv nor listed in the refuse-tuple (_NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS), so the gate happily delegated and the flags evaporated. A tempting alternative fix — a generic "differs-from-default" runtime gate that refuses delegation whenever any field is non-default — was explicitly rejected: query_pattern is auto-set on every search, so a blanket differs-from-default check would trip on it on literally every invocation and kill the native fast path entirely (a re-confirmation of a 2026-06-30 failure mode; see tensor-grep-config-and-flags). |
| Evidence |
Commit 5e6f780 (#342, "fix: refuse native delegation for --rank/--sort-files (silent wrong-output) + coverage ratchet") adds sort_files/rank_bm25 to _NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS in src/tensor_grep/cli/main.py. New governance ratchet tests/unit/test_native_delegation_field_coverage.py AST-derives the forwarded-field set from _build_native_tg_search_command and asserts every SearchConfig field is forwarded | refused | gate-handled | in the documented _NATIVE_TG_DELEGATION_KNOWN_GAP_FIELDS frozenset (which explicitly names query_pattern with the differs-from-default caveat in its comment). |
| Status |
SETTLED for rank_bm25/sort_files. OPEN debt: the KNOWN_GAP frozenset (ast_prefer_native, ast_selector, ast_stdin, nlp_threshold, use_jit, ignore_dot, …) documents fields that were already silently dropped before #342 — acknowledged, not fixed. case_sensitive is NOT in this set — audit #19 forwarded it into the native argv via -s, so it graduated out of KNOWN_GAP into the Forwarded bucket; do not list it as a gap in new docs (tensor-grep-config-and-flags records this explicitly). |
Rule: Same bug class as #336's -u/-uu no-op — a SearchConfig field that affects output is
a silent-drop landmine unless it is forwarded to the native argv, added to the refuse-tuple, or
explicitly gated. Adding a new SearchConfig field? Load tensor-grep-config-and-flags and run
the coverage ratchet. Never propose a generic "differs-from-default" gate for delegation
refusal — query_pattern makes it degenerate to "always refuse."
Battle 10 — the capfd capture-surface trap: a delegation-routing fix broke fd-level test assertions
| Field |
Detail |
| Symptom |
Right after Battle 9's fix made --rank correctly refuse native delegation, test_search_rank_reorders_by_bm25 started raising JSONDecodeError — every test-python job on main went red, even though the BM25 rerank behavior itself was correct. |
| Root cause |
Before the fix, --rank wrongly delegated to a native subprocess, whose stdout landed on the real OS file descriptor — exactly what pytest's capfd fixture (fd-level capture) reads. After the fix, the rerank runs in-process, so the JSON is emitted via typer.echo() inside the CliRunner-invoked call and lands in CliRunner's captured result.stdout (a Python-level buffer), never touching the OS fd. The test still read capfd.readouterr().out → empty string → json.loads("") failed. |
| Evidence |
Commit ab717a1 (#343, "test: fix --rank bm25 capture for the non-delegation path (#342 follow-up)"), diff in tests/integration/test_bm25_search_flag.py: dropped the capfd fixture parameter, switched the assertion to json.loads(result.stdout). Comment left in the test explains the fd-vs-in-process split "only surfaces when the native binary is built, which PR CI skips but main/release CI builds." |
| Status |
SETTLED. |
Rule: A test that reads capfd against a CliRunner.invoke() result is implicitly asserting
that the command delegated to a real subprocess (whose output crosses the OS fd boundary) rather
than running in-process. Any change to native-delegation routing — what gets forwarded vs. refused
— can silently flip which capture surface carries the real output. If you touch
_can_delegate_to_native_tg_search or the refuse-tuple, run tests/integration/ locally, not just
tests/unit/. This is the sibling of Battle 3's "dogfood the real binary, not CliRunner": here
CliRunner is the right tool, but you must know which stream it captures for the path you just
changed.
Battle 11 — MatchLine hashability regression, and a "free" micro-opt that wasn't
| Field |
Detail |
| Symptom |
(a) A MatchLine with a populated submatches field raised TypeError on hash() — a frozen dataclass silently lost its hashability contract. (b) A follow-on "obvious" optimization (only stash submatches when --vimgrep/--column is requested) broke an existing test in the same PR. |
| Root cause |
(a) submatches (added in #340) is typed tuple[dict[str, object], ...] | None — a tuple of dicts is unhashable — so any future caller that hashes/dedupes a MatchLine would crash the instant a real rg run populated it. (b) The gating micro-opt (want_submatches = config.vimgrep or config.column) looked like free wasted-work removal — a profiler had flagged the per-match stash as a small cost during the Battle 12 blast-radius hunt — but tests/unit/test_submatches_output_shaping.py::test_backend_stashes_submatches_without_inflating_count calls RipgrepBackend().search(..., SearchConfig()) with a default config (vimgrep=False, column=False) and asserts result.matches[0].submatches is not None. The gate made that default-config case return None, failing the test — the "wasted work" was not actually dead: a consumer expects it populated regardless of output format. |
| Evidence |
Commit 80de0b4 (#344). The final merged diff touches only src/tensor_grep/core/result.py (submatches: tuple[...] | None = field(default=None, compare=False)) and adds tests/unit/test_matchline_submatches_hashable.py — it does not touch src/tensor_grep/backends/ripgrep_backend.py. The two squashed sub-commits show why: 65022bc added the want_submatches gate to ripgrep_backend.py (+3 -1 there), and 4c34516 reverted exactly that gate (ripgrep_backend.py | 5 +----), keeping only the hashability fix. |
| Status |
SETTLED for (a) — compare=False is the shipped fix. (b) is SETTLED as "already tried and reverted" — do not re-propose the vimgrep/column gate without first checking test_submatches_output_shaping.py's consumer contract. |
Rule: field(default=None, compare=False) is the fix shape for "add a field to a frozen
dataclass that must stay hashable but holds an unhashable value type" (the offsets are a pure
function of text+line, so excluding it from == is correct too). Before gating a per-match stash
"because only formatter X consumes it," grep the actual consumers first — a test that breaks the
instant you ship a profiler-motivated micro-opt is not an obstacle to route around; it is the
consumer contract telling you the guess was wrong.
Battle 12 — the reported +33% blast-radius "regression" was noise; the council's own hot-path guess was also wrong
| Field |
Detail |
| Symptom |
An AI-user report claimed tg blast-radius (depth-2) regressed +33% (188s → 250s) between two tagged versions. |
| Root cause |
Two separate wrong guesses were made and disproven before the real fix. (1) A 3-lens+opus review council correctly ruled out a noise-regression theory on the reported deltas, but then guessed the hot path was AST parsing — live profiling later showed compile() was only 3.6% of runtime, and a Counter-monkeypatch of ripgrep_backend calls showed zero frames on the actual tg callers path the council had profiled (they profiled the wrong file/path — 0 calls on-path). (2) A follow-on regression-hunt workflow's own synthesis guessed "cache the AST parse" as the fix; profiling proved that would have saved only ~3%. The ACTUAL dominant cost, found only by a profile-at-scale of the real tg blast-radius --depth 2 invocation on a high-fan-in symbol (290 callers), was _module_aliases_for_path: called 1,431,341 times for only ~1,000 unique path inputs inside the reverse-import-graph / PageRank loops (6.1s self / 38s cumulative of a 62s run) — a pure function of the path string, rebuilt on every call instead of cached. Separately, the reported cross-version +33% was itself confirmed to be environmental noise: the plain tg callers path was byte-identical between v1.17.31 and HEAD. |
| Evidence |
Commit bb5dc59 (#345, "perf: memoize _module_aliases_for_path — blast-radius depth-2 ~4.8x faster (62s->13s)"); adds @lru_cache(maxsize=16384) + a frozenset return type to _module_aliases_for_path in src/tensor_grep/cli/repo_map.py, plus tests/unit/test_module_aliases_cache.py. Result: 61.7s → 12.8s (4.8x), byte-identical output (affected=62; 231 blast-radius/callers/pagerank parity tests unchanged). Commit message states explicitly: "corrects the regression-hunt synthesis, which guessed AST-parse caching (would have saved ~3%)." |
| Status |
SETTLED — real fix shipped and dogfooded. The "+33% regression" claim itself is SETTLED as noise, not a code defect. |
Rule: For a latency claim, the profiler is the oracle — not a diff-review council, and not
even a first-pass profiler read: this hunt needed a second profiling pass (profile the real
command at scale, with a call-count monkeypatch) before the true hotspot appeared, because the
first profile ran on the wrong command entirely. Before accepting any "X% regression" report,
reproduce it on the byte-identical code path across both versions before designing a fix — it may
be noise (see also tensor-grep-benchmark-and-proof-toolkit). A pure, no-I/O helper function
called repeatedly inside a graph/loop algorithm is a near-free @lru_cache candidate; check
dict[str, set] → dict[str, frozenset] downstream type hints when the cached value is
shared/iterated but never mutated in place.
Addendum (2026-07-24) — the mirror-image trap: a warm run can hide a real WIN too. The same
"measure the actual thing, not a proxy" discipline cuts the other way: a warm end-to-end dogfood
rerun measures whatever CACHED path the optimized function no longer sits on, so a real improvement
can read as noise, or even a false regression, if the changed function only runs on the cold/
first-touch path. Two later optimizations — 9a2a01c ("merge 3 redundant ast.walk passes in
_python_imports_and_symbols into one") and d2c1266 ("textual pre-check before per-candidate
validation-test AST parse") — were verified with a single-pass, fresh-process microbenchmark over
distinct inputs instead of a warm end-to-end dogfood rerun, specifically to avoid this trap; see
profile-guided-byte-identical-optimization for the general technique. Same rule as this battle's
own Rule paragraph, one layer deeper: the proxy trap this time is warm-cache reuse, not "profiled
the wrong command."
Battle 13 — tg diff-docs: 17 green fixture tests, 20k+ false positives on the real corpus (deliberately deferred)
| Field |
Detail |
| Symptom |
A doc-drift-detection prototype (tg diff-docs: flag a doc code-span identifier that no longer resolves to a repo symbol) shipped a fully-passing fixture suite — 17/17 unit tests green — but running it on the real repo corpus (docs/ vs src/) produced an unusable flood of findings dominated by stdlib/language type names. |
| Root cause |
Fixture tests only exercise the hand-picked cases the author thought of; they cannot reveal a heuristic's real-world false-positive rate. The design's own module docstring already cited the risk — "DocPrism: naive code-doc drift detection is 0.62 precision / 98% flag-rate" (arXiv 2511.00215) — but shipped anyway because the fixtures were green. Dogfooding on the real corpus confirmed the DocPrism trap: "unresolved symbol in a doc code-span" flags common language/stdlib type names (e.g. String, Option, Vec) that were never going to resolve in repo_map's symbol table because they are not project symbols — the heuristic has no positive repo-reference signal, only a "not found locally" absence signal, so anything foreign reads as "drifted." |
| Evidence |
Commit 90b7042 on branch wip/diff-docs-precision ("wip: tg diff-docs foundation (DEFERRED — precision inadequate, see task)"), adding src/tensor_grep/cli/diff_docs.py (303 lines) and tests/unit/test_diff_docs.py (17 def test_... functions, direct-counted). docs/SESSION_HANDOFF.md records: "tg diff-docs was prototyped and deliberately deferred pending a precision rebuild — naive doc-drift detection floods false positives (documented follow-up)." The feature was never merged to main; it lives only on the wip/ branch. (The exact real-corpus finding counts were an ad hoc dogfood measurement during the deferring session, not persisted as a committed benchmark artifact — treat the shape, not the digits, as load-bearing.) |
| Status |
OPEN / DEFERRED BY DESIGN. Not a bug to fix — a feature intentionally not shipped. A rebuild needs (1) a positive repo-reference signal (e.g. a qualified repo_module.symbol span where the module resolves in-repo but the symbol doesn't — not a bare identifier), and (2) a measured real-corpus precision gate before merging, not just green fixtures. |
Rule: Fixture-green is not sufficient for a precision/heuristic feature. Before shipping
anything that classifies or flags ("is this drifted", "is this dead code", "is this a bug"),
dogfood it on the real project corpus and eyeball the finding count and the top hits,
not just whether the unit tests pass. Prefer deferring (park on a wip/ branch) over shipping a
feature that trains the agent/user to ignore its own output — thousands of false positives is
worse than no feature. See also Battle 8's broken-oracle framing: a fixture suite that only tests
hand-picked true/false cases is a weaker oracle than a real-corpus run.
Battle 14 — sequential release-watcher deadlock: gate on absolute state, never "changed since I launched"
| Field |
Detail |
| Symptom |
A sequential merge-watcher (Battle 6's one-merge-per-tick discipline, automated) that gates the next merge on "has the release tag changed since I started watching" can deadlock permanently if a release was already in flight — or already published — at the moment the watcher launched, because the tag never "changes" relative to a baseline that was itself already stale. |
| Root cause |
A relative baseline ("did X change since t0") is only correct if t0 is guaranteed to precede the event being waited for. A watcher that launches after a publish already started (or completed) cannot distinguish "still waiting" from "already done" if it only tracks deltas from its own launch time — it must instead check absolute state: the prior PR's state == MERGED, and the specific main CI run (identified by run ID / commit SHA, not "newer than my start time") has actually completed. The full pipeline is also longer than the 6-minute pre-publish compile window documented in Battle 6 — a real measured run (PR #346, gh run id 28667549718, 2026-07-03) took CI-verification-start (15:01) to publish-pypi success (15:41) — **40 minutes** — and to the post-publish release-tag-smoke check completing (15:45) — ~45 minutes end-to-end. build-release-native-assets (macos-15-intel, cpu) and native-build-smoke (macos-15-intel) were consistently the longest individual jobs (~12 min and ~11.7 min respectively) at both the CI-verification and release-asset-build stages — a watcher with a short or relative-only wait window will misdiagnose an in-flight release as "stuck" or "already done." |
| Evidence |
Measured via gh run view 28667549718 --json jobs: repo-hygiene/smoke start 15:01:06-15:01:19; Semantic Release 15:21:43-15:25:49; build-release-native-assets (macos-15-intel, cpu) 15:25:53-15:37:58 (the longest release-asset job); publish-pypi 15:41:16-15:41:57; release-tag-smoke completes 15:45:47. AGENTS.md's "Release publish is not instant — the push-race" section (Battle 6) documents the shorter ~6-minute pre-publish compile window that creates the push-race; the full pipeline through PyPI + tag-smoke is longer still, as measured above. |
| Status |
SETTLED discipline (a corollary to Battle 6, applied to automated watchers rather than human merge timing): gate any sequential watcher on absolute conditions — prior PR state == MERGED AND the specific main CI run (by ID) has status == completed — never on "did the tag change since my launch." Size any watcher wait window for the full measured pipeline (~40-45 min), not the ~6-minute compile window alone. |
Rule: This is Battle 6's push-race discipline applied to automation, not just human merge
timing. A polling/watcher loop that checks "did state change since I started" is race-prone
whenever the state could have already changed before the loop started (a pre-launch publish).
Always identify a specific, absolute target to wait FOR (a run ID, a PR merged-state, a version
string) — never a relative delta from an arbitrary observation point. Re-measure the pipeline
duration periodically (gh run view <id> --json jobs) — CI job mix and runner speed drift over
time, and a stale duration assumption is how a watcher's timeout gets tuned wrong.
Battle 15
…(truncated)
1---2name: tensor-grep-failure-archaeology3description: Use when about to "fix" or "optimize" something in tensor-grep that feels novel — before proposing PyO3/FFI for directory walking, re-enabling free-threading, adding a --json self-test, tightening a dependency upper-cap, blaming an IDF/ranking flip, trusting a green mock/FFI test, diagnosing a release that "didn't publish", chasing a reported latency "regression" without profiling at scale, shipping a doc-drift/precision heuristic off green fixtures alone, adding a "differs-from-default" native-delegation gate, reading a `capfd`-based CliRunner test result, micro-optimizing a hot loop without checking who actually consumes the value, re-proposing cAST structural chunking as the default, re-proposing dense int8/binary/PCA embedding compression, proposing a warm-session/daemon search-index shortcut, proposing GPU-for-search or re-litigating the PFAC-vs-brute-force kernel claim, hitting the many-pattern Aho-Corasick dedup bug, trusting an unverified "cheap win" from a paper/research steal-list, cloning a "mirror4---56# Tensor-Grep Failure Archaeology78A chronicle of **settled battles** in tensor-grep: expensive fights that already reached a9verdict. Each entry is `symptom -> root cause -> evidence -> status` so a future engineer or10model does not burn a day re-discovering the same wall.1112Facts below were verified against the repo on **2026-07-02 at v1.17.25**, with a second pass on13**2026-07-03 at v1.19.3** (Battles 9-14), a third pass adding Battle 15 (`release-tag-smoke`), a14fourth pass **2026-07-16 at v1.78.1** fixing the stale `case_sensitive` KNOWN_GAP claim in Battle 9 and15adding Battle 16 (the `tg find` Opus-gate catches), a fifth pass **2026-07-22 at v1.93.2** adding16Battles 17-22 (the CEO deep-research campaign's research retirements — cAST, dense int8, warm-session,17GPU-for-search, the many-pattern dedup bug, and the "5/5 mirage" meta-lesson), and a sixth pass18**2026-07-24 at v1.96.0** adding Battles 23-24 (the language-expansion campaign's stale onboarding-brief19catch and its sequential multi-PR shared-file union-rebase discipline), a warm-hides-cold addendum to20Battle 12, and re-pointing Battle 16's evidence at its post-squash commit hashes (`501dc26`/`6d79945`)21plus fixing a stale `AGENTS.md` section-letter reference, and a seventh, same-day pass **2026-07-24 at22v1.98.2** adding Battle 25 (the banked C function-pointer fix hypothesis, falsified before any code was23written), and an eighth pass **2026-07-31 at v1.101.24** adding Battles 26-29 (the defaulted-scope24disclosure census undercount, the anonymous `--claim` sentinel-id retention, the MaxSim `rerank`-extra25deliberate hold, and the #868 GPU exit-2 investigation's two false-confirmation traps), a ninth pass26**2026-08-01 at v1.101.27** adding Battle 30 (the `-q` false-zero shipped in the shared `_build_cmd`,27#876/#880), and a tenth retention pass **2026-08-12** that marked Battle 28 RETIRED (task F10, per the28`retrieval_late.py` module header), Battle 29 SETTLED/RETIRED (commit `50595ef` — the exit-2 rule29itself was retired), scoped Battle 20's no-crossover claim to single-pattern + fair public baselines,30corrected Battle 21's #694 description (the guard pins the CORRECT behavior, not the bug) and Battle3123's `lang_java.py` claim, and reconciled Battle 22's steal-list count to its actual 6-candidate list.32Re-verify anything load-bearing with the commands in **Provenance and maintenance** before you act on it.3334## When to use this skill3536Load this **before** you spend effort on any of these, because each has already been fought:3738- Proposing to move directory traversal / file walking into Rust via a PyO3 extension "for speed".39- Re-enabling PyO3 free-threading (`gil_used = false`, `#[pyclass(frozen)]`).40- Adding a self-test or health check that runs `tg search … --json …`.41- Tightening a dependency **upper** cap (`typer<X`, `click<X`, `pydantic<X`, …).42- Diagnosing a release that "didn't publish" or a green-CI-then-no-PyPI gap.43- Blaming a ranking / capsule / semantic-search behavior change on IDF, or "fixing" it by44 widening a candidate pool.45- Trusting a **green mock or FFI test** as proof that a bridge works.46- Reacting to a wall of red governance tests after a README / docs change.47- Chasing a reported latency "regression" by guessing the hot path instead of profiling the48 **actual slow command** at scale.49- Shipping a doc-drift / precision-heuristic feature because its fixture tests are green.50- Adding a **"differs-from-default"** runtime gate to decide native-delegation refusal.51- Reading `capfd`/fd-level captured output from a `CliRunner`-invoked command.52- Micro-optimizing a hot loop (e.g. skip-unless-needed) without checking every consumer of the53 skipped value first.54- Gating a **second** sequential merge-watcher on "did the tag change since I started" instead of55 an absolute condition.56- Re-proposing **cAST structural chunking as the default chunker** — evaluated and REJECTED (Battle 17).57- Re-proposing **dense-embedding int8/binary/PCA compression** for speed — evaluated and DEFERRED, it's58 slower in numpy (Battle 18).59- Proposing a **warm-session/daemon search-index shortcut** ("just serve search from the daemon's cached60 state") — the daemon holds a symbol map, not a search index; this is a big refactor, not a quick win61 (Battle 19).62- Proposing **GPU-for-search**, or describing the shipped GPU kernel as PFAC/Aho-Corasick — it is a63 brute-force byte-compare with no single-pattern crossover at any scale vs the fair public baselines64 (Battle 20); many-fixed-pattern native CUDA remains only the local candidate lane.65- Hitting a **many-pattern `-e`/`-f` count that looks off** — a live native aho-corasick dedup66 over-count bug is already diagnosed and guarded, not yet root-fixed (Battle 21).67- Trusting an unverified **"cheap win" from a paper or research steal-list** without checking it against68 the real code first — every one of the 6 recent steals came back negative/big-refactor/secondary-path69 once verified (Battle 22, the meta-lesson).70- Assuming a **"clone language X's extractor"** onboarding brief is still accurate without checking71 which module is the CURRENT template — a brief that says "mirror inline `_rust_*`" is stale once the72 codebase has grown a `lang_registry` + `lang_go.py`-style module (Battle 23).73- Trusting a **conflict-free git rebase** across several PRs that all touch the same shared74 registration file (a suffix dict, an extras list, a lockfile) as proof the merged file is still75 complete (Battle 24).76- Trusting a cause-emitter **census count** ("N of N covered") that was reached by reasoning one77 emitter transitively covers another, instead of counting each one directly — the defaulted-scope78 disclosure census undercounted by one (Battle 26).79- Re-proposing an **auto-derived agent-id** for `tg ledger --claim` to remove the explicit80 `--agent-id` requirement — evaluated and REJECTED, it silently reproduces #845 through the81 ledger's own overlap suppression (Battle 27).82- Treating **MaxSim reranking's absence** after `tg install-dense` as a CUJ-coverage gap — MaxSim83 late-rerank is RETIRED 2026-08-05 (task F10) as a validated dead end, not a paused hold or a84 coverage gap (Battle 28).85- Chasing a GPU-path **exit code 2** as a plain regression, or trusting a control/repro that86 reproduces the failing output, before confirming which exact symbol it exercised and whether87 that symbol's job runs in the real CI path (Battle 29).88- Adding a flag that **suppresses or alters rg's output** (e.g. `-q`) to the shared `_build_cmd`89 argv builder instead of `search_passthrough` — three of its four consumers PARSE stdout, and90 suppression reads as a false no-match, not a quieter one (Battle 30).9192## When NOT to use this skill (use a sibling instead)9394| Situation | Use instead |95|---|---|96| A **new**, live bug / test failure / crash you are actively debugging | `tensor-grep-debugging-playbook` |97| You want to *re-attempt* a settled battle and need the gates (council, dry-run, flag-flip) | `tensor-grep-change-control` |98| You need the invariants that these battles hardened (front door, registration, fail-closed backend) | `tensor-grep-architecture-contract` |99| You are running the release pipeline / semantic-release mechanics | `tensor-grep-release-and-positioning` |100| Rust/uv/maturin build or env problems (off-PATH cargo, slow LTO) | `tensor-grep-build-and-env` |101| The eval/oracle/grader version of the "green test lied" trap (pnpm exit-127, bidirectional oracle) | `trustworthy-cuj-scoring` |102| Search-flag / config-axis defaults and semantics | `tensor-grep-config-and-flags` |103104**This skill is read-only history. It does NOT authorize a change.** Reopening any settled105battle still goes through `tensor-grep-change-control` (council-verify -> dry-run -> conscious106flag-flip). Nothing here routes around that.107108## How to read an entry109110Every entry has four fields. Trust the **Evidence** column over the prose; if the evidence no111longer matches the code, the battle may have moved — flag it, do not silently act.112113- **Symptom** — what you would observe that tempts the re-fight.114- **Root cause** — the mechanism, stated precisely (the naive guess is usually wrong).115- **Evidence** — the commit / PR / file:symbol that proves it.116- **Status** — SETTLED (do not re-fight without change-control) or OPEN (known debt).117118---119120## Battle 1 — PyO3 for directory walking (FFI boundary loses to `os.walk`)121122| Field | Detail |123|---|---|124| **Symptom** | "Python's `os.walk` is slow; wrap Andrew Gallant's Rust `ignore` crate in a PyO3 class and directory traversal will be much faster." |125| **Root cause** | The bottleneck is the **PyO3 FFI boundary, not the walk**. A Rust iterator that yields paths back to Python must allocate and serialize tens of thousands of Rust `String` into `PyString` on the Python heap, acquiring and releasing the **GIL** (Global Interpreter Lock — CPython's per-interpreter mutex) on every yield. CPython's `os.walk` runs in C *inside* the interpreter and never crosses a language boundary until it yields native objects. |126| **Evidence** | Benchmarked on the `C:\dev` monorepo: **Rust PyO3 `ignore` extension = 48.818 s** vs **pure Python `os.walk` = 39.892 s**. Documented in `docs/PAPER.md` §3.7 ("Why Pure Python Traversals Sometimes Win"); the Rust `scanner.rs` PyO3 path was deleted in commit `b2f3fdd` ("Document PyO3 FFI overhead limits and revert to native CPython directory scanning"). |127| **Status** | **SETTLED.** Directory traversal stays pure-Python stdlib. |128129**Rule:** FFI is the speed path only when it **avoids the per-item boundary entirely** — i.e. the130embedded `tg.exe` route that maps files with `memmap2` + `rayon` and never yields per-path into131Python (`docs/PAPER.md` §3.6). A PyO3 extension that returns a large iterator of small Python132objects will lose to CPython C code. Do not re-propose "just wrap `ignore` in PyO3" for the walk.133134## Battle 2 — PyO3 free-threading (`gil_used=false`) broke Linux, blocked all releases135136| Field | Detail |137|---|---|138| **Symptom** | "Free-threading (drop the GIL, mark `#[pyclass(frozen)]`) should speed up the extension — turn it on." |139| **Root cause** | PR #266 flipped `gil_used = false` + `#[pyclass(frozen)]`. The extension then **failed to import on Linux** (`agent-readiness` red), while Windows and local import stayed green — so the break was invisible where it was developed. Worse: #266 merged with its CI **cancelled by a force-push**, so no green run ever gated it. The release job `needs` `agent-readiness`, so this **blocked every release from v1.13.43 onward**. |140| **Evidence** | Revert commit `a90595f` ("revert #266 free-threading (gil_used=false + frozen) — broke Linux agent-readiness"); reverted to the known-green #265 config (`gil_used=true`, no `frozen`). |141| **Status** | **SETTLED / re-enable only behind full green CI.** Free-threading is marginal-value here; it may be re-attempted, but only via `tensor-grep-change-control` with a **complete green multi-OS CI run** — never merged with cancelled CI. |142143**Rule:** A merge whose CI was cancelled by a force-push is **not** verified. Two independent144traps compounded here (Linux-only import break + ungated merge). Do not re-flip free-threading145casually, and never let a force-push swallow the gating CI run.146147## Battle 3 — the `--json` fork-bomb (mutual native<->Python delegation)148149| Field | Detail |150|---|---|151| **Symptom** | A `tg` invocation with `--json` plus a passthrough flag (`--json -b`, `--json --debug`, `--stats`, …) **hangs, then spawns processes without bound** — historically it **disabled both native `tg.exe` binaries** (audit item "C3"). |152| **Root cause** | The native front door delegates `--json`+passthrough combos to the Python sidecar. If the sidecar's launcher lacks the guard (a stale/guard-less Python), the Python launcher re-invokes the **native** binary, which delegates back to Python — a self-invocation loop that deadlocks and fork-bombs. A self-test that ran `tg search … --json --debug` against a guard-less Python would re-trigger it. |153| **Evidence** | Guard is a **re-exec marker** enforced across three files: `src/tensor_grep/cli/bootstrap.py` (search "fork-bomb"), `rust_core/src/main.rs`, `rust_core/src/python_sidecar.rs`; behavioral test `tests/unit/test_launcher_no_respawn.py`. See CHANGELOG entries for the C3 close ("close the C3 fork-bomb for ALL --json + passthrough-flag combos (re-exec marker)"). |154| **Status** | **SETTLED / guarded.** The immediate mitigation disabled the native binaries; the durable fix is the re-exec marker guard. Current native-binary availability is nuanced — check `tg doctor --json` (`skipped_native_tg_binaries`) and `tensor-grep-run-and-operate` for live status. |155156**Rule:** Never add a health check / self-test that runs `tg search … --json …` — that is the157exact shape that reopens C3. If you touch the native<->Python delegation path, run158`tests/unit/test_launcher_no_respawn.py` and dogfood the **real** binary, not `CliRunner`.159160## Battle 4 — README rewrite: 14 red governance tests + 4 wasted CI cycles161162| Field | Detail |163|---|---|164| **Symptom** | A force-pushed marketing README turned **main red on ~11–14 governance tests** and also tripped a release-blocker gate. The team then burned **~4 CI cycles** trying fixes that did not address the failing check. |165| **Root cause** | Two layers. (1) The README rewrite **dropped enterprise-doc links + pinned content** that `test_public_docs_governance` / `test_enterprise_docs_governance` / `validate_readme_contract` assert. (2) The **4-cycle waste** came from *theorizing from tracebacks* — a free-threading revert and `uv run --no-sync` were tried first — instead of **decoding the structured failing check**. The real release-gate failer was `validate_docs_claims` (a docs-claim staleness check), not the theories. |166| **Evidence** | PR #269 / v1.13.44 ("green main after the README rewrite — restore enterprise-doc links + relax redundant README governance"); commits `fc1f4b9`, and the `validate_readme_contract` / `test_public_docs_governance` relaxations in CHANGELOG v1.13.44. |167| **Status** | **SETTLED.** README stays governed by `validate_docs_claims` for **version-staleness**, but is exempted from **technical-fragment pins**; substance moved to dedicated docs (SKILL.md / AGENTS.md / CONTRACTS.md). |168169**Rule (the load-bearing lesson):** **Decode the structured failing check FIRST.** Run170`gh run view <id> --log-failed` (or read the specific failing test's assertion) and fix *that*171check. Do not theorize a root cause from a traceback and start reverting unrelated things — that172is what cost 4 cycles. This same "decode first" rule is why Battle 6's push-race is diagnosable in173one step.174175## Battle 5 — dependency upper-cap silently downgraded the whole install on Python 3.14176177| Field | Detail |178|---|---|179| **Symptom** | `uv tool install tensor-grep` (or `pipx`/`uvx`) on **Python 3.14** silently installed **stale 1.13.35** instead of the latest — with **no error**. |180| **Root cause** | The pin was `typer>=0.12,<0.25`. On py3.14 there was **no py3.14-compatible `click`** inside the `typer<0.25` range, so the resolver could not satisfy the newest tensor-grep's constraints and **silently resolved the WHOLE package DOWN** to the last version that fit. `requires-python >= X` has **no upper bound**, so nothing surfaced the mismatch as an error — a downgrade reads as success. |181| **Evidence** | PR #310 / v1.17.16 ("Allow typer 0.25 to unblock Python 3.14 installs"); commit `20d22c8`. Current pin in `pyproject.toml` is **`typer>=0.12,<0.26`**. The `<0.25` floor existed because typer 0.26 dropped `CliRunner.isolated_filesystem()` that **49** CLI tests use; typer **0.25.1** threads the needle (keeps that API AND supports py3.14). |182| **Status** | **SETTLED** at `typer>=0.12,<0.26`. |183184**Rule:** When a **new** Python version yields a **stale** install, suspect a transitive185dependency **upper cap** (typer / click / pydantic), **not** `requires-python`. An upper cap that186is unsatisfiable on a newer Python degrades silently — always validate an unpinned install on the187newest supported Python end-to-end before assuming a cap is safe. Tightening any `<X` cap is a188change-control event (`tensor-grep-change-control`).189190## Battle 6 — release "didn't publish": the push-race (non-fast-forward)191192| Field | Detail |193|---|---|194| **Symptom** | PR CI is green, the `fix:`/`feat:` code is on `main`, but **no `chore(release): vX` commit appears and PyPI does not update** — the version "didn't publish". |195| **Root cause** | The real publish is the **`Semantic Release` job inside `.github/workflows/ci.yml`** (gated `github.ref=='refs/heads/main' && github.event_name=='push'`). It **compiles native assets first, so it runs ~6 min** — a race window. If **any** merge lands on `main` during that window — *including a no-release `docs:`/`chore:` PR* — `main` advances and the in-flight release's final `git push origin main` (the version-bump commit) is **rejected non-fast-forward** (`! [rejected] main -> main`). The CI concurrency group serializes **runs**, not the human/agent act of clicking merge. |196| **Evidence** | AGENTS.md "Release publish is not instant — the push-race (hard-won, re-confirmed 2026-07-02)". Receipt: **v1.17.23 (#318, a security batch) failed to publish** because the GPU-pause `docs:` PR **#319** was merged while #318's release job was still compiling assets. `release.yml` is `workflow_dispatch`-only (a manual tag cannot bypass semantic-release). |197| **Status** | **SETTLED discipline = one-merge-per-tick.** Merge ONE release-bearing PR -> wait for its `chore(release): vX` commit on `main` AND the new PyPI version -> then merge the next. Applies even to no-release docs/chore PRs. |198199**Recovery — do NOT panic-rerun.** The failure **self-heals**: the next push-to-`main` re-runs200`Semantic Release`, and because the version is **derived from git tags** (not the failed run's201state), it recomputes the correct next version and covers the orphaned commit. Just confirm that202next run's `Semantic Release` job succeeds. **Diagnose** with the structured result first:203`gh run view <id> --json jobs` -> find `Semantic Release` -> `gh run view <id> --log-failed`. A204`! [rejected] main -> main` line is the push-race signature; anything else is a different problem.205206## Battle 7 — the IDF-ranking-fragility flip (the IDF root-cause guess was DISPROVEN)207208| Field | Detail |209|---|---|210| **Symptom** | A change that added **zero ranking terms** (a GPU-code diff) silently flipped the agent capsule's bridge-binary choice from **"tie, ask the user"** to **"confidently pick a marker no-op"** — i.e. a ranking surface (`tg search --rank`, agent capsule, semantic) silently changed a **safety** behavior on corpus change, and the blast radius was **invisible to the call graph**. |211| **Root cause** | The **first guess — "missing IDF weighting" — was DISPROVEN** by a thinktank, and the minimal "just widen the candidate pool" fix was **proven insufficient**. The real cause is a stack: a **flat, no-IDF scorer** + a **hard top-5 candidate cap** + a `file_score` flip + an **alphabetical path tie-break**. (IDF = inverse document frequency, the term-rarity weighting BM25 uses; its absence makes common and rare terms score alike.) |212| **Evidence** | PR #302 shipped a **degrade-to-ask safety floor** (force `ask_user` when the post-swap primary is still an *unrequested* marker), de-fragilized the self-referential live-test into a safety-floor assertion, and moved the exact-identity contract to a deterministic fixture. See CHANGELOG #302 and AGENTS.md ranking notes. |213| **Status** | **PARTIALLY SETTLED — OPEN DEBT.** The **safety floor** shipped. The underlying **flat no-IDF scorer still exists** and remains fragile; a proper fix is deferred to a separate **benchmarked `repo_map`** PR. |214215**Rule:** Do **not** ship your first root-cause guess for a ranking flip (IDF was the wrong216guess here). Ranking surfaces can **silently degrade a safety behavior** on corpus change — treat217any capsule/`--rank`/semantic change as safety-relevant, keep the degrade-to-ask floor, and gate218scorer changes on a **measured** benchmark (`tensor-grep-benchmark-and-proof-toolkit`), never a219clean-looking diff. No speed/quality claim without numbers vs the accepted baseline.220221## Battle 8 — the broken-oracle trap: green mock/FFI tests while the real bridge was DEAD222223| Field | Detail |224|---|---|225| **Symptom** | Mock-based FFI tests were **green**, yet the real PyO3 passthrough bridge **dropped every forwarded flag** — the passthrough was effectively **dead** and users got wrong/unaccelerated results. |226| **Root cause** | The bridge passed `None` for `glob`/`file_type` into an rg `Vec<String>` -> `TypeError` -> a broad `except` swallowed it into a **silent fallback** that dropped all flags. The **mocks never exercised the real extension**, so the tests validated nothing about the live boundary — a **broken oracle** (a test that passes regardless of whether the thing works). |227| **Evidence** | PR #309 / v1.17.15 ("Forward dropped rg flags through PyO3 bridge + revive the passthrough (audit #3)"); commit `fd30e6d`. The fix forwards the hardcoded flags and guards `(… or [])`. |228| **Status** | **SETTLED.** |229230**Rule:** **Verify FFI/bridge changes against the REAL extension** (a live `maturin develop`231runtime call), never mocks alone. A green mock is a hypothesis, not proof (see the unwritten rule232"never trust a self-report"). For the eval/grader version of this trap — a whole lane failing233uniformly, a grader that passes an empty answer, `pnpm.cmd` exit-127 on Windows — load234**`trustworthy-cuj-scoring`**, which enforces **bidirectional** oracle validation (a correct235answer must PASS *and* a wrong/empty answer must FAIL before any batch is trusted).236237## Battle 9 — native delegation silently dropped `--rank`/`--sort-files` (+ the `query_pattern` landmine)238239| Field | Detail |240|---|---|241| **Symptom** | `tg search --rank --cpu` (also `--rank --json`/`--ndjson`, `--sort-files --cpu`) silently returned **unranked / unsorted** results — no error, output just looked plausible and wrong. |242| **Root cause** | Native-tg delegation `sys.exit()`s **before** the Python-side BM25 rerank and the in-backend sort ever run. `rank_bm25` and `sort_files` were `SearchConfig` fields that were neither forwarded into the native argv nor listed in the refuse-tuple (`_NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS`), so the gate happily delegated and the flags evaporated. A tempting alternative fix — a generic **"differs-from-default" runtime gate** that refuses delegation whenever any field is non-default — was explicitly rejected: `query_pattern` is auto-set on **every** search, so a blanket differs-from-default check would trip on it on literally every invocation and kill the native fast path entirely (a re-confirmation of a 2026-06-30 failure mode; see `tensor-grep-config-and-flags`). |243| **Evidence** | Commit `5e6f780` (#342, "fix: refuse native delegation for --rank/--sort-files (silent wrong-output) + coverage ratchet") adds `sort_files`/`rank_bm25` to `_NATIVE_TG_DELEGATION_DEFAULT_REQUIRED_FIELDS` in `src/tensor_grep/cli/main.py`. New governance ratchet `tests/unit/test_native_delegation_field_coverage.py` **AST-derives** the forwarded-field set from `_build_native_tg_search_command` and asserts every `SearchConfig` field is forwarded \| refused \| gate-handled \| in the documented `_NATIVE_TG_DELEGATION_KNOWN_GAP_FIELDS` frozenset (which explicitly names `query_pattern` with the differs-from-default caveat in its comment). |244| **Status** | **SETTLED** for `rank_bm25`/`sort_files`. **OPEN debt**: the `KNOWN_GAP` frozenset (`ast_prefer_native`, `ast_selector`, `ast_stdin`, `nlp_threshold`, `use_jit`, `ignore_dot`, …) documents fields that were *already* silently dropped before #342 — acknowledged, not fixed. **`case_sensitive` is NOT in this set** — audit #19 forwarded it into the native argv via `-s`, so it graduated out of `KNOWN_GAP` into the Forwarded bucket; do not list it as a gap in new docs (`tensor-grep-config-and-flags` records this explicitly). |245246**Rule:** Same bug class as #336's `-u`/`-uu` no-op — a `SearchConfig` field that affects output is247a silent-drop landmine unless it is forwarded to the native argv, added to the refuse-tuple, or248explicitly gated. Adding a new `SearchConfig` field? Load `tensor-grep-config-and-flags` and run249the coverage ratchet. **Never** propose a generic "differs-from-default" gate for delegation250refusal — `query_pattern` makes it degenerate to "always refuse."251252## Battle 10 — the `capfd` capture-surface trap: a delegation-routing fix broke fd-level test assertions253254| Field | Detail |255|---|---|256| **Symptom** | Right after Battle 9's fix made `--rank` correctly refuse native delegation, `test_search_rank_reorders_by_bm25` started raising `JSONDecodeError` — every `test-python` job on `main` went red, even though the BM25 rerank behavior itself was correct. |257| **Root cause** | Before the fix, `--rank` wrongly delegated to a **native subprocess**, whose stdout landed on the real OS file descriptor — exactly what pytest's `capfd` fixture (fd-level capture) reads. After the fix, the rerank runs **in-process**, so the JSON is emitted via `typer.echo()` inside the `CliRunner`-invoked call and lands in `CliRunner`'s captured `result.stdout` (a Python-level buffer), never touching the OS fd. The test still read `capfd.readouterr().out` → empty string → `json.loads("")` failed. |258| **Evidence** | Commit `ab717a1` (#343, "test: fix --rank bm25 capture for the non-delegation path (#342 follow-up)"), diff in `tests/integration/test_bm25_search_flag.py`: dropped the `capfd` fixture parameter, switched the assertion to `json.loads(result.stdout)`. Comment left in the test explains the fd-vs-in-process split "only surfaces when the native binary is built, which PR CI skips but main/release CI builds." |259| **Status** | **SETTLED.** |260261**Rule:** A test that reads `capfd` against a `CliRunner.invoke()` result is **implicitly asserting262that the command delegated to a real subprocess** (whose output crosses the OS fd boundary) rather263than running in-process. Any change to native-delegation routing — what gets forwarded vs. refused264— can silently flip which capture surface carries the real output. If you touch265`_can_delegate_to_native_tg_search` or the refuse-tuple, run `tests/integration/` locally, not just266`tests/unit/`. This is the sibling of Battle 3's "dogfood the real binary, not `CliRunner`": here267`CliRunner` is the right tool, but you must know **which stream** it captures for the path you just268changed.269270## Battle 11 — `MatchLine` hashability regression, and a "free" micro-opt that wasn't271272| Field | Detail |273|---|---|274| **Symptom** | (a) A `MatchLine` with a populated `submatches` field raised `TypeError` on `hash()` — a frozen dataclass silently lost its hashability contract. (b) A follow-on "obvious" optimization (only stash `submatches` when `--vimgrep`/`--column` is requested) broke an existing test in the same PR. |275| **Root cause** | (a) `submatches` (added in #340) is typed `tuple[dict[str, object], ...] \| None` — a tuple of dicts is **unhashable** — so any future caller that hashes/dedupes a `MatchLine` would crash the instant a real rg run populated it. (b) The gating micro-opt (`want_submatches = config.vimgrep or config.column`) looked like free wasted-work removal — a profiler had flagged the per-match stash as a small cost during the Battle 12 blast-radius hunt — but `tests/unit/test_submatches_output_shaping.py::test_backend_stashes_submatches_without_inflating_count` calls `RipgrepBackend().search(..., SearchConfig())` with a **default** config (`vimgrep=False`, `column=False`) and asserts `result.matches[0].submatches is not None`. The gate made that default-config case return `None`, failing the test — the "wasted work" was not actually dead: a consumer expects it populated regardless of output format. |276| **Evidence** | Commit `80de0b4` (#344). The **final merged diff** touches only `src/tensor_grep/core/result.py` (`submatches: tuple[...] \| None = field(default=None, compare=False)`) and adds `tests/unit/test_matchline_submatches_hashable.py` — it does **not** touch `src/tensor_grep/backends/ripgrep_backend.py`. The two squashed sub-commits show why: `65022bc` added the `want_submatches` gate to `ripgrep_backend.py` (`+3 -1` there), and `4c34516` **reverted exactly that gate** (`ripgrep_backend.py \| 5 +----`), keeping only the hashability fix. |277| **Status** | **SETTLED** for (a) — `compare=False` is the shipped fix. (b) is **SETTLED as "already tried and reverted"** — do not re-propose the vimgrep/column gate without first checking `test_submatches_output_shaping.py`'s consumer contract. |278279**Rule:** `field(default=None, compare=False)` is the fix shape for "add a field to a frozen280dataclass that must stay hashable but holds an unhashable value type" (the offsets are a pure281function of text+line, so excluding it from `==` is correct too). Before gating a per-match stash282"because only formatter X consumes it," grep the actual consumers first — a test that breaks the283instant you ship a profiler-motivated micro-opt is not an obstacle to route around; it is the284consumer contract telling you the guess was wrong.285286## Battle 12 — the reported +33% blast-radius "regression" was noise; the council's own hot-path guess was also wrong287288| Field | Detail |289|---|---|290| **Symptom** | An AI-user report claimed `tg blast-radius` (depth-2) regressed **+33%** (188s → 250s) between two tagged versions. |291| **Root cause** | Two separate wrong guesses were made and disproven before the real fix. **(1)** A 3-lens+opus review council correctly ruled out a noise-regression theory on the reported deltas, but then **guessed** the hot path was AST parsing — live profiling later showed `compile()` was only **3.6%** of runtime, and a `Counter`-monkeypatch of `ripgrep_backend` calls showed **zero** frames on the actual `tg callers` path the council had profiled (they profiled the wrong file/path — 0 calls on-path). **(2)** A follow-on regression-hunt workflow's own synthesis guessed "cache the AST parse" as the fix; profiling proved that would have saved only **~3%**. The ACTUAL dominant cost, found only by a **profile-at-scale** of the real `tg blast-radius --depth 2` invocation on a high-fan-in symbol (290 callers), was `_module_aliases_for_path`: called **1,431,341 times** for only **~1,000 unique path inputs** inside the reverse-import-graph / PageRank loops (6.1s self / 38s cumulative of a 62s run) — a pure function of the path string, rebuilt on every call instead of cached. Separately, the reported cross-version **+33% was itself confirmed to be environmental noise**: the plain `tg callers` path was byte-identical between v1.17.31 and HEAD. |292| **Evidence** | Commit `bb5dc59` (#345, "perf: memoize _module_aliases_for_path — blast-radius depth-2 ~4.8x faster (62s->13s)"); adds `@lru_cache(maxsize=16384)` + a `frozenset` return type to `_module_aliases_for_path` in `src/tensor_grep/cli/repo_map.py`, plus `tests/unit/test_module_aliases_cache.py`. Result: **61.7s → 12.8s (4.8x)**, byte-identical output (affected=62; 231 blast-radius/callers/pagerank parity tests unchanged). Commit message states explicitly: "corrects the regression-hunt synthesis, which guessed AST-parse caching (would have saved ~3%)." |293| **Status** | **SETTLED** — real fix shipped and dogfooded. The "+33% regression" claim itself is **SETTLED as noise**, not a code defect. |294295**Rule:** For a **latency** claim, the profiler is the oracle — not a diff-review council, and not296even a first-pass profiler read: this hunt needed a **second** profiling pass (profile the real297command at scale, with a call-count monkeypatch) before the true hotspot appeared, because the298first profile ran on the wrong command entirely. Before accepting any "X% regression" report,299reproduce it on the byte-identical code path across both versions before designing a fix — it may300be noise (see also `tensor-grep-benchmark-and-proof-toolkit`). A pure, no-I/O helper function301called repeatedly inside a graph/loop algorithm is a near-free `@lru_cache` candidate; check302`dict[str, set]` → `dict[str, frozenset]` downstream type hints when the cached value is303shared/iterated but never mutated in place.304305**Addendum (2026-07-24) — the mirror-image trap: a warm run can hide a real WIN too.** The same306"measure the actual thing, not a proxy" discipline cuts the other way: a **warm** end-to-end dogfood307rerun measures whatever CACHED path the optimized function no longer sits on, so a real improvement308can read as noise, or even a false regression, if the changed function only runs on the **cold**/309first-touch path. Two later optimizations — `9a2a01c` ("merge 3 redundant `ast.walk` passes in310`_python_imports_and_symbols` into one") and `d2c1266` ("textual pre-check before per-candidate311validation-test AST parse") — were verified with a **single-pass, fresh-process microbenchmark over312distinct inputs** instead of a warm end-to-end dogfood rerun, specifically to avoid this trap; see313`profile-guided-byte-identical-optimization` for the general technique. Same rule as this battle's314own Rule paragraph, one layer deeper: the proxy trap this time is warm-cache reuse, not "profiled315the wrong command."316317## Battle 13 — `tg diff-docs`: 17 green fixture tests, 20k+ false positives on the real corpus (deliberately deferred)318319| Field | Detail |320|---|---|321| **Symptom** | A doc-drift-detection prototype (`tg diff-docs`: flag a doc code-span identifier that no longer resolves to a repo symbol) shipped a fully-passing fixture suite — **17/17** unit tests green — but running it on the **real** repo corpus (`docs/` vs `src/`) produced an unusable flood of findings dominated by stdlib/language type names. |322| **Root cause** | Fixture tests only exercise the hand-picked cases the author thought of; they cannot reveal a heuristic's real-world false-positive rate. The design's own module docstring already cited the risk — "DocPrism: naive code-doc drift detection is 0.62 precision / 98% flag-rate" (arXiv 2511.00215) — but shipped anyway because the fixtures were green. Dogfooding on the real corpus confirmed the DocPrism trap: "unresolved symbol in a doc code-span" flags common language/stdlib type names (e.g. `String`, `Option`, `Vec`) that were never going to resolve in `repo_map`'s symbol table because they are not project symbols — the heuristic has no **positive** repo-reference signal, only a "not found locally" absence signal, so anything foreign reads as "drifted." |323| **Evidence** | Commit `90b7042` on branch `wip/diff-docs-precision` ("wip: tg diff-docs foundation (DEFERRED — precision inadequate, see task)"), adding `src/tensor_grep/cli/diff_docs.py` (303 lines) and `tests/unit/test_diff_docs.py` (17 `def test_...` functions, direct-counted). `docs/SESSION_HANDOFF.md` records: "`tg diff-docs` was prototyped and deliberately deferred pending a precision rebuild — naive doc-drift detection floods false positives (documented follow-up)." The feature was **never merged to `main`**; it lives only on the `wip/` branch. (The exact real-corpus finding counts were an ad hoc dogfood measurement during the deferring session, not persisted as a committed benchmark artifact — treat the shape, not the digits, as load-bearing.) |324| **Status** | **OPEN / DEFERRED BY DESIGN.** Not a bug to fix — a feature intentionally **not** shipped. A rebuild needs (1) a positive repo-reference signal (e.g. a qualified `repo_module.symbol` span where the module resolves in-repo but the symbol doesn't — not a bare identifier), and (2) a measured real-corpus precision gate **before** merging, not just green fixtures. |325326**Rule:** **Fixture-green is not sufficient for a precision/heuristic feature.** Before shipping327anything that classifies or flags ("is this drifted", "is this dead code", "is this a bug"),328dogfood it on the **real** project corpus and eyeball the finding **count** and the **top hits**,329not just whether the unit tests pass. Prefer deferring (park on a `wip/` branch) over shipping a330feature that trains the agent/user to ignore its own output — thousands of false positives is331worse than no feature. See also Battle 8's broken-oracle framing: a fixture suite that only tests332hand-picked true/false cases is a weaker oracle than a real-corpus run.333334## Battle 14 — sequential release-watcher deadlock: gate on absolute state, never "changed since I launched"335336| Field | Detail |337|---|---|338| **Symptom** | A sequential merge-watcher (Battle 6's one-merge-per-tick discipline, automated) that gates the next merge on "has the release tag changed **since I started watching**" can deadlock permanently if a release was already in flight — or already published — at the moment the watcher launched, because the tag never "changes" relative to a baseline that was itself already stale. |339| **Root cause** | A relative baseline ("did X change since t0") is only correct if t0 is guaranteed to precede the event being waited for. A watcher that launches **after** a publish already started (or completed) cannot distinguish "still waiting" from "already done" if it only tracks deltas from its own launch time — it must instead check **absolute** state: the prior PR's `state == MERGED`, and the specific `main` CI run (identified by run ID / commit SHA, not "newer than my start time") has actually completed. The full pipeline is also longer than the ~6-minute pre-publish compile window documented in Battle 6 — a real measured run (PR #346, `gh run` id `28667549718`, 2026-07-03) took CI-verification-start (15:01) to `publish-pypi` success (15:41) — **~40 minutes** — and to the post-publish `release-tag-smoke` check completing (15:45) — **~45 minutes end-to-end**. `build-release-native-assets (macos-15-intel, cpu)` and `native-build-smoke (macos-15-intel)` were consistently the **longest individual jobs** (~12 min and ~11.7 min respectively) at both the CI-verification and release-asset-build stages — a watcher with a short or relative-only wait window will misdiagnose an in-flight release as "stuck" or "already done." |340| **Evidence** | Measured via `gh run view 28667549718 --json jobs`: `repo-hygiene`/`smoke` start `15:01:06-15:01:19`; `Semantic Release` `15:21:43-15:25:49`; `build-release-native-assets (macos-15-intel, cpu)` `15:25:53-15:37:58` (the longest release-asset job); `publish-pypi` `15:41:16-15:41:57`; `release-tag-smoke` completes `15:45:47`. AGENTS.md's "Release publish is not instant — the push-race" section (Battle 6) documents the shorter ~6-minute pre-publish compile window that creates the push-race; the **full** pipeline through PyPI + tag-smoke is longer still, as measured above. |341| **Status** | **SETTLED discipline** (a corollary to Battle 6, applied to automated watchers rather than human merge timing): gate any sequential watcher on **absolute** conditions — prior PR `state == MERGED` AND the specific `main` CI run (by ID) has `status == completed` — never on "did the tag change since my launch." Size any watcher wait window for the full measured pipeline (~40-45 min), not the ~6-minute compile window alone. |342343**Rule:** This is Battle 6's push-race discipline applied to **automation**, not just human merge344timing. A polling/watcher loop that checks "did state change since I started" is race-prone345whenever the state could have already changed **before** the loop started (a pre-launch publish).346Always identify a specific, absolute target to wait FOR (a run ID, a PR merged-state, a version347string) — never a relative delta from an arbitrary observation point. Re-measure the pipeline348duration periodically (`gh run view <id> --json jobs`) — CI job mix and runner speed drift over349time, and a stale duration assumption is how a watcher's timeout gets tuned wrong.350351---352353## Battle 15354355…(truncated)