diff --git a/docs/tickets/ticket-000035-prg-choice-phi-prg.md b/docs/tickets/ticket-000035-prg-choice-phi-prg.md
index e57ecaa..23fede8 100644
--- a/docs/tickets/ticket-000035-prg-choice-phi-prg.md
+++ b/docs/tickets/ticket-000035-prg-choice-phi-prg.md
@@ -271,12 +271,16 @@ the moment v7 needs it.
from any version number. Acceptance-criteria §5 item 1 reads
through to "or equivalent location" so the move doesn't
invalidate the original criterion.
-- ``tests/test_anchor_prg.py`` — 20 tests covering determinism,
+- ``tests/test_anchor_prg.py`` — 27 tests covering determinism,
range invariants, chi² loose-uniformity sanity, dim_h boundary
(1, 16384), seed-bit-flip avalanche, hash-bit-flip avalanche,
- input validation (short/long/non-bytes hashes; non-positive /
- non-int dim_h), module export shape, and KAT regression.
-- ``bench/fixtures/phi-prg/known-answer-tests.jsonl`` — 10 pinned
+ hand-computed HMAC-SHA-512 first-block formula, prefix-extension
+ closure, parametrized invalid-input cones, input validation
+ (short/long/non-bytes hashes; non-positive / non-int dim_h),
+ module export shape, and KAT regression. (Was 20; +7 from
+ ``de997f7`` 2026-05-10 pattern backfill per
+ ``docs/calculator-test-patterns.md``.)
+- ``bench/fixtures/phi-prg/known-answer-tests.jsonl`` — 10 pinned
KAT vectors covering the placeholder seed (smoke), block-boundary
cases (dim_h=16 = exactly one HMAC-SHA-512 block; dim_h=17 = two
blocks with truncation), seed/hash one-bit-flip variants, and a
diff --git a/docs/warrant-substrate-cookbook.md b/docs/warrant-substrate-cookbook.md
index 5c38992..92b0ad1 100644
--- a/docs/warrant-substrate-cookbook.md
+++ b/docs/warrant-substrate-cookbook.md
@@ -361,7 +361,7 @@ The 92 chains have three quality tiers:
*proves* the axiom is a separate, weaker claim. Heuristic-grade.
The schema doesn't currently distinguish these tiers. Audit
-reviewers walk `citation_aliases` (74 rows live as of 2026-05-10) + read
+reviewers walk `citation_aliases` (74 rows live as of 2026-05-10) + read
`decision_rationale` fields to grade each chain. If tier
distinction becomes load-bearing, add a `tier ∈ {primary,
substitute, soft}` column to `derivations`.
@@ -434,8 +434,8 @@ arborist warrant-resolve --use-aliases --write
## References
- `#000031` — surface-ingest cited textbooks
-- `#000041` — citation-aliases table + 74 live rows
-- `#000042` — term-aliases table + 13 live rows (5 arithmetic + 4 classical-physics + 4 geometry)
+- `#000041` — citation-aliases table + 74 live rows
+- `#000042` — term-aliases table + 13 live rows (5 arithmetic + 4 classical-physics + 4 geometry)
- `#000038` — Phase 4 content acquisition (still-blocked
proprietary candidates: Hilbert-Ackermann 1928, Mendelson,
Enderton, Jech, Goldstein, Barendregt, Stanley, Brualdi, Knuth;
@@ -566,17 +566,17 @@ than waiting for bench-time STRICT-rate drift to surface it.
| domain | LOC tested | test LOC | test/code ratio |
|---|---|---|---|
-| aliases.py | 512 | 469 (28 tests) | 0.92 |
+| aliases.py | 512 | 469 (28 tests) | 0.92 |
| warrant_resolver.py | ~800 | ~430 (combined) | 0.54 |
-| warrant_chain.py | 89 | 320 (9 tests) | 3.6 |
-| t3_bound_calculator.py | 249 | 446 (53 tests) | 1.79 |
-| fork_score.py | 298 | 403 (18 tests) | 1.35 |
-| weights.py | 73 | 180 (16 tests) | 2.5 |
-| pi_star/protocol+registry | 124 | 280 (21 tests) | 2.3 |
-| qa/progress.py | 85 | 226 (31 tests) | 2.7 |
-| qa/prompts.py | 153 | 207 (20 tests) | 1.35 |
-| concepts/extract.py | 288 | 278 (20 tests) | 0.97 |
-| textbooks_manifest.py | 236 | 463 (43 tests) | 1.96 |
+| warrant_chain.py | 89 | 320 (9 tests) | 3.6 |
+| t3_bound_calculator.py | 249 | 446 (53 tests) | 1.79 |
+| fork_score.py | 298 | 403 (18 tests) | 1.35 |
+| weights.py | 73 | 180 (16 tests) | 2.5 |
+| pi_star/protocol+registry | 124 | 280 (21 tests) | 2.3 |
+| qa/progress.py | 85 | 226 (31 tests) | 2.7 |
+| qa/prompts.py | 153 | 207 (20 tests) | 1.35 |
+| concepts/extract.py | 288 | 278 (20 tests) | 0.97 |
+| textbooks_manifest.py | 236 | 463 (43 tests) | 1.96 |
Pattern: small contract-defining modules (weights, prompts,
warrant_chain) get 1.4-3.6× test LOC because they're foundation;
diff --git a/tests/test_doc_counts.py b/tests/test_doc_counts.py
index 3fee722..84a7570 100644
--- a/tests/test_doc_counts.py
+++ b/tests/test_doc_counts.py
@@ -21,6 +21,12 @@ Where ``metric`` is one of:
live value is ``pytest --collect-only`` count.
- ``fixture-rows`` — ``path`` is a JSONL fixture; the live value
is the non-blank-non-comment line count.
+- ``db-rows`` — ``path`` is either ``
`` (resolves against
+ the default shard ``~/.arborist/shards/000.db``) or
+ ``:`` (resolves against ``~/.arborist/shards/``).
+ When the DB or table isn't present (CI or fresh checkout), the
+ claim is skipped with a logged note rather than failing — claim-
+ pack and alias counts are operator state, not source-state.
GitHub and most markdown renderers strip HTML comments, so readers
see only ``N``. The tags are invisible in rendered output but
@@ -95,6 +101,62 @@ def _live_fixture_rows(path: str) -> int:
)
+_DB_MISSING = -2
+_TABLE_MISSING = -3
+_DB_ERROR = -4
+
+# Default location for arborist alias / claim-pack tables. Operator state,
+# not source state — absence is not a failure, it's a skip signal.
+_DEFAULT_SHARDS_DIR = Path.home() / ".arborist" / "shards"
+_DEFAULT_SHARD = _DEFAULT_SHARDS_DIR / "000.db"
+
+
+def _live_db_rows(target: str) -> int:
+ """Count rows in a SQLite table.
+
+ Target syntax::
+
+ # ~/.arborist/shards/000.db (default)
+ : # ~/.arborist/shards/
+
+ Sentinel returns:
+ _DB_MISSING (-2) DB file not present — caller marks skipped
+ _TABLE_MISSING (-3) DB present but table absent — caller marks skipped
+ _DB_ERROR (-4) sqlite3 error — caller marks skipped
+ N >= 0 live row count
+ """
+ import sqlite3
+
+ if ":" in target:
+ shard_name, table = target.split(":", 1)
+ db = _DEFAULT_SHARDS_DIR / shard_name
+ else:
+ db = _DEFAULT_SHARD
+ table = target
+ if not db.exists():
+ return _DB_MISSING
+ # Validate table name is a bare identifier — defends against the
+ # dynamic SQL string interpolation below. Tags are author-controlled
+ # but this is belt-and-suspenders.
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
+ return _DB_ERROR
+ try:
+ c = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
+ try:
+ row = c.execute(
+ f"SELECT COUNT(*) FROM {table}" # noqa: S608 — validated above
+ ).fetchone()
+ return int(row[0]) if row else 0
+ finally:
+ c.close()
+ except sqlite3.OperationalError as exc:
+ if "no such table" in str(exc).lower():
+ return _TABLE_MISSING
+ return _DB_ERROR
+ except sqlite3.Error:
+ return _DB_ERROR
+
+
def _iter_claims() -> Iterator[tuple[Path, int, str, str, int]]:
"""Yield (doc_path, lineno, metric, target_path, claimed_n)."""
for md in sorted(DOCS_DIR.rglob("*.md")):
@@ -110,7 +172,7 @@ def _iter_claims() -> Iterator[tuple[Path, int, str, str, int]]:
)
-def test_doc_autocount_claims_match_live() -> None:
+def test_doc_autocount_claims_match_live(capsys: pytest.CaptureFixture[str]) -> None:
"""Every ```` claim in ``docs/`` matches live."""
claims = list(_iter_claims())
# Batch the pytest-collect call across every tests:* claim — one
@@ -119,25 +181,60 @@ def test_doc_autocount_claims_match_live() -> None:
test_counts = _live_test_count_batch(test_paths)
drifts: list[str] = []
+ skipped: list[str] = []
for doc, lineno, metric, target, claimed in claims:
rel = doc.relative_to(REPO_ROOT)
if metric == "tests":
live = test_counts.get(target, -1)
+ if live < 0:
+ drifts.append(
+ f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) target "
+ f"missing or uncollectable"
+ )
+ continue
elif metric == "fixture-rows":
live = _live_fixture_rows(target)
+ if live < 0:
+ drifts.append(
+ f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) target "
+ f"missing or uncollectable"
+ )
+ continue
+ elif metric == "db-rows":
+ live = _live_db_rows(target)
+ if live == _DB_MISSING:
+ skipped.append(
+ f"{rel}:{lineno} db-rows:{target} skipped — "
+ f"{_DEFAULT_SHARDS_DIR} not present (CI / fresh checkout)"
+ )
+ continue
+ if live == _TABLE_MISSING:
+ skipped.append(
+ f"{rel}:{lineno} db-rows:{target} skipped — table not "
+ f"present in shard"
+ )
+ continue
+ if live == _DB_ERROR:
+ skipped.append(
+ f"{rel}:{lineno} db-rows:{target} skipped — sqlite "
+ f"error or invalid table name"
+ )
+ continue
else:
drifts.append(f"{rel}:{lineno} unknown AUTOCOUNT metric {metric!r}")
continue
- if live < 0:
- drifts.append(
- f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) target "
- f"missing or uncollectable"
- )
- elif live != claimed:
+ if live != claimed:
drifts.append(
f"{rel}:{lineno} AUTOCOUNT({metric}:{target}) claims "
f"{claimed}, live is {live}"
)
+ if skipped:
+ # Print to captured stdout — pytest -v shows it; the suite still
+ # passes as long as no live count actually drifted.
+ with capsys.disabled():
+ print(f"\n{len(skipped)} db-rows AUTOCOUNT claim(s) skipped:")
+ for s in skipped:
+ print(f" {s}")
assert not drifts, "Doc count drift detected:\n " + "\n ".join(drifts)
@@ -168,7 +265,7 @@ def test_autocount_tags_are_well_formed() -> None:
def test_autocount_metric_names_are_documented() -> None:
"""Fail-closed if a doc uses an undocumented metric."""
- known = {"tests", "fixture-rows"}
+ known = {"tests", "fixture-rows", "db-rows"}
seen: set[str] = set()
for _, _, metric, _, _ in _iter_claims():
seen.add(metric)