Files
neuron/tools/retrieval-eval/run_eval.py
T
Neuron cf41d12d22
Neuron Soul CI / build (pull_request) Failing after 14m41s
Neuron Soul CI / deploy (pull_request) Failing after 14m45s
test(retrieval): a measurement harness for memory recall, and its first verdict
Nothing else on the memory roadmap should be built until a change can be shown
to help. Right now we judge by feel, and the benchmark literature is full of
systems that felt better and measured worse. This is the missing gate.

WHAT IT MEASURES, AND WHY IT BOOTS A REAL SOUL
The subject is Will's designed retrieval — spreading activation over the
weighted directed graph, four-factor multiplicative scoring — not a proxy for
it. A Python re-implementation would measure my reading of the design, so the
harness compiles the actual soul.el amalgam from a git ref and asks it over
HTTP on /api/neuron/recall, exactly as the MCP wrapper and the app do.

BUILT ON WHAT WAS ALREADY HERE, NOT AROUND IT
  docs/research/graphrag_eval/{collect,score}.py  — per-query relevant-id
    scoring and fixed-denominator precision@5 (kept verbatim: an empty result
    should be punished like a page of junk).
  docs/research-archive/p0-prototypes/eval_pinned_40q_20260715.py — the pinned
    ground truth + --check winnability gate, so every run judges alike.
  scripts/verify-soul-contract.sh — the isolation recipe, including the
    non-obvious SOUL_ISE_URL pin without which an "isolated" soul silently
    syncs the operator's live brain.
  gen-soul-amalgam.sh + .gitea/workflows/ci.yaml — the build recipe and flags.
New here: ids rather than regexes as ground truth, an associative category
derived from real edges, a superseded category scored on ranking, a
machine-checked zero-lexical-overlap guarantee on paraphrases, paired
significance testing, and measurement of the real compiled soul rather than an
offline replica of one leg of it.

THE GOLD SET IS AUDITABLE, NOT VIBES
38 queries over the real 78,768-node corpus, each carrying a `derivation`
string, each re-validated by `build_gold_set.py --check`. exact_rare is mined
(document frequency 1). phrase is mined (verbatim scan; >25 matches rejected as
too diffuse). paraphrase is hand-selected then PROVEN to share zero content
words with its target — a leak fails the build, so the category cannot decay
into lexical matching. associative is derived from real hub edges with
lexically-reachable siblings dropped. nonsense is verified absent. superseded
pairs are kept only when both sides survive as distinct nodes.

HONEST ABOUT NOISE
Minimum detectable swing on 38 queries is 6: if every changed query moves the
same way, p = 2*0.5^n first clears 0.05 at n=6. Run-to-run drift is measured,
not assumed — activation is a stateful read, and it shows: main is fully
deterministic across 3 runs, the candidate drifts by 1 query. compare.py
reports "no measurable difference" for anything inside max(6, drift+1).

FIRST VERDICT — feat/recall-through-activation
hit@5 34.3% -> 22.9%, phrase 85.7% -> 28.6%, latency p50 2.81x. Five discordant
pairs, all five against the candidate, none for it; McNemar exact p = 0.0625,
so by the stated rule this is one query short of significant and is reported as
such rather than as a win for main. The latency regression is deterministic and
not in any noise band.

The benefit the branch was written for is absent: associative recall is 0/6 on
BOTH builds. Probed directly, the traversal returns the lexical seed at rank 8
and none of its 12 hub siblings. Two measured corpus facts explain it — only
4,060 of 78,768 nodes (5.2%) carry any edge, and no node has an embedding, so
the fourth factor of the four-factor product has nothing to compute from. The
mechanism runs; the corpus lacks the structure it needs.

SAFETY
Throwaway port, throwaway HOME, disposable per-run copy of the corpus; live
ports refused by name. Every soul started is killed AND confirmed dead by pid
probe, with the confirmation written into the results file; run_comparison.sh
sweeps for strays and exits non-zero if any survive. Nothing under ~/.neuron,
/Applications/Neuron*, or ~/neuron-dev-stack is read, written, or restarted.

Rung: E2E-VERIFIED — 6 full runs (3 per config) against the real compiled
binaries on the real corpus; numbers above are measured, not projected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:40:51 -05:00

399 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
run_eval.py — measure one soul build's retrieval against the gold set.
WHAT IT MEASURES, AND WHY IT BOOTS A REAL SOUL
The point is Will's designed retrieval — spreading activation over the
weighted directed graph with four-factor multiplicative scoring — not a
Python re-implementation of it. A re-implementation would measure my
reading of the design; booting the compiled binary measures the design. So
this harness compiles the actual `soul.el` amalgam (build-soul.sh) and asks
it over HTTP, exactly as the MCP wrapper and the app do.
SAFETY — read this before changing anything here
* Boots on a THROWAWAY port with a THROWAWAY $HOME and a THROWAWAY COPY of
the corpus. Refuses to use 7770 / 8742 / 7779 / 17779 / 7771.
* ENGRAM_URL / SOUL_ENGRAM_URL are UNSET and SOUL_ISE_URL is pinned to a
dead port. This is not belt-and-braces: the periodic engram sync resolves
its source as env(SOUL_ISE_URL) -> state -> DEFAULT http://localhost:8742,
so leaving it unset makes an "isolated" run silently pull the operator's
LIVE brain. (Learned the hard way on 2026-08-03; see the same note in
scripts/verify-soul-contract.sh.)
* Every process this file starts is tracked and killed in a finally block,
then CONFIRMED dead by pid probe, and the confirmation is written into the
results file. A run that cannot confirm its child is dead exits non-zero.
* Activation is a STATEFUL read by design (patent claim 29: traversal
updates last-activation and increments activation counts). The corpus copy
is therefore per-run and disposable, and every run starts from a byte-
identical copy so two configurations see the same starting graph.
usage:
python3 run_eval.py --soul <binary> --corpus <snapshot.json> --label main \
[--port 7893] [--gold gold_set.json] [--limit 10] [--out results-main.json]
"""
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
FORBIDDEN_PORTS = {7770, 8742, 7779, 17779, 7771, 8080}
# ─────────────────────────────────────────────────────────────────────────────
# metrics
# ─────────────────────────────────────────────────────────────────────────────
def recall_at_k(returned, relevant, k):
if not relevant:
return None
return len(set(returned[:k]) & set(relevant)) / len(relevant)
def hit_at_k(returned, relevant, k):
if not relevant:
return None
return 1.0 if set(returned[:k]) & set(relevant) else 0.0
def precision_at_k(returned, relevant, k):
"""Fixed denominator k, as in docs/research/graphrag_eval/score.py.
Fixed denominator penalises an empty result and a page of junk equally,
which is what we want: a retriever that returns nothing is not 'precise'.
"""
if not relevant:
return None
return len(set(returned[:k]) & set(relevant)) / k
def mrr(returned, relevant, k):
if not relevant:
return None
rel = set(relevant)
for i, nid in enumerate(returned[:k], start=1):
if nid in rel:
return 1.0 / i
return 0.0
def mean(vals):
vals = [v for v in vals if v is not None]
return sum(vals) / len(vals) if vals else 0.0
def pct(vals):
return f"{100 * mean(vals):.1f}%"
# ─────────────────────────────────────────────────────────────────────────────
# soul lifecycle
# ─────────────────────────────────────────────────────────────────────────────
class Soul:
def __init__(self, binary, corpus, port, verbose=True):
if port in FORBIDDEN_PORTS:
raise SystemExit(f"REFUSING: port {port} is a live service port.")
self.binary = os.path.abspath(binary)
self.corpus = os.path.abspath(corpus)
self.port = port
self.verbose = verbose
self.home = None
self.proc = None
self.pid = None
self.log = None
self.confirmed_dead = None
@property
def base(self):
return f"http://127.0.0.1:{self.port}"
def start(self, boot_timeout=180):
self.home = tempfile.mkdtemp(prefix="retrieval-eval-home.")
snap = os.path.join(self.home, "corpus.json")
t0 = time.time()
shutil.copyfile(self.corpus, snap) # per-run disposable copy, never the source
self.log = os.path.join(self.home, "soul.log")
env = {k: v for k, v in os.environ.items()
if k not in ("ENGRAM_URL", "ENGRAM_API_KEY", "SOUL_ENGRAM_URL",
"ANTHROPIC_API_KEY", "NEURON_LLM_API_KEY", "SOUL_IDENTITY",
"SOUL_API_KEY")}
env.update({
"HOME": self.home,
"NEURON_PORT": str(self.port),
"SOUL_CGI_ID": f"ntn-retrieval-eval-{os.getpid()}",
"SOUL_ENGRAM_PATH": snap,
"NEURON_API_URL": "http://127.0.0.1:9", # dead port
"SOUL_ISE_URL": "http://127.0.0.1:9", # dead port — see SAFETY above
# Park the background loops for an hour so heartbeat/consolidation
# cannot mutate the graph between queries and make runs unrepeatable.
"SOUL_TICK_MS": "3600000",
"SOUL_HEARTBEAT_MS": "3600000",
"SOUL_REFRESH_MS": "3600000",
})
with open(self.log, "wb") as lf:
self.proc = subprocess.Popen([self.binary], env=env, stdout=lf, stderr=lf,
start_new_session=True)
self.pid = self.proc.pid
if self.verbose:
print(f" booted pid={self.pid} port={self.port} home={self.home}")
deadline = time.time() + boot_timeout
while time.time() < deadline:
if self.proc.poll() is not None:
raise RuntimeError(f"soul exited during boot: {self._log_tail()}")
rss = self._rss_kb()
if rss and rss > 6 * 1024 * 1024:
self.stop()
raise RuntimeError(f"soul RSS {rss}KB > 6GB — aborted")
try:
with urllib.request.urlopen(f"{self.base}/health", timeout=2) as r:
if r.status == 200:
if self.verbose:
print(f" healthy in {time.time() - t0:.1f}s, RSS={self._rss_kb()}KB")
return
except Exception:
pass
time.sleep(0.5)
self.stop()
raise RuntimeError(f"soul never healthy on {self.base}: {self._log_tail()}")
def _rss_kb(self):
try:
out = subprocess.run(["ps", "-o", "rss=", "-p", str(self.pid)],
capture_output=True, text=True, timeout=5).stdout.strip()
return int(out) if out else None
except Exception:
return None
def _log_tail(self, n=15):
try:
with open(self.log, encoding="utf-8", errors="replace") as fh:
return "\n".join(fh.read().splitlines()[-n:])
except Exception:
return "(no log)"
def recall(self, query, limit, timeout=60):
url = f"{self.base}/api/neuron/recall?query={urllib.parse.quote(query)}&limit={limit}"
t0 = time.perf_counter()
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
raw = r.read().decode("utf-8", "replace")
ms = (time.perf_counter() - t0) * 1000
except Exception as exc:
return [], (time.perf_counter() - t0) * 1000, f"{type(exc).__name__}: {exc}"
try:
arr = json.loads(raw)
except Exception:
return [], ms, f"unparseable response ({len(raw)}B)"
if not isinstance(arr, list):
return [], ms, f"non-array response: {str(arr)[:120]}"
ids = [x.get("id") for x in arr if isinstance(x, dict) and x.get("id")]
return ids, ms, None
def stop(self):
"""Kill and CONFIRM. A test process that outlives its test is a bug."""
if self.pid is None:
self.confirmed_dead = True
return True
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.kill(self.pid, sig)
except ProcessLookupError:
break
except Exception:
pass
for _ in range(20):
try:
os.kill(self.pid, 0)
except ProcessLookupError:
break
time.sleep(0.1)
else:
continue
break
try:
self.proc.wait(timeout=5)
except Exception:
pass
try:
os.kill(self.pid, 0)
self.confirmed_dead = False
except ProcessLookupError:
self.confirmed_dead = True
if self.verbose:
print(f" pid {self.pid}: {'CONFIRMED DEAD' if self.confirmed_dead else 'STILL ALIVE'}")
if self.home and os.path.isdir(self.home):
shutil.rmtree(self.home, ignore_errors=True)
return self.confirmed_dead
# ─────────────────────────────────────────────────────────────────────────────
# eval
# ─────────────────────────────────────────────────────────────────────────────
def evaluate(soul, gold, limit):
rows = []
for q in gold["queries"]:
ids, ms, err = soul.recall(q["query"], limit)
rel = q.get("relevant") or []
row = {
"id": q["id"],
"category": q["category"],
"query": q["query"],
"returned": ids,
"n_returned": len(ids),
"latency_ms": round(ms, 1),
"error": err,
}
if q.get("expect_empty"):
row["clean"] = (len(ids) == 0)
row["false_positives"] = len(ids)
else:
row["hit@5"] = hit_at_k(ids, rel, 5)
row["recall@5"] = recall_at_k(ids, rel, 5)
row["recall@10"] = recall_at_k(ids, rel, 10)
row["precision@5"] = precision_at_k(ids, rel, 5)
row["mrr@10"] = mrr(ids, rel, 10)
if q.get("must_outrank"):
correct, stale = q["must_outrank"]
ic = ids.index(correct) if correct in ids else None
istale = ids.index(stale) if stale in ids else None
# Correct must be present AND above the stale node. A run that
# returns neither is NOT a pass: the corrected fact is what the
# user needed.
row["outranks"] = (ic is not None) and (istale is None or ic < istale)
row["rank_correct"] = None if ic is None else ic + 1
row["rank_stale"] = None if istale is None else istale + 1
rows.append(row)
return rows
def aggregate(rows):
scored = [r for r in rows if "hit@5" in r]
nonsense = [r for r in rows if "clean" in r]
outrank = [r for r in rows if "outranks" in r]
lat = sorted(r["latency_ms"] for r in rows)
agg = {
"n_queries": len(rows),
"n_scored": len(scored),
"hit@5": mean([r["hit@5"] for r in scored]),
"recall@5": mean([r["recall@5"] for r in scored]),
"recall@10": mean([r["recall@10"] for r in scored]),
"precision@5": mean([r["precision@5"] for r in scored]),
"mrr@10": mean([r["mrr@10"] for r in scored]),
"nonsense_clean": f"{sum(1 for r in nonsense if r['clean'])}/{len(nonsense)}",
"superseded_outranks": f"{sum(1 for r in outrank if r['outranks'])}/{len(outrank)}",
"latency_ms_p50": lat[len(lat) // 2] if lat else 0,
"latency_ms_p95": lat[max(0, int(len(lat) * 0.95) - 1)] if lat else 0,
"latency_ms_max": lat[-1] if lat else 0,
"errors": sum(1 for r in rows if r["error"]),
"by_category": {},
}
cats = sorted({r["category"] for r in rows})
for c in cats:
cr = [r for r in rows if r["category"] == c]
if c == "nonsense":
agg["by_category"][c] = {
"n": len(cr),
"clean": sum(1 for r in cr if r["clean"]),
"avg_false_positives": mean([float(r["false_positives"]) for r in cr]),
}
else:
e = {
"n": len(cr),
"hit@5": mean([r.get("hit@5") for r in cr]),
"recall@5": mean([r.get("recall@5") for r in cr]),
"recall@10": mean([r.get("recall@10") for r in cr]),
"mrr@10": mean([r.get("mrr@10") for r in cr]),
}
if c == "superseded":
e["outranks"] = sum(1 for r in cr if r.get("outranks"))
agg["by_category"][c] = e
return agg
def print_table(label, agg):
print(f"\n=== {label} ===")
print(f" queries {agg['n_queries']} ({agg['n_scored']} scored + "
f"{agg['n_queries'] - agg['n_scored']} control) · errors {agg['errors']}")
print(f" {'hit@5':>12} {'recall@5':>10} {'recall@10':>10} {'prec@5':>9} {'MRR@10':>9}")
print(f" {pct([agg['hit@5']]):>12} {pct([agg['recall@5']]):>10} {pct([agg['recall@10']]):>10} "
f"{pct([agg['precision@5']]):>9} {agg['mrr@10']:>9.3f}")
print(f" nonsense clean {agg['nonsense_clean']} · superseded outranks {agg['superseded_outranks']}")
print(f" latency ms p50 {agg['latency_ms_p50']:.0f} · p95 {agg['latency_ms_p95']:.0f} "
f"· max {agg['latency_ms_max']:.0f}")
print(f"\n {'category':14} {'n':>3} {'hit@5':>8} {'recall@5':>9} {'recall@10':>10} {'MRR@10':>8}")
for c, e in agg["by_category"].items():
if c == "nonsense":
print(f" {c:14} {e['n']:>3} {'clean ' + str(e['clean']) + '/' + str(e['n']):>8}"
f"{'':>9} {'':>10} {'avg FP ' + format(e['avg_false_positives'], '.1f'):>8}")
else:
extra = f" outranks {e['outranks']}/{e['n']}" if "outranks" in e else ""
print(f" {c:14} {e['n']:>3} {pct([e['hit@5']]):>8} {pct([e['recall@5']]):>9} "
f"{pct([e['recall@10']]):>10} {e['mrr@10']:>8.3f}{extra}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--soul", required=True)
ap.add_argument("--corpus", required=True)
ap.add_argument("--label", required=True)
ap.add_argument("--gold", default=os.path.join(HERE, "gold_set.json"))
ap.add_argument("--port", type=int, default=7893)
ap.add_argument("--limit", type=int, default=10)
ap.add_argument("--out", default=None)
args = ap.parse_args()
with open(args.gold, encoding="utf-8") as fh:
gold = json.load(fh)
print(f"[{args.label}] soul={os.path.basename(args.soul)} "
f"corpus={os.path.basename(args.corpus)} gold={len(gold['queries'])}q limit={args.limit}")
soul = Soul(args.soul, args.corpus, args.port)
rows = []
started = time.time()
try:
soul.start()
rows = evaluate(soul, gold, args.limit)
finally:
dead = soul.stop()
agg = aggregate(rows)
print_table(args.label, agg)
out = args.out or os.path.join(HERE, f"results-{args.label}.json")
doc = {
"label": args.label,
"soul_binary": os.path.abspath(args.soul),
"soul_md5": subprocess.run(["md5", "-q", args.soul], capture_output=True,
text=True).stdout.strip(),
"corpus": os.path.abspath(args.corpus),
"corpus_nodes": gold.get("corpus_nodes"),
"corpus_edges": gold.get("corpus_edges"),
"gold_set": os.path.abspath(args.gold),
"limit": args.limit,
"port": args.port,
"wall_clock_s": round(time.time() - started, 1),
"child_pid": soul.pid,
"child_confirmed_dead": soul.confirmed_dead,
"aggregate": agg,
"rows": rows,
}
with open(out, "w", encoding="utf-8") as fh:
json.dump(doc, fh, indent=1, ensure_ascii=False)
print(f"\nwrote {out}")
if not dead:
print("FATAL: child process could not be confirmed dead", file=sys.stderr)
sys.exit(4)
if __name__ == "__main__":
main()