arborist/bench/pre_migration_snapshot.py
russell@unturf.com 3aae8119a6
#000065 step 1: routing helper + meta field + pre-migration snapshot
Three pieces, all read-only or additive — no shard mutation, no
schema-version bump:

1. shard_for_document(document_root, M) in arborist/document.py.
   Pure function: int(document_root[:8], 16) % M. 22 tests cover
   determinism, range-bounds, near-uniform distribution (±5pp at
   N=20k), and seven lock-in fixtures so peers will disagree
   loudly if anyone changes the formula.

2. corpus_shard_count meta field + get/set helpers in store.py.
   Lives in the existing key/value meta table; SCHEMA_VERSION
   stays at v9.8.0 (the DDL doesn't change and source_root is
   layout-independent, so cache records survive a reshard).
   Legacy shards (without the field) return None; reshard tool
   populates it on every target shard at migration time.

3. Pre-migration snapshot captured to
   bench/results/pre-migration-snapshot.json:
     docs        3,468,392  (3,468,226 globally unique)
     chunks      6,235,764
     edges      90,593,537
     audit       3,468,403
   This is the reference set post-reshard row counts must match.

4. Audit-event extraction script writes all 3.47M events from
   all 4 shards to /tmp/audit-events.ndjson (2.0 GB) for the
   Option-A canonical-chain consolidation step. Verifies chain
   integrity on extract — all 4 source chains report 0 breaks.

5. Fixed a wrong chunk count in docs/corpus-history.md
   (had ~3.54M/shard; actual is ~1.56M/shard) and added the
   edge-count column (~22.6M/shard, 90.6M total). 6.24M chunks
   total, not 14.12M.

Tests: 29 new pass (22 routing + 7 meta). No existing tests
touched.
2026-05-26 13:13:44 -04:00

118 lines
4 KiB
Python

"""Pre-migration row-count snapshot.
Captures per-shard (docs, chunks, edges, audit_events) + global
document_root count for ~/.arborist/shards/00[0-3].db. Writes to
bench/results/pre-migration-snapshot.json so post-#000065 hydration
can verify row counts match.
Read-only; no shard mutation.
"""
from __future__ import annotations
import json
import os
import sqlite3
import sys
import time
from pathlib import Path
SHARDS_DIR = Path(os.path.expanduser("~/.arborist/shards"))
OUT_PATH = Path(__file__).resolve().parent / "results" / "pre-migration-snapshot.json"
SHARD_FILES = sorted(SHARDS_DIR.glob("00[0-9].db"))
def _count(conn: sqlite3.Connection, sql: str) -> int:
row = conn.execute(sql).fetchone()
return int(row[0]) if row else 0
def _snapshot_shard(path: Path) -> dict:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
info = {
"path": str(path),
"size_bytes": path.stat().st_size,
"docs": _count(conn, "SELECT COUNT(*) FROM documents"),
"chunks": _count(conn, "SELECT COUNT(*) FROM chunks"),
"edges": _count(conn, "SELECT COUNT(*) FROM edges"),
"audit_events": _count(conn, "SELECT COUNT(*) FROM audit_events"),
"providence_cache": _count(
conn, "SELECT COUNT(*) FROM providence_cache"
) if _table_exists(conn, "providence_cache") else 0,
"schema_version": _scalar(
conn, "SELECT value FROM meta WHERE key='schema_version'"
),
}
cur = conn.execute(
"SELECT source_type, COUNT(*) FROM documents GROUP BY source_type"
)
info["docs_by_source_type"] = {row[0] or "": int(row[1]) for row in cur}
info["audit_first_ts"] = _scalar(
conn, "SELECT MIN(ts) FROM audit_events"
)
info["audit_last_ts"] = _scalar(
conn, "SELECT MAX(ts) FROM audit_events"
)
cur = conn.execute(
"SELECT event_type, COUNT(*) FROM audit_events GROUP BY event_type"
)
info["audit_by_type"] = {row[0]: int(row[1]) for row in cur}
return info
finally:
conn.close()
def _scalar(conn: sqlite3.Connection, sql: str):
row = conn.execute(sql).fetchone()
return row[0] if row else None
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
).fetchone()
return row is not None
def _global_unique_docs(shards: list[Path]) -> int:
seen: set[bytes] = set()
for shard in shards:
conn = sqlite3.connect(f"file:{shard}?mode=ro", uri=True)
try:
cur = conn.execute("SELECT document_root FROM documents")
for (root,) in cur:
seen.add(root)
finally:
conn.close()
return len(seen)
def main() -> int:
if not SHARD_FILES:
print(f"no shards under {SHARDS_DIR}", file=sys.stderr)
return 1
t0 = time.time()
out = {
"captured_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"shards_dir": str(SHARDS_DIR),
"shards": [_snapshot_shard(p) for p in SHARD_FILES],
}
out["totals"] = {
"size_bytes": sum(s["size_bytes"] for s in out["shards"]),
"docs": sum(s["docs"] for s in out["shards"]),
"chunks": sum(s["chunks"] for s in out["shards"]),
"edges": sum(s["edges"] for s in out["shards"]),
"audit_events": sum(s["audit_events"] for s in out["shards"]),
}
out["totals"]["docs_globally_unique"] = _global_unique_docs(SHARD_FILES)
out["elapsed_seconds"] = round(time.time() - t0, 2)
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(json.dumps(out, indent=2, default=str))
print(json.dumps(out["totals"], indent=2, default=str))
print(f"\nwrote {OUT_PATH}")
print(f"elapsed {out['elapsed_seconds']}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())