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.
156 lines
4.8 KiB
JavaScript
156 lines
4.8 KiB
JavaScript
// Phase 5 tests — access tracking.
|
|
// Run: node test/test_access_log.js
|
|
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { AccessLog } from "../src/access_log.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})` : ""}`); }
|
|
}
|
|
|
|
const TMP = `/tmp/access_log_test_${process.pid}.json`;
|
|
|
|
async function fresh() {
|
|
// wipe file
|
|
try { await fs.unlink(TMP); } catch {}
|
|
// simple: pass filePath directly to constructor
|
|
return new AccessLog(TMP);
|
|
}
|
|
|
|
// ---- constructor ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
assert("new AccessLog: empty map", al.byUrn.size === 0);
|
|
assert("new AccessLog: not loaded", al._loaded === false);
|
|
assert("new AccessLog: schema_version=1", al.meta.schema_version === 1);
|
|
}
|
|
|
|
// ---- bump ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
const r1 = al.bump("urn:ump:a");
|
|
assert("bump new urn: returns object", r1 && r1.count === 1);
|
|
assert("bump new urn: last_accessed_at is ISO", typeof r1.last_accessed_at === "string" && r1.last_accessed_at.endsWith("Z"));
|
|
const r2 = al.bump("urn:ump:a");
|
|
assert("bump same urn: count++", r2.count === 2, `got ${r2.count}`);
|
|
const r3 = al.bump("urn:ump:b");
|
|
assert("bump different urn: separate counter", r3.count === 1 && al.byUrn.size === 2);
|
|
}
|
|
|
|
{
|
|
const al = await fresh();
|
|
const r = al.bump(null);
|
|
assert("bump null: returns null", r === null);
|
|
const r2 = al.bump(undefined);
|
|
assert("bump undefined: returns null", r2 === null);
|
|
const r3 = al.bump("");
|
|
assert("bump empty string: returns null", r3 === null);
|
|
}
|
|
|
|
// ---- bumpMany ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
const results = al.bumpMany(["urn:ump:a", "urn:ump:b", "urn:ump:c", null, ""]);
|
|
assert("bumpMany: skips null/empty entries",
|
|
Object.keys(results).length === 3);
|
|
al.bumpMany(["urn:ump:a", "urn:ump:a"]);
|
|
assert("bumpMany: cumulative counts",
|
|
al.byUrn.get("urn:ump:a").count === 3, `got ${al.byUrn.get("urn:ump:a").count}`);
|
|
}
|
|
|
|
// ---- get ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
al.bump("urn:ump:x");
|
|
const got = al.get("urn:ump:x");
|
|
assert("get: returns copy", got && got.count === 1);
|
|
// Mutating returned object shouldn't affect internal state
|
|
got.count = 999;
|
|
assert("get: returns shallow copy",
|
|
al.byUrn.get("urn:ump:x").count === 1,
|
|
`internal was mutated`);
|
|
assert("get: null urn → null", al.get(null) === null);
|
|
assert("get: unknown urn → null", al.get("urn:ump:never-bumped") === null);
|
|
}
|
|
|
|
// ---- snapshot ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
al.bump("urn:ump:a");
|
|
al.bump("urn:ump:b");
|
|
al.bump("urn:ump:b");
|
|
const snap = al.snapshot();
|
|
assert("snapshot: includes all URNs",
|
|
Object.keys(snap).length === 2);
|
|
assert("snapshot: counts preserved",
|
|
snap["urn:ump:a"].count === 1 && snap["urn:ump:b"].count === 2);
|
|
}
|
|
|
|
// ---- load ----
|
|
|
|
{
|
|
// Write a log file manually, then load
|
|
const al1 = await fresh();
|
|
al1.bump("urn:ump:existing");
|
|
al1.bump("urn:ump:existing");
|
|
await al1.flush();
|
|
|
|
const al2 = new AccessLog(TMP);
|
|
await al2.load();
|
|
assert("load: existing data loaded",
|
|
al2.byUrn.has("urn:ump:existing") && al2.byUrn.get("urn:ump:existing").count === 2);
|
|
}
|
|
|
|
{
|
|
// Load from non-existent file → no error, empty map
|
|
const al = new AccessLog("/tmp/does-not-exist-" + Date.now() + ".json");
|
|
await al.load();
|
|
assert("load: missing file → empty (no error)", al.byUrn.size === 0);
|
|
}
|
|
|
|
// ---- flush + persistence roundtrip ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
al.bump("urn:ump:flush-test");
|
|
al.bump("urn:ump:flush-test");
|
|
al.bump("urn:ump:flush-test");
|
|
await al.flush();
|
|
// File exists
|
|
const raw = await fs.readFile(TMP, "utf8");
|
|
const data = JSON.parse(raw);
|
|
assert("flush: file written",
|
|
data.by_urn && data.by_urn["urn:ump:flush-test"].count === 3);
|
|
assert("flush: schema_version present", data.schema_version === 1);
|
|
assert("flush: meta.total_writes incremented", data.meta.total_writes >= 1);
|
|
// No .tmp left behind
|
|
const tmpFiles = (await fs.readdir(path.dirname(TMP))).filter(f => f.includes("access_log_test") && f.endsWith(".tmp-" + process.pid));
|
|
assert("flush: no .tmp file left behind", tmpFiles.length === 0, `found ${tmpFiles.join(",")}`);
|
|
}
|
|
|
|
// ---- stats ----
|
|
|
|
{
|
|
const al = await fresh();
|
|
al.bump("urn:ump:s1");
|
|
al.bump("urn:ump:s1");
|
|
al.bump("urn:ump:s2");
|
|
al.bump("urn:ump:s3");
|
|
const s = al.stats();
|
|
assert("stats: unique_urns=3", s.unique_urns === 3, `got ${s.unique_urns}`);
|
|
assert("stats: total_accesses=4", s.total_accesses === 4, `got ${s.total_accesses}`);
|
|
assert("stats: accessed_within_7d=3 (all just bumped)", s.accessed_within_7d === 3);
|
|
assert("stats: file path set", s.file && s.file.length > 0);
|
|
}
|
|
|
|
console.log(`\n${passed}/${passed + failed} passed`);
|
|
process.exit(failed > 0 ? 1 : 0); |