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.
213 lines
7.6 KiB
JavaScript
213 lines
7.6 KiB
JavaScript
// Phase 3 tests — ACT-R scoring and re-ranking.
|
|
//
|
|
// Run: node test/test_actr.js
|
|
// Pass criteria: every assertion line must print PASS. Exits 1 if any FAIL.
|
|
|
|
import { activation, minMaxNormalize, rerank, ACTR_DEFAULTS } from "../src/actr.js";
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function assert(name, ok, extra = "") {
|
|
if (ok) {
|
|
passed++;
|
|
console.log(`PASS ${name}`);
|
|
} else {
|
|
failed++;
|
|
console.log(`FAIL ${name}${extra ? ` (${extra})` : ""}`);
|
|
}
|
|
}
|
|
|
|
function approx(a, b, tol = 1e-3) {
|
|
return Math.abs(a - b) <= tol;
|
|
}
|
|
|
|
// ---- activation() formula tests ----
|
|
|
|
{
|
|
// age=1 → base = -d·ln(1) = 0
|
|
const a = activation({ ageDays: 1, frequency: 0, confidence: 1, d: 0.5, beta: 1, epsilon: 1 });
|
|
assert("activation: age=1, no freq/conf bonus → 1.0", approx(a, 1.0), `got ${a}`);
|
|
}
|
|
|
|
{
|
|
// age=30 semantic-like: base = -0.5*ln(30) ≈ -1.701
|
|
// freq=0 → 0; conf=1.0 → 1.0
|
|
// total ≈ -1.701 + 0 + 1.0 = -0.701
|
|
const a = activation({ ageDays: 30, frequency: 0, confidence: 1, d: 0.5, beta: 1, epsilon: 1 });
|
|
assert("activation: age=30 conf=1 → ≈-0.701", approx(a, -0.701, 0.01), `got ${a}`);
|
|
}
|
|
|
|
{
|
|
// Higher freq → higher activation
|
|
const a0 = activation({ ageDays: 10, frequency: 0, confidence: 1 });
|
|
const a10 = activation({ ageDays: 10, frequency: 10, confidence: 1 });
|
|
const a100 = activation({ ageDays: 10, frequency: 100, confidence: 1 });
|
|
assert("activation: freq is monotonic", a10 > a0 && a100 > a10, `a0=${a0} a10=${a10} a100=${a100}`);
|
|
}
|
|
|
|
{
|
|
// Higher confidence → higher activation
|
|
const low = activation({ ageDays: 10, frequency: 5, confidence: 0.3 });
|
|
const mid = activation({ ageDays: 10, frequency: 5, confidence: 0.6 });
|
|
const high = activation({ ageDays: 10, frequency: 5, confidence: 1.0 });
|
|
assert("activation: confidence is monotonic", low < mid && mid < high, `${low} < ${mid} < ${high}`);
|
|
}
|
|
|
|
{
|
|
// Older → lower activation (forgetting)
|
|
const fresh = activation({ ageDays: 1, frequency: 5, confidence: 1 });
|
|
const old = activation({ ageDays: 365, frequency: 5, confidence: 1 });
|
|
assert("activation: older is lower (forgetting)", fresh > old, `fresh=${fresh} old=${old}`);
|
|
}
|
|
|
|
{
|
|
// Edge case: ageDays=0 (just-modified record) → falls back to 1
|
|
const a = activation({ ageDays: 0, frequency: 0, confidence: 1 });
|
|
assert("activation: age=0 falls back to 1 (no -Infinity)", Number.isFinite(a), `got ${a}`);
|
|
}
|
|
|
|
{
|
|
// Edge case: missing fields use defaults
|
|
const a = activation({});
|
|
assert("activation: empty args → defaults are sane", Number.isFinite(a), `got ${a}`);
|
|
}
|
|
|
|
{
|
|
// Edge case: negative confidence clamped to 0 (so eTerm=0; base term
|
|
// can still be negative for old records, which is correct ACT-R behavior)
|
|
const a = activation({ ageDays: 10, frequency: 0, confidence: -0.5 });
|
|
const baseline = activation({ ageDays: 10, frequency: 0, confidence: 0 });
|
|
assert("activation: negative confidence clamps (matches confidence=0)",
|
|
approx(a, baseline), `a=${a} baseline=${baseline}`);
|
|
}
|
|
|
|
{
|
|
// Edge case: huge frequency still works
|
|
const a = activation({ ageDays: 10, frequency: 1000000, confidence: 0.5 });
|
|
assert("activation: huge freq is finite", Number.isFinite(a), `got ${a}`);
|
|
}
|
|
|
|
// ---- minMaxNormalize() tests ----
|
|
|
|
{
|
|
const out = minMaxNormalize([1, 2, 3, 4, 5]);
|
|
assert("minMaxNormalize: 5 values → [0, 0.25, 0.5, 0.75, 1]",
|
|
approx(out[0], 0) && approx(out[1], 0.25) && approx(out[2], 0.5) &&
|
|
approx(out[3], 0.75) && approx(out[4], 1),
|
|
`got ${JSON.stringify(out)}`);
|
|
}
|
|
|
|
{
|
|
const out = minMaxNormalize([5, 5, 5, 5]);
|
|
assert("minMaxNormalize: all-equal → all 0.5",
|
|
out.every((v) => approx(v, 0.5)),
|
|
`got ${JSON.stringify(out)}`);
|
|
}
|
|
|
|
{
|
|
assert("minMaxNormalize: empty → empty", minMaxNormalize([]).length === 0);
|
|
}
|
|
|
|
{
|
|
assert("minMaxNormalize: non-array → empty",
|
|
minMaxNormalize(null).length === 0 && minMaxNormalize(undefined).length === 0);
|
|
}
|
|
|
|
// ---- rerank() tests ----
|
|
|
|
{
|
|
// 3 candidates with different ages/freqs. Use a stub lookupMeta.
|
|
const candidates = [
|
|
{ urn: "u1", score: 0.5, byChannel: { ump: { rank: 1 } } },
|
|
{ urn: "u2", score: 0.4, byChannel: { ump: { rank: 2 } } },
|
|
{ urn: "u3", score: 0.3, byChannel: { ump: { rank: 3 } } },
|
|
];
|
|
const metas = {
|
|
u1: { ageDays: 100, frequency: 0, confidence: 0.3 }, // old, no freq, low conf
|
|
u2: { ageDays: 1, frequency: 50, confidence: 1.0 }, // fresh, popular, fully encoded
|
|
u3: { ageDays: 5, frequency: 5, confidence: 0.7 },
|
|
};
|
|
const lookupMeta = async (urn) => metas[urn] || { ageDays: 1, frequency: 0, confidence: 1 };
|
|
|
|
const ranked = await rerank(candidates, lookupMeta, { alpha: 0.5 });
|
|
assert("rerank: returns same count", ranked.length === 3, `got ${ranked.length}`);
|
|
assert("rerank: assigns final_rank 1..N",
|
|
ranked[0].final_rank === 1 && ranked[1].final_rank === 2 && ranked[2].final_rank === 3);
|
|
assert("rerank: fresh+popular+confident beats old+rare (with alpha=0.5)",
|
|
ranked[0].urn === "u2", `top is ${ranked[0].urn}`);
|
|
assert("rerank: includes actr_score and final_score",
|
|
typeof ranked[0].actr_score === "number" && typeof ranked[0].final_score === "number");
|
|
}
|
|
|
|
{
|
|
// alpha=0 → pure RRF (no reordering)
|
|
const candidates = [
|
|
{ urn: "u1", score: 0.5 },
|
|
{ urn: "u2", score: 0.4 },
|
|
];
|
|
const lookupMeta = async () => ({ ageDays: 1000, frequency: 0, confidence: 0 });
|
|
const ranked = await rerank(candidates, lookupMeta, { alpha: 0 });
|
|
assert("rerank: alpha=0 keeps RRF order",
|
|
ranked[0].urn === "u1" && ranked[1].urn === "u2",
|
|
`got ${ranked.map((r) => r.urn).join(",")}`);
|
|
}
|
|
|
|
{
|
|
// alpha=1 → pure ACT-R (order may flip)
|
|
const candidates = [
|
|
{ urn: "u1", score: 0.5 }, // will have low ACT-R (old, no freq, low conf)
|
|
{ urn: "u2", score: 0.4 }, // will have high ACT-R (fresh, popular, confident)
|
|
];
|
|
const lookupMeta = async (urn) => {
|
|
if (urn === "u1") return { ageDays: 1000, frequency: 0, confidence: 0.1 };
|
|
return { ageDays: 1, frequency: 100, confidence: 1 };
|
|
};
|
|
const ranked = await rerank(candidates, lookupMeta, { alpha: 1 });
|
|
assert("rerank: alpha=1 reverses based on ACT-R alone",
|
|
ranked[0].urn === "u2", `got ${ranked.map((r) => r.urn).join(",")}`);
|
|
}
|
|
|
|
{
|
|
// Empty input
|
|
const ranked = await rerank([], async () => ({}));
|
|
assert("rerank: empty input → empty output", ranked.length === 0);
|
|
}
|
|
|
|
{
|
|
// lookupMeta throws → gracefully skipped (uses defaults)
|
|
const candidates = [{ urn: "u1", score: 0.5 }];
|
|
const ranked = await rerank(candidates, async () => {
|
|
throw new Error("mock failure");
|
|
});
|
|
assert("rerank: lookupMeta throw → still returns candidates", ranked.length === 1);
|
|
assert("rerank: lookupMeta throw → uses default metadata",
|
|
typeof ranked[0].actr_score === "number");
|
|
}
|
|
|
|
{
|
|
// RRF tie-break: when final_score ties, higher RRF wins
|
|
const candidates = [
|
|
{ urn: "high_rrf_low_actr", score: 0.9 },
|
|
{ urn: "low_rrf_high_actr", score: 0.5 },
|
|
];
|
|
// Both have same ACT-R → final_score from rrf norm = [1.0, 0.0]
|
|
// With alpha=0, final_score == rrf_norm → high_rrf wins
|
|
const lookupMeta = async () => ({ ageDays: 10, frequency: 10, confidence: 0.5 });
|
|
const ranked = await rerank(candidates, lookupMeta, { alpha: 0 });
|
|
assert("rerank: ties broken by RRF score desc",
|
|
ranked[0].urn === "high_rrf_low_actr",
|
|
`got ${ranked.map((r) => r.urn).join(",")}`);
|
|
}
|
|
|
|
// ---- defaults ----
|
|
|
|
{
|
|
assert("ACTR_DEFAULTS: alpha=0.3", ACTR_DEFAULTS.alpha === 0.3);
|
|
assert("ACTR_DEFAULTS: d=0.5", ACTR_DEFAULTS.d === 0.5);
|
|
assert("ACTR_DEFAULTS: beta=1.0", ACTR_DEFAULTS.beta === 1.0);
|
|
assert("ACTR_DEFAULTS: epsilon=1.0", ACTR_DEFAULTS.epsilon === 1.0);
|
|
}
|
|
|
|
console.log(`\n${passed}/${passed + failed} passed`);
|
|
process.exit(failed > 0 ? 1 : 0); |