Files
Krystie dc5dc94d79 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.
2026-07-12 19:24:47 -07:00

194 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
Phase 3 eval — measure ACT-R re-ranking lift on top of 3-channel RRF.
Method:
For each query:
1. Run /recall with ACTR_ENABLED=true (the new behavior)
2. Run /recall with ACTR_ENABLED=false — but since ACT-R is a server
flag, we can't toggle it per-request. Instead, we ASK for raw RRF
ranking by setting weights={"actr":...}. Wait — ACT-R isn't a
channel, it's a re-ranker; weights won't disable it.
3. So we use a different proxy: get the "rrf_rank" field (which is
rank-before-ACT-R) and the "final_rank" field (rank-after-ACT-R),
and measure how often they differ.
For ground truth: use the self-bootstrapping trick (3ch's vector-channel
top-1 is the "ground truth" urn). Then check whether ACT-R re-ranking
moved that ground-truth urn to a better position than RRF alone did.
Metrics:
- top1_agreement: did the query's #1 hit stay #1?
- top1_improved: did ACT-R move something higher than RRF did?
- top1_regressed: did ACT-R move the RRF #1 down?
- rank_delta_distribution: how much did ACT-R move things?
- coverage_at_k: what fraction of top-K kept their ground truth?
- avg_rank_movement: mean signed rank delta (positive = promoted)
"""
import json
import os
import sys
import time
import urllib.parse
import urllib.request
SIDECAR_URL = os.getenv("SIDECAR_URL", "http://127.0.0.1:4380")
UMP_URL = os.getenv("UMP_URL", "http://127.0.0.1:4317")
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(query, limit=10):
return http_json(f"{SIDECAR_URL}/recall", {"query": query, "limit": limit}, method="POST")
def main():
qpath = "/root/ump-recall/eval/queries.py"
sys.path.insert(0, os.path.dirname(qpath))
from queries import QUERIES
print(f"Loaded {len(QUERIES)} queries from {qpath}\n")
# Per-query analytics
rows = []
for q in QUERIES:
query = q["query"]
s, body, t = recall(query, 10)
if s != 200 or not body:
rows.append({"query": query[:60], "error": True})
continue
hits = body.get("hits", [])
# RRF-only rank (what we'd see without ACT-R): position in the
# response sorted by rrf_score desc.
rrf_sorted = sorted(hits, key=lambda h: -(h.get("rrf_score") or h.get("score") or 0))
rrf_ranks = {h["urn"]: i + 1 for i, h in enumerate(rrf_sorted)}
# Final rank (with ACT-R applied): from final_rank field.
final_ranks = {h["urn"]: h.get("final_rank", i + 1) for i, h in enumerate(hits)}
# RRF top-1
rrf_top1 = rrf_sorted[0]["urn"] if rrf_sorted else None
# Final top-1
final_top1 = hits[0]["urn"] if hits else None
# How many top-K URNs stayed in their position?
rank_changes = []
for h in hits:
urn = h["urn"]
rrf_r = rrf_ranks.get(urn)
final_r = final_ranks.get(urn)
if rrf_r is not None and final_r is not None:
rank_changes.append({
"urn": urn[-25:],
"rrf_rank": rrf_r,
"final_rank": final_r,
"delta": rrf_r - final_r, # positive = promoted
"actr_score": h.get("actr_score"),
})
# Avg rank movement (positive = promoted)
deltas = [rc["delta"] for rc in rank_changes]
avg_delta = sum(deltas) / len(deltas) if deltas else 0
# Of hits moved by ACT-R, how many were promoted vs demoted?
promoted = sum(1 for d in deltas if d > 0)
demoted = sum(1 for d in deltas if d < 0)
unchanged = sum(1 for d in deltas if d == 0)
rows.append({
"query": query[:60],
"rrf_top1": rrf_top1[-25:] if rrf_top1 else None,
"final_top1": final_top1[-25:] if final_top1 else None,
"top1_changed": rrf_top1 != final_top1,
"actr_applied": body.get("rerank_applied"),
"rerank_pool": body.get("rerank_pool"),
"actr_alpha": body.get("actr", {}).get("alpha"),
"actr_d": body.get("actr", {}).get("d"),
"promoted": promoted,
"demoted": demoted,
"unchanged": unchanged,
"avg_delta": avg_delta,
"t_ms": round(t, 1),
"rank_changes": rank_changes,
})
n = len(rows)
valid = [r for r in rows if not r.get("error")]
print(f"=== ACT-R Re-rank Lift — Phase 3 ===\n")
print(f"Queries: {n} valid: {len(valid)}\n")
# Top-level metrics
top1_changed = sum(1 for r in valid if r["top1_changed"])
promoted_total = sum(r["promoted"] for r in valid)
demoted_total = sum(r["demoted"] for r in valid)
unchanged_total = sum(r["unchanged"] for r in valid)
avg_pool = sum(r["rerank_pool"] or 0 for r in valid) / len(valid) if valid else 0
avg_t = sum(r["t_ms"] for r in valid) / len(valid) if valid else 0
avg_delta = sum(r["avg_delta"] for r in valid) / len(valid) if valid else 0
print(f"Queries where ACT-R changed the #1 hit: {top1_changed}/{len(valid)} ({top1_changed/len(valid):.0%})")
print(f"Average ACT-R alpha: {sum(r['actr_alpha'] or 0 for r in valid)/len(valid):.2f}")
print(f"Average ACT-R d: {sum(r['actr_d'] or 0 for r in valid)/len(valid):.2f}")
print(f"Average rerank pool size: {avg_pool:.1f}")
print(f"Average latency: {avg_t:.0f}ms")
print()
print(f"Per-position movement across all queries:")
print(f" Promoted (RRF rank > final rank): {promoted_total} hits")
print(f" Demoted (RRF rank < final rank): {demoted_total} hits")
print(f" Unchanged: {unchanged_total} hits")
print(f" Avg rank movement (positive=promoted): {avg_delta:+.2f}")
print()
# Top-5 hit agreement — what fraction of top-5 stayed top-5?
# This is harder to measure per-query without tracking URN sets.
# Instead: how many RRF top-5 URNs are still in the final top-5?
top5_kept = 0
top5_total = 0
for r in valid:
rrf_top5 = sorted(r["rank_changes"], key=lambda x: x["rrf_rank"])[:5]
final_top5_urns = set(rc["urn"] for rc in sorted(r["rank_changes"], key=lambda x: x["final_rank"])[:5])
for rc in rrf_top5:
top5_total += 1
if rc["urn"] in final_top5_urns:
top5_kept += 1
if top5_total:
print(f"Top-5 set retention: {top5_kept}/{top5_total} ({top5_kept/top5_total:.0%})")
# Show queries with biggest re-rank deltas
print(f"\nPer-query (sorted by # of promoted hits):")
print(f"{'query':<55} {'top1_changed':>12} {'promo':>5} {'demo':>5} {'unch':>5} {'avg_Δ':>6}")
sorted_rows = sorted(valid, key=lambda r: -(r["promoted"] + r["demoted"]))
for r in sorted_rows[:25]:
tc = "yes" if r["top1_changed"] else "no"
print(f"{r['query'][:54]:<55} {tc:>12} {r['promoted']:>5} {r['demoted']:>5} {r['unchanged']:>5} {r['avg_delta']:>+6.2f}")
# Show the actual movements for the top movers
print(f"\nSample rank movements (queries with most change):")
for r in sorted_rows[:5]:
print(f"\n {r['query']}")
# Sort by |delta| desc, show top 4
moves = sorted(r["rank_changes"], key=lambda x: -abs(x["delta"]))[:4]
for m in moves:
arrow = "" if m["delta"] > 0 else ("" if m["delta"] < 0 else "·")
print(f" {arrow} RRF#{m['rrf_rank']} → final#{m['final_rank']} (actr={m['actr_score']:+.3f}) {m['urn']}")
if __name__ == "__main__":
main()