Files
dotfiles/scripts/l1-replay-sampler.py
T
David F GliddenandClaude Opus 5 9c2a7c985c session 2026-08-04: PENDING-92/93/94 + the replay-never-resumed finding + l1-replay-sampler
Filed PENDING-92 [HARDENING] idle ladder (cool/deep unreachable, spec §9A.1
divergence), PENDING-93 [PROPOSAL] event_seqs normalisation, PENDING-94
[ESCALATE] the resume floor — minCursor pinned at 0 by two non-participating
modules, so 13/13 restarts rebuilt from seq 0 and the catch-up branch has
never executed. Recall never worked either (retrieval_count = 0 across the
whole April-June graph); same fact from the other end.

Adds scripts/l1-replay-sampler.py (external read-only sampler, four positive
controls, refuses to run blind). Note to Seb pushed separately as
CapableMind-AI@ad285df.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuMjg3ipEVa3n8CoSzoyvc
2026-08-04 15:30:56 +02:00

359 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
l1-replay-sampler.py — external, read-only progress sampler for a BMF replay.
WHY THIS EXISTS
---------------
The replay logs progress once per 100 events. In the slow regime that is one
data point every ~51 minutes — telemetry coarser than the failure mode it is
meant to describe. On 2026-08-04 three separate mechanism hypotheses (machine
sleep, entity-pipeline timeout, work-queue drain gating) each survived hours of
diagnosis because nothing at that resolution could contradict them. All three
were wrong. This samples the substrate directly, every 30s, and writes a curve.
It answers, continuously and without being asked:
- is the replay advancing, and at what instantaneous rate
- edges-per-node and chains-per-node as the graph densifies
- coherence_evaluated — the open question of whether that governor ever runs
- which idle state the process is in when each sample is taken
SAFETY
------
Read-only by discipline: only SELECTs are issued, nothing is restarted, no
config is touched. The DB is opened read-write rather than with mode=ro
because a mode=ro open of this instance FAILS — SQLite must be able to create
the -shm file to read a WAL database, and mode=ro cannot. (Earned 2026-08-03;
every read that day ran against a backup for this reason.) WAL readers do not
block the writer, so a 30s poll is negligible against a process writing
continuously.
INSTRUMENT DISCIPLINE
---------------------
Every source carries a same-run positive control. An absence is not evidence
until the instrument is shown capable of detecting presence — so if any source
cannot be read, or reads as structurally implausible, the sampler prints
INSTRUMENT NOT VERIFIED and refuses to start rather than emitting a clean-
looking stream of zeros. A sampler that silently reports nothing is exactly the
failure class it was built to investigate.
USAGE
-----
l1-replay-sampler.py --selftest run the controls, print, exit
l1-replay-sampler.py sample every 30s until stopped
l1-replay-sampler.py --interval 60 sample every 60s
l1-replay-sampler.py --once emit exactly one sample and exit
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
INSTANCE = os.environ.get("BM_INSTANCE_NAME", "mindfabric-00")
DB_PATH = Path.home() / ".capablemind" / "data" / INSTANCE / "sqlite" / "temporal.sqlite3"
LOG_PATH = Path.home() / ".capablemind" / "logs" / "bmf.stderr.log"
OUT_PATH = Path.home() / ".capablemind" / "diagnostics" / f"replay-samples-{INSTANCE}.jsonl"
TAIL_BYTES = 262_144 # 256 KB of log tail — enough to hold a progress line even when sparse
PROGRESS_RE = re.compile(r"Phase (\d) replay: (\d+)/(\d+) events")
IDLE_RE = re.compile(r"Idle state transition: (\w+) → (\w+) \(([^)]*)\)")
PAUSE_RE = re.compile(r"Phase \d replay paused for resource pressure")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------
# sources
# --------------------------------------------------------------------------
def read_db() -> dict:
"""Counts from the temporal store. SELECTs only."""
conn = sqlite3.connect(f"file:{DB_PATH}", uri=True, timeout=10.0)
try:
cur = conn.cursor()
out = {}
for key, sql in (
("nodes", "SELECT count(*) FROM temporal_node"),
("edges", "SELECT count(*) FROM caused"),
("chains", "SELECT count(*) FROM causal_chain"),
("coherence_evaluated", "SELECT count(*) FROM causal_chain WHERE coherence_evaluated = 1"),
):
out[key] = cur.execute(sql).fetchone()[0]
return out
finally:
conn.close()
def read_log_tail() -> dict:
"""Last replay position, last idle transition, and pause count in the tail window."""
size = LOG_PATH.stat().st_size
with LOG_PATH.open("rb") as fh:
fh.seek(max(0, size - TAIL_BYTES))
tail = fh.read().decode("utf-8", errors="replace")
out: dict = {
"phase": None,
"events_done": None,
"events_total": None,
"idle_state": None,
"idle_reason": None,
"pauses_in_tail": len(PAUSE_RE.findall(tail)),
"log_bytes": size,
}
progress = PROGRESS_RE.findall(tail)
if progress:
phase, done, total = progress[-1]
out["phase"] = int(phase)
out["events_done"] = int(done)
out["events_total"] = int(total)
idle = IDLE_RE.findall(tail)
if idle:
_frm, to, reason = idle[-1]
out["idle_state"] = to
out["idle_reason"] = reason
return out
def read_process() -> dict:
"""CPU seconds consumed — distinguishes 'working slowly' from 'not working'."""
try:
pids = subprocess.run(
["pgrep", "-f", "BetterMemories.io/dist/index.js"],
capture_output=True, text=True, timeout=10,
).stdout.split()
if not pids:
return {"pid": None, "cpu_seconds": None, "alive": False}
pid = int(pids[0])
raw = subprocess.run(
["ps", "-o", "time=", "-p", str(pid)],
capture_output=True, text=True, timeout=10,
).stdout.strip()
# ps time format: [[dd-]hh:]mm:ss
days, _, rest = raw.partition("-")
if not rest:
days, rest = "0", days
parts = [float(p) for p in rest.split(":")]
while len(parts) < 3:
parts.insert(0, 0.0)
secs = int(days) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
return {"pid": pid, "cpu_seconds": round(secs, 2), "alive": True}
except Exception as exc: # noqa: BLE001 — degradation must be visible, not silent
return {"pid": None, "cpu_seconds": None, "alive": False, "error": str(exc)}
# --------------------------------------------------------------------------
# positive controls
# --------------------------------------------------------------------------
def selftest() -> tuple[bool, list[str]]:
"""
Each control must demonstrate the instrument can SEE something, not merely
that it did not crash. Returns (ok, lines).
"""
lines: list[str] = []
ok = True
# Control 1 — the DB exists, opens, and the four counters return integers.
try:
db = read_db()
if not all(isinstance(v, int) for v in db.values()):
ok = False
lines.append("FAIL db: a counter returned a non-integer")
elif db["nodes"] == 0 and db["edges"] == 0 and db["chains"] == 0:
ok = False
lines.append("FAIL db: all three counters are zero — cannot distinguish "
"'empty store' from 'reading the wrong database'")
else:
lines.append(f"pass db: nodes={db['nodes']} edges={db['edges']} "
f"chains={db['chains']} coherence_evaluated={db['coherence_evaluated']}")
except Exception as exc: # noqa: BLE001
ok = False
lines.append(f"FAIL db: {exc}")
# Control 2 — the log is readable AND the progress regex actually matches.
# A regex that matches nothing would report 'no progress' forever.
try:
log = read_log_tail()
if log["events_done"] is None:
ok = False
lines.append("FAIL log: no 'Phase N replay: X/Y events' line in the last "
f"{TAIL_BYTES // 1024} KB — the progress parser is unverified")
else:
lines.append(f"pass log: phase={log['phase']} "
f"events={log['events_done']}/{log['events_total']}")
if log["idle_state"] is None:
lines.append("warn log: no idle transition in the tail window "
"(idle_state will read null; not fatal)")
else:
lines.append(f"pass log: idle_state={log['idle_state']} ({log['idle_reason']})")
except Exception as exc: # noqa: BLE001
ok = False
lines.append(f"FAIL log: {exc}")
# Control 3 — the process is locatable and reports non-zero CPU.
proc = read_process()
if not proc["alive"]:
ok = False
lines.append(f"FAIL proc: BMF process not found ({proc.get('error', 'no match')})")
elif not proc["cpu_seconds"]:
ok = False
lines.append("FAIL proc: cpu_seconds parsed as zero — the ps parser is unverified")
else:
lines.append(f"pass proc: pid={proc['pid']} cpu_seconds={proc['cpu_seconds']}")
# Control 4 — the output path is writable.
try:
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with OUT_PATH.open("a"):
pass
lines.append(f"pass out: {OUT_PATH} writable")
except Exception as exc: # noqa: BLE001
ok = False
lines.append(f"FAIL out: {exc}")
return ok, lines
# --------------------------------------------------------------------------
# sampling
# --------------------------------------------------------------------------
def sample(prev: dict | None, events_changed_at: float | None = None) -> dict:
rec: dict = {"ts": now_iso()}
try:
rec.update(read_db())
except Exception as exc: # noqa: BLE001
rec["db_error"] = str(exc)
try:
rec.update(read_log_tail())
except Exception as exc: # noqa: BLE001
rec["log_error"] = str(exc)
rec.update(read_process())
# Derived ratios — the densification curve, which is the thing that decides
# whether the rebuild bought a fix or only a constant factor.
if rec.get("nodes"):
rec["edges_per_node"] = round(rec.get("edges", 0) / rec["nodes"], 2)
rec["chains_per_node"] = round(rec.get("chains", 0) / rec["nodes"], 2)
# Instantaneous rates against the previous sample.
if prev and prev.get("ts"):
dt = (datetime.fromisoformat(rec["ts"]) - datetime.fromisoformat(prev["ts"])).total_seconds()
if dt > 0:
if rec.get("nodes") is not None and prev.get("nodes") is not None:
rec["nodes_per_min"] = round((rec["nodes"] - prev["nodes"]) * 60 / dt, 2)
if rec.get("events_done") is not None and prev.get("events_done") is not None:
rec["events_per_min"] = round((rec["events_done"] - prev["events_done"]) * 60 / dt, 2)
if rec.get("cpu_seconds") is not None and prev.get("cpu_seconds") is not None:
rec["cpu_percent"] = round((rec["cpu_seconds"] - prev["cpu_seconds"]) * 100 / dt, 1)
# events_per_min is derived from a counter the replay only writes every 100
# events. At 12 events/min that is one update per ~8 minutes, so the rate
# reads 0.0 for most samples and then spikes — the same coarse-telemetry
# flaw this sampler exists to escape, reproduced one level in. Publish how
# long the counter has been FROZEN so a flat reading is legible as stale
# rather than as zero throughput, and treat nodes_per_min as the fine signal.
if events_changed_at is not None:
rec["events_stale_seconds"] = round(time.time() - events_changed_at, 1)
return rec
def seed_staleness() -> tuple[int | None, float | None]:
"""
Recover the events counter and when it last CHANGED from the existing
stream, so a restart of the sampler does not reset the staleness clock.
Without this, restarting the instrument makes a counter frozen for 33
minutes report stale=0.0s — the instrument's own restart erasing the very
signal it exists to publish. Observed 2026-08-04 on the first handoff
between two sampler processes.
"""
if not OUT_PATH.exists():
return None, None
try:
size = OUT_PATH.stat().st_size
with OUT_PATH.open("rb") as fh:
fh.seek(max(0, size - TAIL_BYTES))
lines = fh.read().decode("utf-8", errors="replace").splitlines()
rows = []
for line in lines:
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue # a partial first line from the seek, or a torn write
rows = [r for r in rows if r.get("events_done") is not None]
if not rows:
return None, None
current = rows[-1]["events_done"]
# Walk back to the first record still holding the current value.
changed_ts = rows[-1]["ts"]
for row in reversed(rows):
if row["events_done"] != current:
break
changed_ts = row["ts"]
return current, datetime.fromisoformat(changed_ts).timestamp()
except Exception: # noqa: BLE001 — seeding is best-effort; never block sampling
return None, None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--interval", type=float, default=30.0, help="seconds between samples (default 30)")
ap.add_argument("--once", action="store_true", help="emit one sample and exit")
ap.add_argument("--selftest", action="store_true", help="run positive controls and exit")
args = ap.parse_args()
ok, lines = selftest()
for line in lines:
print(line, file=sys.stderr)
if not ok:
print("\nINSTRUMENT NOT VERIFIED — refusing to sample. A stream of zeros from a "
"blind instrument is worse than no stream at all.", file=sys.stderr)
return 2
if args.selftest:
print("\ninstrument verified", file=sys.stderr)
return 0
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
print(f"sampling every {args.interval:g}s → {OUT_PATH}", file=sys.stderr)
prev: dict | None = None
last_events, events_changed_at = seed_staleness()
if last_events is not None:
held = time.time() - (events_changed_at or time.time())
print(f"resumed staleness: events={last_events} unchanged for {held:.0f}s", file=sys.stderr)
while True:
rec = sample(prev, events_changed_at)
if rec.get("events_done") is not None and rec["events_done"] != last_events:
last_events = rec["events_done"]
events_changed_at = time.time()
rec["events_stale_seconds"] = 0.0
with OUT_PATH.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
prev = rec
if args.once:
print(json.dumps(rec, indent=2), file=sys.stderr)
return 0
time.sleep(args.interval)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)