Initial commit: Adaptive Recall sidecar for UMP (Phase 5)
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.
This commit is contained in:
Executable
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Adaptive Recall eval harness — Phase 1F.
|
||||
|
||||
Runs the same queries against UMP-only baseline (POST :4317/ump/recall) and
|
||||
the sidecar fused recall (POST :4380/recall), computes recall@1/3/5, MRR,
|
||||
and latency p50/p95/p99. Acceptance for Phase 1: sidecar recall@3 must
|
||||
exceed baseline by >30%.
|
||||
|
||||
Usage:
|
||||
python3 eval.py # full eval, prints results table
|
||||
python3 eval.py --json # machine-readable JSON
|
||||
python3 eval.py --weights '{"ump":1.0,"vector":2.0}' # tune RRF channel weights
|
||||
|
||||
Each query in eval/queries.py needs a real `expected_id` (a UMP URN). If
|
||||
the expected urn doesn't exist or returns null, we treat that query as a
|
||||
known-gap and skip it from the metric (with a warning in the report).
|
||||
|
||||
Output columns:
|
||||
query expected_id baseline_rank sidecar_rank baseline_ms sidecar_ms
|
||||
PLUS summary table at the bottom with recall@1/3/5, MRR, latency percentiles.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# Local imports — eval/queries.py is one directory up
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "eval"))
|
||||
from queries import QUERIES
|
||||
|
||||
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_post_json(url, body, timeout=30):
|
||||
"""POST JSON. Returns (status, parsed_body_or_None, elapsed_ms)."""
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
try:
|
||||
return resp.status, json.loads(raw), (time.time() - t0) * 1000
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, None, (time.time() - t0) * 1000
|
||||
except Exception as e:
|
||||
return 0, {"error": repr(e)}, (time.time() - t0) * 1000
|
||||
|
||||
|
||||
def baseline_recall(query, limit=10):
|
||||
"""Run query against UMP only. Returns (rank_of_expected_urn_or_None, elapsed_ms)."""
|
||||
status, body, ms = http_post_json(
|
||||
f"{UMP_URL}/ump/recall",
|
||||
{"query": query, "limit": limit},
|
||||
)
|
||||
if status != 200 or not body:
|
||||
return None, ms, f"baseline error: status={status} body={body}"
|
||||
hits = body.get("results", [])
|
||||
for idx, r in enumerate(hits):
|
||||
if r.get("record", {}).get("id") == expected_id_for_query(query):
|
||||
return idx + 1, ms, "ok"
|
||||
return None, ms, "not in top-K"
|
||||
|
||||
|
||||
def sidecar_recall(query, limit=10, weights=None):
|
||||
"""Run query against sidecar (RRF fused). Returns (rank, ms, status)."""
|
||||
payload = {"query": query, "limit": limit}
|
||||
if weights:
|
||||
payload["weights"] = weights
|
||||
status, body, ms = http_post_json(
|
||||
f"{SIDECAR_URL}/recall",
|
||||
payload,
|
||||
timeout=60,
|
||||
)
|
||||
if status != 200 or not body:
|
||||
return None, ms, f"sidecar error: status={status} body={body}"
|
||||
hits = body.get("hits", [])
|
||||
exp = expected_id_for_query(query)
|
||||
for idx, h in enumerate(hits):
|
||||
if h.get("urn") == exp:
|
||||
return idx + 1, ms, "ok"
|
||||
return None, ms, "not in top-K"
|
||||
|
||||
|
||||
def expected_id_for_query(query):
|
||||
"""Look up the expected urn from the QUERIES table."""
|
||||
for q in QUERIES:
|
||||
if q["query"] == query:
|
||||
return q.get("expected_id")
|
||||
return None
|
||||
|
||||
|
||||
def recall_at_k(ranks, k):
|
||||
"""Given a list of ranks (None if not in top-K), what fraction made top-K?"""
|
||||
hits = sum(1 for r in ranks if r is not None and r <= k)
|
||||
return hits / len(ranks) if ranks else 0
|
||||
|
||||
|
||||
def mrr(ranks):
|
||||
"""Mean reciprocal rank over ranks."""
|
||||
if not ranks:
|
||||
return 0
|
||||
total = sum(1.0 / r for r in ranks if r is not None)
|
||||
return total / len(ranks)
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
"""Nearest-rank percentile, simple and dependency-free."""
|
||||
if not values:
|
||||
return 0
|
||||
s = sorted(values)
|
||||
idx = max(0, min(len(s) - 1, int(len(s) * p / 100)))
|
||||
return s[idx]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
||||
parser.add_argument("--limit", type=int, default=10, help="top-K for retrieval (default 10)")
|
||||
parser.add_argument("--weights", type=str, default=None, help="RRF channel weights as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
weights = json.loads(args.weights) if args.weights else None
|
||||
|
||||
results = []
|
||||
baseline_ranks = []
|
||||
sidecar_ranks = []
|
||||
baseline_latencies = []
|
||||
sidecar_latencies = []
|
||||
skipped = []
|
||||
|
||||
for q in QUERIES:
|
||||
query = q["query"]
|
||||
exp = q.get("expected_id")
|
||||
if not exp:
|
||||
skipped.append({"query": query, "reason": "no expected_id"})
|
||||
continue
|
||||
|
||||
b_rank, b_ms, b_status = baseline_recall(query, limit=args.limit)
|
||||
s_rank, s_ms, s_status = sidecar_recall(query, limit=args.limit, weights=weights)
|
||||
|
||||
baseline_ranks.append(b_rank)
|
||||
sidecar_ranks.append(s_rank)
|
||||
baseline_latencies.append(b_ms)
|
||||
sidecar_latencies.append(s_ms)
|
||||
|
||||
results.append({
|
||||
"query": query,
|
||||
"expected_id": exp,
|
||||
"baseline_rank": b_rank,
|
||||
"sidecar_rank": s_rank,
|
||||
"baseline_ms": b_ms,
|
||||
"sidecar_ms": s_ms,
|
||||
"baseline_status": b_status,
|
||||
"sidecar_status": s_status,
|
||||
})
|
||||
|
||||
# Metrics
|
||||
metrics = {
|
||||
"baseline": {
|
||||
"recall@1": recall_at_k(baseline_ranks, 1),
|
||||
"recall@3": recall_at_k(baseline_ranks, 3),
|
||||
"recall@5": recall_at_k(baseline_ranks, 5),
|
||||
"mrr": mrr(baseline_ranks),
|
||||
"latency_p50_ms": percentile(baseline_latencies, 50),
|
||||
"latency_p95_ms": percentile(baseline_latencies, 95),
|
||||
"latency_p99_ms": percentile(baseline_latencies, 99),
|
||||
},
|
||||
"sidecar": {
|
||||
"recall@1": recall_at_k(sidecar_ranks, 1),
|
||||
"recall@3": recall_at_k(sidecar_ranks, 3),
|
||||
"recall@5": recall_at_k(sidecar_ranks, 5),
|
||||
"mrr": mrr(sidecar_ranks),
|
||||
"latency_p50_ms": percentile(sidecar_latencies, 50),
|
||||
"latency_p95_ms": percentile(sidecar_latencies, 95),
|
||||
"latency_p99_ms": percentile(sidecar_latencies, 99),
|
||||
},
|
||||
}
|
||||
# Acceptance gate
|
||||
baseline_r3 = metrics["baseline"]["recall@3"]
|
||||
sidecar_r3 = metrics["sidecar"]["recall@3"]
|
||||
if baseline_r3 > 0:
|
||||
improvement_pct = ((sidecar_r3 - baseline_r3) / baseline_r3) * 100
|
||||
else:
|
||||
improvement_pct = float("inf") if sidecar_r3 > 0 else 0
|
||||
metrics["acceptance_recall@3_improvement_pct"] = improvement_pct
|
||||
metrics["acceptance_met"] = improvement_pct > 30
|
||||
|
||||
output = {
|
||||
"queries_evaluated": len(results),
|
||||
"queries_skipped": len(skipped),
|
||||
"metrics": metrics,
|
||||
"results": results,
|
||||
"skipped": skipped,
|
||||
"weights": weights or {"ump": 1.0, "vector": 1.0},
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(f"\nAdaptive Recall Eval — Phase 1F")
|
||||
print(f"{'='*60}")
|
||||
print(f"Queries evaluated: {len(results)} | Skipped: {len(skipped)}")
|
||||
print(f"\n{'metric':<28} {'baseline':>10} {'sidecar':>10} {'delta':>10}")
|
||||
print(f"{'-'*60}")
|
||||
for k in ["recall@1", "recall@3", "recall@5", "mrr"]:
|
||||
b = metrics["baseline"][k]
|
||||
s = metrics["sidecar"][k]
|
||||
delta = s - b
|
||||
print(f"{k:<28} {b:>10.2%} {s:>10.2%} {delta:>+10.2%}")
|
||||
for k in ["latency_p50_ms", "latency_p95_ms", "latency_p99_ms"]:
|
||||
b = metrics["baseline"][k]
|
||||
s = metrics["sidecar"][k]
|
||||
delta = s - b
|
||||
print(f"{k:<28} {b:>10.0f} {s:>10.0f} {delta:>+10.0f}")
|
||||
print(f"\nAcceptance: recall@3 improvement = {improvement_pct:+.1f}% (target >30%)")
|
||||
print(f"Result: {'PASS' if metrics['acceptance_met'] else 'NEEDS WORK'}")
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Per-query results:")
|
||||
print(f"{'query':<55} {'B-rank':>7} {'S-rank':>7} {'B-ms':>7} {'S-ms':>7}")
|
||||
print(f"{'-'*83}")
|
||||
for r in results:
|
||||
b_str = f"{r['baseline_rank']}" if r['baseline_rank'] else "miss"
|
||||
s_str = f"{r['sidecar_rank']}" if r['sidecar_rank'] else "miss"
|
||||
q_short = r['query'][:54]
|
||||
print(f"{q_short:<55} {b_str:>7} {s_str:>7} {r['baseline_ms']:>7.0f} {r['sidecar_ms']:>7.0f}")
|
||||
if skipped:
|
||||
print(f"\nSkipped: {skipped}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user