Files
self-directed-learning/test/test_actr.js
T
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

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);