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.
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Phase 2 eval — Channel-contribution analysis.
|
|
|
|
Uses self-bootstrapping: for each query, the ground truth is the top-1
|
|
sidecar hit. Then we ask: which channels contributed to the top-K, and
|
|
did adding graph change the ranking?
|
|
|
|
Reports:
|
|
- For each query, what fraction of top-5 came from each channel?
|
|
- How often does graph contribute a UNIQUE hit (not in 2ch top-K)?
|
|
- Average rank position of graph-only contributions
|
|
- Latency cost of graph channel
|
|
|
|
This is more robust than the expected_id-based eval because it measures
|
|
the actual contribution of each channel rather than guessing what the
|
|
"right" answer is.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
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(query, limit=10, with_graph=True):
|
|
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 channel_source(hit, mode="2ch"):
|
|
"""Which channel(s) contributed this hit. Returns set of channel names."""
|
|
by_ch = hit.get("by_channel", {})
|
|
if mode == "2ch":
|
|
return set(k for k in by_ch if k in ("ump", "vector"))
|
|
return set(by_ch.keys())
|
|
|
|
|
|
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")
|
|
|
|
rows = []
|
|
for q in QUERIES:
|
|
query = q["query"]
|
|
|
|
s3, b3, t3 = recall(query, 10, with_graph=True)
|
|
s2, b2, t2 = recall(query, 10, with_graph=False)
|
|
|
|
if s3 != 200 or s2 != 200:
|
|
rows.append({"query": query[:60], "error": True})
|
|
continue
|
|
|
|
hits3 = b3.get("hits", [])
|
|
hits2 = b2.get("hits", [])
|
|
|
|
# How many of 3ch's top-5 came via graph (graph-only or graph+other)?
|
|
top5_3 = hits3[:5]
|
|
graph_contrib_top5_3 = sum(1 for h in top5_3 if "graph" in h.get("by_channel", {}))
|
|
|
|
top5_2 = hits2[:5]
|
|
graph_contrib_top5_2 = sum(1 for h in top5_2 if "graph" in h.get("by_channel", {}))
|
|
|
|
# Graph-unique: hits in 3ch top-5 that are NOT in 2ch top-5
|
|
urns_3 = {h["urn"] for h in top5_3}
|
|
urns_2 = {h["urn"] for h in top5_2}
|
|
unique_to_3 = urns_3 - urns_2
|
|
|
|
# For each graph-only hit, what was its rank in 3ch?
|
|
graph_only_ranks = [
|
|
i + 1 for i, h in enumerate(top5_3)
|
|
if h["urn"] in unique_to_3 and "graph" in h.get("by_channel", {})
|
|
]
|
|
|
|
rows.append({
|
|
"query": query[:60],
|
|
"graph_contrib_top5_3ch": graph_contrib_top5_3,
|
|
"graph_contrib_top5_2ch": graph_contrib_top5_2,
|
|
"unique_to_3ch_count": len(unique_to_3),
|
|
"graph_only_ranks_in_3ch": graph_only_ranks,
|
|
"t_2ch_ms": round(t2, 1),
|
|
"t_3ch_ms": round(t3, 1),
|
|
})
|
|
|
|
n = len(rows)
|
|
valid = [r for r in rows if not r.get("error")]
|
|
print(f"Valid: {len(valid)}/{n}\n")
|
|
|
|
if not valid:
|
|
return
|
|
|
|
# Per-query: graph channel contribution
|
|
g_in_top5_3 = sum(r["graph_contrib_top5_3ch"] for r in valid) / len(valid)
|
|
g_in_top5_2 = sum(r["graph_contrib_top5_2ch"] for r in valid) / len(valid)
|
|
print(f"Avg graph-channel hits in top-5:")
|
|
print(f" 3ch (graph enabled): {g_in_top5_3:.2f} hits/query")
|
|
print(f" 2ch (graph zero-weigh): {g_in_top5_2:.2f} hits/query")
|
|
print(f" Delta: {g_in_top5_3 - g_in_top5_2:+.2f} (should be ~equal since weights zero it)")
|
|
|
|
# Unique-to-3ch count
|
|
avg_unique = sum(r["unique_to_3ch_count"] for r in valid) / len(valid)
|
|
total_unique = sum(r["unique_to_3ch_count"] for r in valid)
|
|
print(f"\nHits in 3ch top-5 that are NOT in 2ch top-5:")
|
|
print(f" Total unique: {total_unique} (avg {avg_unique:.2f}/query)")
|
|
|
|
# Latency
|
|
avg_t2 = sum(r["t_2ch_ms"] for r in valid) / len(valid)
|
|
avg_t3 = sum(r["t_3ch_ms"] for r in valid) / len(valid)
|
|
print(f"\nLatency: 2ch={avg_t2:.0f}ms 3ch={avg_t3:.0f}ms Δ={avg_t3-avg_t2:+.0f}ms (graph overhead)")
|
|
|
|
# Show queries where graph added value
|
|
print(f"\nPer-query graph contribution (top 20 by graph involvement):")
|
|
print(f"{'query':<55} {'g-top5':>6} {'uniq':>5} {'2ch-ms':>7} {'3ch-ms':>7} {'g-ranks':>10}")
|
|
sorted_rows = sorted(valid, key=lambda r: -r["graph_contrib_top5_3ch"])
|
|
for r in sorted_rows[:20]:
|
|
print(f"{r['query'][:54]:<55} {r['graph_contrib_top5_3ch']:>6} "
|
|
f"{r['unique_to_3ch_count']:>5} {r['t_2ch_ms']:>7.0f} {r['t_3ch_ms']:>7.0f} "
|
|
f"{str(r['graph_only_ranks_in_3ch']):>10}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |