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

242 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""
Phase 1F eval — self-bootstrapping version.
The original eval failed because hand-picked expected_ids were guesses that
didn't match real UMP records. This version:
1. Runs each query against the sidecar's vector channel.
2. Takes the top-1 vector hit as "ground truth" (the query semantically
matches SOMETHING — that's what we test for).
3. Runs the same query against UMP-only baseline (FTS5 + recency).
4. Measures: did the baseline find the same record? If yes, baseline wins
on that query. If no, sidecar is the only path that surfaces it.
This measures the REAL question: "does the vector channel find relevant
records that FTS5 misses?" — which is the Adaptive Recall improvement
we're building.
Plus a precision sanity check: of the top-3 sidecar hits, how many have
keywords overlapping with the query? High overlap = vector channel is
finding what we want, not noise.
Output: same format as eval.py.
"""
import argparse
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=30):
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()
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 run_query(url, payload, timeout=30):
return http_json(url, payload, method="POST", timeout=timeout)
def fetch_record(urn):
status, body, _ = http_json(
f"{UMP_URL}/ump/memory/{urllib.parse.quote(urn, safe='')}",
method="GET", timeout=5,
)
if status != 200:
return None
return body.get("record") or body
def recall_at_k(rank, k):
return 1 if (rank is not None and rank <= k) else 0
def percentile(values, p):
if not values:
return 0
s = sorted(values)
idx = max(0, min(len(s) - 1, int(len(s) * p / 100)))
return s[idx]
def overlap_score(query, text):
"""What fraction of query keywords appear in text? (0..1)"""
if not text:
return 0
q_words = {w.lower() for w in query.split() if len(w) > 3}
if not q_words:
return 0
text_lower = text.lower()
hits = sum(1 for w in q_words if w in text_lower)
return hits / len(q_words)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=10)
parser.add_argument("--queries-from", default=None,
help="Path to queries.py (default: ../eval/queries.py)")
args = parser.parse_args()
if args.queries_from:
sys.path.insert(0, os.path.dirname(args.queries_from))
from queries import QUERIES
else:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "eval"))
from queries import QUERIES
# For each query:
# 1. Sidecar vector channel top-1 = ground truth urn
# 2. Baseline UMP tries to find it. If found at rank R, baseline recall@R=1.
# 3. Sidecar RRF tries to find it. Same metric.
# Plus precision check: top-3 sidecar keyword overlap with query.
results = []
baseline_hits = [] # 1/0 per query: did baseline find the ground truth?
sidecar_hits = [] # 1/0 per query: did sidecar find the ground truth?
overlap_scores = [] # top-3 sidecar keyword overlap average
for q in QUERIES:
query = q["query"]
# Step 1: get ground truth from vector channel top-1
status, body, gt_ms = run_query(
f"{SIDECAR_URL}/recall",
{"query": query, "limit": args.limit},
)
if status != 200 or not body:
results.append({"query": query, "error": f"sidecar vector failed: {body}"})
continue
gt_urn = body["hits"][0]["urn"] if body.get("hits") else None
gt_subject = (body["hits"][0].get("record") or {}).get("body", {}).get("subject", "")
gt_text = (body["hits"][0].get("record") or {}).get("body", {}).get("text", "")
if not gt_urn:
results.append({"query": query, "error": "vector channel returned no hits"})
continue
# Step 2: baseline UMP recall
status, body, b_ms = run_query(
f"{UMP_URL}/ump/recall",
{"query": query, "limit": args.limit},
)
baseline_rank = None
if status == 200 and body:
for idx, r in enumerate(body.get("results", [])):
if r.get("record", {}).get("id") == gt_urn:
baseline_rank = idx + 1
break
# Step 3: sidecar RRF (already have it, but re-fetch for fair timing)
status, body, s_ms = run_query(
f"{SIDECAR_URL}/recall",
{"query": query, "limit": args.limit},
)
sidecar_rank = None
top3_overlap = 0
if status == 200 and body:
for idx, h in enumerate(body.get("hits", [])):
if h.get("urn") == gt_urn:
sidecar_rank = idx + 1
break
# Top-3 keyword overlap
top3 = body.get("hits", [])[:3]
overlaps = []
for h in top3:
rec = h.get("record") or {}
text = rec.get("body", {}).get("text", "")[:500] # first 500 chars
subject = rec.get("body", {}).get("subject", "")
overlaps.append(overlap_score(query, f"{subject} {text}"))
top3_overlap = sum(overlaps) / len(overlaps) if overlaps else 0
baseline_hits.append(recall_at_k(baseline_rank, 10))
sidecar_hits.append(recall_at_k(sidecar_rank, 10))
overlap_scores.append(top3_overlap)
results.append({
"query": query,
"ground_truth_urn": gt_urn[-30:],
"ground_truth_subject": gt_subject[:50],
"baseline_rank": baseline_rank,
"sidecar_rank": sidecar_rank,
"baseline_ms": b_ms,
"sidecar_ms": s_ms,
"sidecar_top3_overlap": round(top3_overlap, 3),
})
n = len(results)
if n == 0:
print("No results to analyze.")
return
# Metrics
metrics = {
"n_queries": n,
"baseline_hit_rate@10": sum(baseline_hits) / n,
"sidecar_hit_rate@10": sum(sidecar_hits) / n,
"sidecar_top3_keyword_overlap_avg": sum(overlap_scores) / n,
}
# Lift = sidecar hit rate - baseline hit rate
metrics["hit_rate_lift"] = (
metrics["sidecar_hit_rate@10"] - metrics["baseline_hit_rate@10"]
)
# Output
print(f"\nAdaptive Recall Eval (self-bootstrapping) — Phase 1F")
print(f"{'='*70}")
print(f"Queries evaluated: {n}")
print(f"\n{'metric':<40} {'value':>15}")
print(f"{'-'*55}")
for k, v in metrics.items():
if isinstance(v, float) and k != "queries_evaluated":
print(f"{k:<40} {v:>15.3f}")
else:
print(f"{k:<40} {v:>15}")
# Count scenarios
sidecar_only = sum(1 for r in results if r.get("baseline_rank") is None and r.get("sidecar_rank") is not None)
baseline_only = sum(1 for r in results if r.get("baseline_rank") is not None and r.get("sidecar_rank") is None)
both_found = sum(1 for r in results if r.get("baseline_rank") is not None and r.get("sidecar_rank") is not None)
neither = sum(1 for r in results if r.get("baseline_rank") is None and r.get("sidecar_rank") is None)
print(f"\nScenario breakdown:")
print(f" Both find: {both_found:>3}")
print(f" Sidecar-only: {sidecar_only:>3} ← semantic channel found, FTS missed")
print(f" Baseline-only: {baseline_only:>3}")
print(f" Neither: {neither:>3}")
print(f"\n{'='*70}")
print(f"Per-query details (GT = vector-channel top-1):")
print(f"{'query':<55} {'B-rank':>7} {'S-rank':>7} {'top3OL':>7}")
print(f"{'-'*78}")
for r in results:
b_str = f"{r['baseline_rank']}" if r.get("baseline_rank") else "miss"
s_str = f"{r['sidecar_rank']}" if r.get("sidecar_rank") else "miss"
ov = r.get("sidecar_top3_overlap", 0)
q_short = r["query"][:54]
print(f"{q_short:<55} {b_str:>7} {s_str:>7} {ov:>7.2f}")
if __name__ == "__main__":
main()