dc5dc94d79
Multi-channel retrieval sidecar over Universal Memory Protocol: - 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph) - ACT-R re-ranking (Anderson 1983) with access tracking - Co-occurrence graph edges (Phase 6) for dense traversal - Memory lifecycle decay (Phase 4) with per-kind confidence - MCP shim routes recall through sidecar, falls back to canonical UMP Architecture: - src/server.js HTTP sidecar on port 4380 - src/graph.js 2592-node / 111-edge graph from UMP (or +cooccur: 13k+) - src/actr.js A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf - src/access_log.js per-URN counter + last_accessed_at - src/ump-recall-mcp.js MCP shim (recall via sidecar, others passthrough) Eval results (851-record UMP corpus): - 2ch RRF over baseline: +50pp recall@10 - 3ch RRF (+graph): +60pp, 12 unique wins - ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention Tests: 76/76 passing across graph (27), actr (27), access_log (28), decay (20), mcp-shim (sidecar + fallback). Run with: npm test Inspired by AIAppsAPI/adaptive-recall but built from scratch against existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333, Ollama at :11434). No paid SaaS, MIT-licensed.
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Phase 2 eval — compare 3-channel (ump+vector+graph) vs 2-channel (ump+vector).
|
|
|
|
Method:
|
|
1. Run each eval query with GRAPH_ENABLED=true → 3-channel sidecar
|
|
2. Run same query with GRAPH_ENABLED=false → 2-channel sidecar
|
|
(achieved by setting weights={graph:0} which zero-weights graph contributions;
|
|
OR by adding a query flag. Simpler: use ?channels=ump,vector if we add that.)
|
|
|
|
For ground truth: use the verified-queries corpus (queries_verified.py).
|
|
For broader coverage: also run unverified queries (queries.py) and report
|
|
graph's marginal contribution.
|
|
|
|
Reports:
|
|
- 3ch hit_rate@K (K=3,5,10)
|
|
- 2ch hit_rate@K
|
|
- delta_lift = 3ch - 2ch per K
|
|
- graph-only wins: queries where 3ch finds GT at rank R, 2ch doesn't
|
|
- 2ch-only wins: queries where 2ch finds GT at rank R, 3ch doesn't
|
|
- Average rank shift for queries both find
|
|
|
|
Acceptance: 3ch hit_rate@10 >= 2ch hit_rate@10 (graph should not regress).
|
|
Stretch: 3ch hit_rate@10 > 2ch hit_rate@10 by >=5pp (graph adds value).
|
|
"""
|
|
import json
|
|
import os
|
|
import statistics
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
UMP_URL = os.getenv("UMP_URL", "http://127.0.0.1:4317")
|
|
SIDECAR_URL = os.getenv("SIDECAR_URL", "http://127.0.0.1:4380")
|
|
|
|
|
|
def http_json(url, body=None, method="GET", timeout=60):
|
|
data = json.dumps(body).encode() if body else None
|
|
req = urllib.request.Request(
|
|
url, data=data,
|
|
headers={"content-type": "application/json"},
|
|
method=method,
|
|
)
|
|
t0 = time.time()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
raw = resp.read()
|
|
return resp.status, json.loads(raw) if raw else None, (time.time() - t0) * 1000
|
|
except Exception as e:
|
|
return 0, {"error": repr(e)}, (time.time() - t0) * 1000
|
|
|
|
|
|
def recall_sidecar(query, limit=10, with_graph=True):
|
|
"""Run sidecar /recall, optionally zero-weighting the graph channel."""
|
|
weights = None if with_graph else {"ump": 1.0, "vector": 1.0, "graph": 0.0}
|
|
body = {"query": query, "limit": limit}
|
|
if weights:
|
|
body["weights"] = weights
|
|
return http_json(f"{SIDECAR_URL}/recall", body, method="POST")
|
|
|
|
|
|
def recall_baseline(query, limit=10):
|
|
"""Raw UMP recall (FTS5 + recency + salience)."""
|
|
return http_json(f"{UMP_URL}/ump/recall", {"query": query, "limit": limit}, method="POST")
|
|
|
|
|
|
def rank_of(hits, urn):
|
|
for idx, h in enumerate(hits):
|
|
if h.get("urn") == urn or h.get("record", {}).get("id") == urn:
|
|
return idx + 1
|
|
return None
|
|
|
|
|
|
def main():
|
|
# Try verified queries first, fall back to full queries.
|
|
qpath = None
|
|
for candidate in [
|
|
"/root/ump-recall/eval/queries.py",
|
|
"/root/ump-recall/eval/queries_verified.py",
|
|
]:
|
|
if os.path.exists(candidate):
|
|
qpath = candidate
|
|
break
|
|
if not qpath:
|
|
print("No queries file found.")
|
|
sys.exit(1)
|
|
|
|
sys.path.insert(0, os.path.dirname(qpath))
|
|
queries_module = os.path.basename(qpath).replace(".py", "")
|
|
# Tolerate either QUERIES or QUERIES_VERIFIED constant name.
|
|
mod = __import__(queries_module)
|
|
queries = getattr(mod, "QUERIES", None) or getattr(mod, "QUERIES_VERIFIED", None)
|
|
if not queries:
|
|
print(f"No QUERIES / QUERIES_VERIFIED found in {qpath}")
|
|
sys.exit(1)
|
|
print(f"Loaded {len(queries)} queries from {qpath}\n")
|
|
|
|
results = []
|
|
for q in queries:
|
|
query = q["query"]
|
|
gt_urn = q.get("expected_id")
|
|
if not gt_urn:
|
|
continue
|
|
|
|
# 3-channel
|
|
s3, b3, t3 = recall_sidecar(query, 10, with_graph=True)
|
|
r3 = rank_of(b3.get("hits", []), gt_urn) if s3 == 200 else None
|
|
|
|
# 2-channel
|
|
s2, b2, t2 = recall_sidecar(query, 10, with_graph=False)
|
|
r2 = rank_of(b2.get("hits", []), gt_urn) if s2 == 200 else None
|
|
|
|
# baseline
|
|
sB, bB, tB = recall_baseline(query, 10)
|
|
rB = None
|
|
if sB == 200:
|
|
for idx, r in enumerate(bB.get("results", [])):
|
|
if r.get("record", {}).get("id") == gt_urn:
|
|
rB = idx + 1
|
|
break
|
|
|
|
results.append({
|
|
"query": query[:60],
|
|
"gt_urn": gt_urn[-25:],
|
|
"baseline_rank": rB,
|
|
"two_ch_rank": r2,
|
|
"three_ch_rank": r3,
|
|
"t_baseline_ms": round(tB, 1),
|
|
"t_two_ch_ms": round(t2, 1),
|
|
"t_three_ch_ms": round(t3, 1),
|
|
})
|
|
|
|
n = len(results)
|
|
if not n:
|
|
print("No queries with expected_id.")
|
|
return
|
|
|
|
def hit(rank, k):
|
|
return 1 if (rank is not None and rank <= k) else 0
|
|
|
|
for k in [3, 5, 10]:
|
|
b = sum(hit(r["baseline_rank"], k) for r in results) / n
|
|
c2 = sum(hit(r["two_ch_rank"], k) for r in results) / n
|
|
c3 = sum(hit(r["three_ch_rank"], k) for r in results) / n
|
|
print(f"Recall@{k:>2}: baseline={b:.2%} 2ch={c2:.2%} 3ch={c3:.2%} Δ(3-2)={c3-c2:+.2%}")
|
|
|
|
# Graph's marginal contribution
|
|
g_only = sum(1 for r in results if r["two_ch_rank"] is None and r["three_ch_rank"] is not None)
|
|
both_found = sum(1 for r in results if r["two_ch_rank"] is not None and r["three_ch_rank"] is not None)
|
|
rank_improved = sum(1 for r in results if r["two_ch_rank"] is not None and r["three_ch_rank"] is not None and r["three_ch_rank"] < r["two_ch_rank"])
|
|
rank_regressed = sum(1 for r in results if r["two_ch_rank"] is not None and r["three_ch_rank"] is not None and r["three_ch_rank"] > r["two_ch_rank"])
|
|
two_only = sum(1 for r in results if r["two_ch_rank"] is not None and r["three_ch_rank"] is None)
|
|
|
|
print(f"\nGraph channel contribution:")
|
|
print(f" Graph-only wins (3ch finds, 2ch misses): {g_only}")
|
|
print(f" 2ch-only wins (3ch misses, 2ch finds): {two_only}")
|
|
print(f" Both found: {both_found}")
|
|
print(f" Rank improved by graph: {rank_improved}")
|
|
print(f" Rank regressed by graph: {rank_regressed}")
|
|
|
|
# Latency
|
|
avg_lat_2 = statistics.mean(r["t_two_ch_ms"] for r in results)
|
|
avg_lat_3 = statistics.mean(r["t_three_ch_ms"] for r in results)
|
|
avg_lat_B = statistics.mean(r["t_baseline_ms"] for r in results)
|
|
print(f"\nAvg latency: baseline={avg_lat_B:.0f}ms 2ch={avg_lat_2:.0f}ms 3ch={avg_lat_3:.0f}ms (Δ={avg_lat_3-avg_lat_2:+.0f}ms)")
|
|
|
|
print(f"\nPer-query (top {min(n, 30)}):")
|
|
print(f"{'query':<55} {'BL':>4} {'2ch':>4} {'3ch':>4} {'2ch-ms':>6} {'3ch-ms':>6}")
|
|
for r in results[:30]:
|
|
b_s = str(r["baseline_rank"]) if r["baseline_rank"] else "-"
|
|
c2_s = str(r["two_ch_rank"]) if r["two_ch_rank"] else "-"
|
|
c3_s = str(r["three_ch_rank"]) if r["three_ch_rank"] else "-"
|
|
print(f"{r['query'][:54]:<55} {b_s:>4} {c2_s:>4} {c3_s:>4} {r['t_two_ch_ms']:>6.0f} {r['t_three_ch_ms']:>6.0f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |