Files
rune/scripts/check_graph_memory_parity.py
Nick 16b5569411 feat(graph_memory): close PARTIAL gaps to FULL Rust — dichotomy wave
Kernel port of the 6 audit-gap fns (now_iso, connect, resolve_entity,
add_edge, query, stats) + handle_hook into src/rust/graph_memory (was a
W6 stub): the write-time gating (gm_strength_to_conf — exact float
semantics incl. the 3.5->0.8200000000000001 artifact, unlike the Go
twin's 2-decimal rounding), pred vocabulary gate (gm_normalize_pred),
node-type fallback (gm_node_type), duplicate-vs-supersede decision
(gm_supersede), 400-char evidence cap (gm_trim_evidence), query-fact
projection (gm_query_fact), stats top-8 projection (gm_stats), hook
contract (gm_handle_hook). SQLite IO stays Python (every oracle fn
takes a live db handle); the facade (graph_memory.py) delegates the
decisions through gm_loader with RUNE_GRAPH_MEMORY_CORE=python pin
honor; the probe is _core (machinery-excluded).

Truth: frozen_truth.json 38 cases (scripts/gen_graph_memory_truth.py
under scripts/gm_freeze_seams — wiped-per-process hermetic store,
RUNE_MEMORY_DIR pin, envelope clock) — byte-identical across
generation and two --verify runs. Rust battery 2/2 with the truth
embedded via include_str! (scripted store mirrors the SQL semantics
row for row: OR-IGNORE aliases, sqlite_sequence in the connect shape).

Live parity 1330/1330 (15 frozen FFI rows + 1310 fuzz checks over 60
seeded op-sequences dual-mode python-pin vs live core + 5 live
supersession-chain checks). clippy --all-targets -D warnings 0.
Python suite both pins: test_graph_health+test_graph_projection
7 passed + 1 pre-existing worktree-only failure (store cache
current.json missing — verified identical on stashed base);
test_p1_03 baseline 23 pre-existing failures identical on stashed
base (zero regressions). ruff clean.

Audit: graph_memory 2/9 PARTIAL -> 9/9 FULL (RUST); headline
FULL 43% (83/192), fn 64% (1447/2278).

Evidence: verification/gm_*.{log,exit} (all exit 0 except the two
pre-existing pytest baselines).
2026-10-14 04:03:51 +01:00

207 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Live parity: graph_memory Rust kernels vs Python oracle (dichotomy wave,
gap-close pass). Layers:
1. FROZEN — the gap truth corpus (src/rust/graph_memory/frozen_truth.json,
generated by scripts/gen_graph_memory_truth.py under the
scripts/gm_freeze_seams rig against the LIVE oracle): the kernel-owned
rows (now_iso, strength_to_conf) replayed through the ctypes FFI,
case by case, byte-exact.
2. FUZZ — 60 seeded random op-sequences (seed 2479): the full facade
(add_edge/resolve_entity/query/stats with their kernel decisions)
executed with RUNE_GRAPH_MEMORY_CORE=python (oracle floor) and with
the Rust core live, under a pinned clock; per-op results, final
stats, and a query sweep must be identical.
3. LIVE — dual-mode determinism: the same facade calls on the same
hermetic store both pins; supersession chains preserved.
Exit 0 only at 100% parity on the gap-closing functions.
"""
import importlib
import json
import os
import random
import shutil
import sys
from datetime import datetime as _dt
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
os.environ["RUNE_GRAPH_MEMORY_CORE"] = "python" # oracle floor to start
SCRATCH = "/tmp/gm_parity"
os.environ["RUNE_MEMORY_DIR"] = SCRATCH
sys.path.insert(0, str(REPO / "src"))
import rune.gm_loader as gl # noqa: E402
import rune.graph_memory as gm # noqa: E402
if not gl.available():
sys.exit("FATAL: Rust core not built — cargo build --release in src/rust/graph_memory")
FROZEN_NOW = "2026-10-13T12:00:00"
fails: list[str] = []
counts = {"frozen": 0, "fuzz": 0, "live": 0}
def fail(layer, name, detail):
fails.append(f"[{layer}] {name}: {detail}")
print(f"FAIL[{layer}] {name}: {detail}"[:400])
def j(x):
return json.dumps(x, sort_keys=True, ensure_ascii=True)
def check(layer, name, want, got):
counts[layer] += 1
if j(want) != j(got):
fail(layer, name, f"want={j(want)[:300]} got={j(got)[:300]}")
def pin(mode):
if mode == "python":
os.environ["RUNE_GRAPH_MEMORY_CORE"] = "python"
else:
os.environ.pop("RUNE_GRAPH_MEMORY_CORE", None)
def frozen_clock():
base = _dt.fromisoformat(FROZEN_NOW)
class FrozenDatetime(_dt):
@classmethod
def now(cls, tz=None):
if tz is not None:
return base.replace(tzinfo=tz)
return base
gm.datetime = FrozenDatetime
def fresh_db():
shutil.rmtree(os.path.join(SCRATCH, "graph"), ignore_errors=True)
return gm.connect()
TRUTH = json.loads(
(REPO / "src" / "rust" / "graph_memory" / "frozen_truth.json").read_text())
CASES = TRUTH["cases"] if isinstance(TRUTH, dict) else TRUTH
# ── layer 1: FROZEN (kernel-owned rows through the FFI) ────────────────────
for row in CASES:
fn = row["fn"]
if fn == "now_iso":
check("frozen", "ffi_now_iso", row["expected"],
gl.now_iso("2026-10-13T12:00:00.123456+00:00"))
elif fn == "strength_to_conf":
(sv,) = row["args"]
res = gl.strength_to_conf(sv)
got = res["conf"] if res["ok"] else None
check("frozen", f"ffi_strength_to_conf:{sv!r}", row["expected"], got)
# ── layers 2+3: FUZZ + LIVE (dual-mode facade on random op-sequences) ───────
NAMES = ["Ada Lovelace", "Babbage", "Analytical Engine", "Difference Engine",
" punched card ", "Nightflyer", "", "babbage"]
PREDS = ["builds", "knows", "Works On", "frobnicate", "uses", "LIKES", "part_of"]
TYPES = ["person", "project", "alien", "tool", ""]
EVID = ["first notes", "revised notes", " spaced ", "x" * 500, ""]
rng = random.Random(2479)
def run_seq(seq, mode):
pin(mode)
importlib.reload(gm)
frozen_clock()
db = fresh_db()
out = []
for op in seq:
kind = op[0]
if kind == "add":
_, subj, pred, obj, kw = op
out.append(gm.add_edge(db, subj, pred, obj, **kw))
elif kind == "resolve":
_, name, nt = op
out.append(gm.resolve_entity(db, name, nt))
elif kind == "query":
_, term, hops = op
out.append(gm.query(db, term, hops=hops))
elif kind == "stats":
out.append(gm.stats(db))
sweep = [gm.query(db, t, hops=h) for t in ("Ada Lovelace", "babbage",
"Engine", "zzz") for h in (0, 1, 2)]
out.append(gm.stats(db))
db.close()
return out, sweep
for trial in range(60):
seq = []
for _ in range(rng.randrange(4, 14)):
r = rng.random()
if r < 0.55:
kw = {}
if rng.random() < 0.5:
kw["confidence"] = rng.choice([None, 0.5, 0.9, 0.99, 0.3])
if rng.random() < 0.6 or "confidence" not in kw:
kw["strength"] = rng.choice([None, 2, 3, 4, 5, "4", 3.5, "x"])
if rng.random() < 0.7:
kw["evidence"] = rng.choice(EVID)
if rng.random() < 0.4:
kw["source"] = rng.choice(["truth", "curator", ""])
if rng.random() < 0.3:
kw["subj_type"] = rng.choice(TYPES)
if rng.random() < 0.3:
kw["obj_type"] = rng.choice(TYPES)
seq.append(("add", rng.choice(NAMES), rng.choice(PREDS),
rng.choice(NAMES), kw))
elif r < 0.7:
seq.append(("resolve", rng.choice(NAMES), rng.choice(TYPES)))
elif r < 0.9:
seq.append(("query", rng.choice(NAMES + ["Engine", "ada lovelace"]),
rng.choice([0, 1, 2, -1])))
else:
seq.append(("stats",))
want_out, want_sweep = run_seq(seq, "python")
got_out, got_sweep = run_seq(seq, "rust")
for i, (w, g) in enumerate(zip(want_out, got_out)):
check("fuzz", f"trial{trial}:op{i}", w, g)
for i, (w, g) in enumerate(zip(want_sweep, got_sweep)):
check("fuzz", f"trial{trial}:sweep{i}", w, g)
if fails:
break
# ── layer 3: LIVE — pinned-clock determinism + supersession chains ─────────
def chain_probe(mode):
pin(mode)
importlib.reload(gm)
frozen_clock()
db = fresh_db()
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.6, evidence="v1", source="t")
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.7, evidence="v2", source="t")
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.7, evidence="v2", source="t") # dup
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.65, evidence="v2", source="t") # weaker dup
gm.add_edge(db, "Ada", "uses", "Engine", confidence=0.65, evidence="v3", source="t") # weaker, NEW evidence -> supersede
rows = [dict(r) for r in db.execute(
"SELECT id, subj_id, pred, obj_id, confidence, evidence, status, superseded_by "
"FROM edges ORDER BY id")]
q = gm.query(db, "engine", hops=1)
s = gm.stats(db)
db.close()
return rows, q, s
want_chain = chain_probe("python")
got_chain = chain_probe("rust")
for i in range(len(want_chain[0])):
check("live", f"supersession_chain:edge{i}", want_chain[0][i], got_chain[0][i])
check("live", "supersession_chain:query", want_chain[1], got_chain[1])
check("live", "supersession_chain:stats", want_chain[2], got_chain[2])
print(f"parity: {sum(counts.values())} checks "
f"(frozen {counts['frozen']}, fuzz {counts['fuzz']}, live {counts['live']}) "
f"— {len(fails)} failures")
sys.exit(1 if fails else 0)