Vault

tools/

Deterministic generators & audit gates for the vault — the derive half of method/author-web-derive-hierarchy.md: keep the vault write-optimized (a web of forward links), and let tools derive the read-optimized checks instead of auditing by hand.

Most of what follows maintains the vault itself. One exception is noted as such: risk-battle-odds.py is a research solver backing a specific page, kept here because it is the same kind of artifact — a deterministic generator whose output is checkable — and because a solver that lives next to its page cannot go unfindable.

Consumers — the skills that must run these rather than re-implement them: /vault-heartbeat (its whole structural pass is vault-graph + vault-tagindex + tag-counts + open-questions) and /vault-sync (step 4 → tag-counts.py). Registered 2026-08-26, after a heartbeat hand-rolled its own broken-link and tag checkers because nothing routed it here — the index existed, the pointer to it didn’t. Unfindable capability gets rebuilt; that is what the pointer is for.

vault-graph.py — the derive-the-ledger audit gate

Scans the markdown link graph and reports:

py -3 tools/vault-graph.py

Exit 0 = no HARD violations (usable as a pre-commit gate); 1 = violations.

First run (2026-06-08): ledger reciprocity CLEAN (0) — validates the specimen↔thesis reorg deterministically. Surfaced 178 broken links, mostly tag/INDEX deep-links to game-annotation/* and pygone/* subpages that were extracted to standalone repos (the repo-extraction seam-breaker at scale) — the baseline to clean before wiring this as a blocking gate.

Most broken links are path-depth bugs or moved files where the target still exists under the same filename. For each broken link, if exactly ONE vault .md file has that basename, rewrite to the correct relative path (preserving #anchors); no-match or ambiguous (e.g. README.md) targets are reported, never guessed.

py -3 tools/vault-fix-links.py            # dry-run
py -3 tools/vault-fix-links.py --write    # apply

First run (2026-06-08): auto-fixed 96 of the baseline’s broken links (the whole history-of-philosophy ../../../tags/ depth bug + moved-file paths). The ~20 it left were genuine decisions — extracted-repo deep-links (redirected to the game-annotation stub), missing tag files (created), and intentional doc placeholders. Combined with manual mop-up, the baseline went 178 → 0 broken links. (A greedy manual substring edit during mop-up re-broke 5 valid links — and vault-graph.py caught it immediately. The gate works.)

Fixed 2026-08-26 — two false-positive bugs. (1) Link targets were resolved on disk without percent-decoding, so any legitimate %20 path reported [missing] — every link into raw/ (whose filenames carry spaces) was a standing false positive, including ones on published pages. (2) raw/ was excluded from the basename candidate index as well as from scanning, so a broken link into raw could never be matched. Now split: EXCLUDE still means never-scanned and never-rewritten (raw is immutable), while INDEX_ONLY re-admits raw’s .md files as link targets. Emitted replacements re-encode spaces as %20 to match vault convention. This cleared 5 false positives; the 4 that remain are genuine doc placeholders. Noted because AI as a Cognitive Tool cites this very checker as its worked example of a verification layer that silently degraded — it had degraded again.

vault-tagindex.py — regenerate the tag reverse-indexes (the “derive indexes” half)

Source of truth = each page’s ## Tags section (which links to ../tags/<tag>.md); this derives the reverse index. CONSERVATIVE — it does not clobber curated descriptions: prunes entries whose target no longer exists (repo-extraction casualties), adds pages that tag X but aren’t listed (with the page’s own > one-liner), and keeps existing valid entries verbatim. Preserves each tag file’s header.

py -3 tools/vault-tagindex.py            # dry-run summary (-N broken, +N new per file)
py -3 tools/vault-tagindex.py --write    # apply

First apply (2026-06-08): 41 tag files reconciled — broken links 178 → 117, ~200 missing memberships added (+48 philosophy, +29 morality, …, the hand-maintained index’s rot). The remaining 117 broken links are non-tag (INDEX deep-links, a few wrong-path bugs) — a separate pass.

open-questions.py — the open-questions registry (the recall half of reuse-over-rebuild)

Derives notes/open-questions.md from every page’s ## Open Questions section, so a question can be matched against the vault by a tool rather than by remembering to grep. Fixes the failure named in Vault/CLAUDE.md, Check the open questions. Modelled on REA’s record_unknown (REA candidate tool), but derived: the pages stay the source of truth and the registry is a pure function of them.

py -3 tools/open-questions.py                 # report; exit 1 if the registry is stale
py -3 tools/open-questions.py --write         # regenerate notes/open-questions.md
py -3 tools/open-questions.py --find "TEXT"   # run before filing a finding or question as new

First run (2026-10-16): 1,032 questions on 217 pages (981 open, 51 resolved); 2 possibly answered elsewhere, 15 possible re-asks, 5 marker drifts, 19 resolved without a link. The top answered elsewhere hit is real: the multimodal-grounding question resolved on LLM Grounding Problem on 2026-09-16 is still open on Cognitive vs. Motor. --find "is there a fifth" ranks reading-outcome-statistics.md’s question first, the match CLAUDE.md says was found only by luck. First triage (2026-10-16, with Chris): both answered elsewhere hits resolved, the 5 drifts converted, and 9 re-asks given one home each, which added the ↪ and cross-link rules above. The 3 that remain (insurance/scope-confusion ×2, zappatic part 1/6) are false positives and were left on purpose.

risk-battle-odds.py — exact Risk battle odds (research solver, not a vault gate)

Backs research/gaming/risk-attrition-odds.md. Solves a Risk battle fought to the death as an absorbing Markov chain — exact, no simulation — returning P(win), the full survivor distribution, percentiles, and the closed-form mean/spread.

py -3 tools/risk-battle-odds.py 299 300 --stack --observed 54   # a real battle, scored
py -3 tools/risk-battle-odds.py 298 300 --reduced               # show what the reduction trims
py -3 tools/risk-battle-odds.py --selftest                      # exit 0 = clean

Two engines, deliberately. solve() is a forward sweep over the whole (a,d) grid; reduced() exploits the fact that a 3-vs-2 round kills exactly two armies (so a+d parity is invariant and the bulk is a 1-D IID walk) and solves the a≤2 or d≤1 boundary shell separately. They are structurally independent, so --selftest cross-checks them against each other on every run — plus the per-round dice table against the published Risk odds, and the martingale identity behind the closed form. That mutual check is the point: the reduction is not faster (both are O(A·D)/2), it is a second oracle.

cook-county-rail-deaths.py — how the Cook County ME classifies train deaths (research query, not a vault gate)

Like risk-battle-odds.py, a research tool kept next to the pages it backs (Reading Outcome Statistics #3, Mens Rea § Manner of death), so the published numbers can be re-derived. It queries the county’s open Medical Examiner Case Archive (Socrata cjeq-bs86) and prints rail deaths by year and manner, the ethanol subset, and a case list for one city.

py -3 tools/cook-county-rail-deaths.py [--city "DES PLAINES"]

First run (2026-09-29): 472 rail deaths from 2014 to September 2026, 219 suicide and 219 accident. The ethanol subset was 10 of 10 accident. Des Plaines had 12 cases, and all five at the station address were suicide.

Derives the reverse walk and writes it into a marked, auto-generated ## Backlinks block at the bottom of each research//notes//method/ page. Idempotent. Held back by default: a dry-run shows it would touch 193 pages, it duplicates Obsidian’s native backlinks pane, and it pushes against the vault’s “keep pages lean” rule — so it’s an on-demand tool, not an always-run gate.

py -3 tools/vault-backlinks.py            # dry-run summary
py -3 tools/vault-backlinks.py --write    # apply (193 pages)