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

320 lines
8.1 KiB
JavaScript

// Phase 2A graph store test suite.
// Run: node test/test_graph.js
// Exits 0 on full pass, 1 on any failure.
//
// Style mirrors test/test-entities.js.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Graph } from "../src/graph.js";
import { extractEntities } from "../src/entities.js";
// Pre-flight: lock the extractor output we expect, so the test stays
// robust if the corpus drifts. We mirror test-entities.js's contract.
const extractorCases = [
{
name: "machine + product + tailscale ip",
in: "DNS2 runs Triangles daemon on 100.121.150.22",
want: ["DNS2", "Triangles", "100.121.150.22"],
},
{
name: "PR code + Phase code",
in: "PR-30 fixed Phase 4 of the plan",
want: ["PR-30", "Phase 4"],
},
{
name: "explicit arrow relations yield source+target entities",
in: "DashCaddy → Caddy → nftables",
want: ["DashCaddy", "Caddy", "nftables"],
},
{
name: "Hermes + krystie (profile) + MCP all-caps token",
in: "Hermes (krystie profile) uses MCP stdio",
want: ["Hermes", "krystie", "MCP"],
},
];
// A small in-memory fixture built from entities the EXTRACTOR actually
// recognizes (see src/entities.js). The extractor's product list does NOT
// include "Ollama" or "Qdrant" (those are inferred, not literal), so we
// anchor the fixture on the real entity vocabulary: DNS2/DNS3, DashCaddy,
// Caddy, nftables, Triangles, Hermes, Krystie, MCP, PR-30, Phase 4.
const FIXTURE = [
{
id: "urn:test:1",
lifecycle: { status: "active" },
body: {
text: "DNS2 runs DashCaddy on 100.121.150.22. DashCaddy -> Caddy.",
subject: "DNS2 runs DashCaddy on 100.121.150.22",
},
},
{
id: "urn:test:2",
lifecycle: { status: "active" },
body: {
text: "Caddy runs on DNS2. Caddy uses nftables for fire-walling.",
subject: "Caddy runs on DNS2",
},
},
{
id: "urn:test:3",
lifecycle: { status: "active" },
body: {
text: "Hermes (krystie) uses DashCaddy. Hermes -> MCP stdio.",
subject: "Hermes uses DashCaddy",
},
},
{
id: "urn:test:4",
lifecycle: { status: "active" },
body: {
text: "PR-30 shipped Phase 2. DashCaddy -> Triangles for payments.",
subject: "PR-30 shipped Phase 2",
},
},
{
id: "urn:test:5",
lifecycle: { status: "active" },
body: {
text: "DNS3 runs Triangles. DNS3 depends on DNS2 for replication.",
subject: "DNS3 runs Triangles",
},
},
{
id: "urn:test:6",
lifecycle: { status: "deleted" }, // must be skipped
body: {
text: "DNS2 ghost record",
subject: "DNS2 ghost record",
},
},
{
id: "urn:test:7",
lifecycle: { status: "tombstone" }, // must be skipped
body: {
text: "DNS2 tombstone",
subject: "DNS2 tombstone",
},
},
];
let pass = 0;
let fail = 0;
function ok(cond, name, detail) {
if (cond) {
pass++;
console.log(`PASS ${name}`);
} else {
fail++;
console.log(`FAIL ${name}`);
if (detail) console.log(` ${detail}`);
}
}
function expect(label, actual, predicate) {
if (predicate(actual)) {
pass++;
console.log(`PASS ${label}`);
} else {
fail++;
console.log(`FAIL ${label}`);
console.log(` actual=${JSON.stringify(actual)}`);
}
}
// ---------- extractor contract ----------
console.log("---- extractor contract (smoke) ----");
for (const c of extractorCases) {
const got = extractEntities(c.in);
ok(
JSON.stringify(got) === JSON.stringify(c.want),
`extract: ${c.name}`,
`got=${JSON.stringify(got)} want=${JSON.stringify(c.want)}`
);
}
// ---------- fixture build ----------
console.log("\n---- fixture build ----");
const g = new Graph();
g.buildFromRecords(FIXTURE);
expect(
"node DNS2 present",
g.nodes.get("DNS2"),
(n) => n && n.frequency >= 3 // 1,2,5 each contribute + URLs in 1
);
expect(
"node DashCaddy present",
g.nodes.get("DashCaddy"),
(n) => n && n.frequency >= 2 // records 1,3 (and possibly 4 as relation source)
);
expect(
"node Caddy present",
g.nodes.get("Caddy"),
(n) => n && n.frequency >= 2 // records 1,2
);
expect(
"edge DashCaddy -> Caddy (relates-to) from record 1",
[...g.edges.values()].find(
(e) => e.src === "DashCaddy" && e.tgt === "Caddy" && e.type === "relates-to"
),
(e) => !!e && e.weight >= 1
);
expect(
"edge Caddy located-on DNS2 (runs on = located-on)",
[...g.edges.values()].find(
(e) =>
(e.src === "Caddy" && e.tgt === "DNS2" && e.type === "located-on") ||
(e.src === "DNS2" && e.tgt === "Caddy" && e.type === "located-on")
),
(e) => !!e
);
expect(
"edge DNS3 depends-on DNS2",
[...g.edges.values()].find(
(e) => e.src === "DNS3" && e.tgt === "DNS2" && e.type === "depends-on"
),
(e) => !!e
);
expect(
"deleted record skipped (urn:test:6 not present)",
g.stats().urns,
(u) => u === 5
);
expect(
"tombstone record skipped (urn:test:7 not present)",
g.nodes.get("DNS2")?.urns.has("urn:test:7"),
(v) => v === false
);
// ---------- query API ----------
console.log("\n---- query API ----");
const nDns2 = g.neighbors("DNS2", 2);
expect(
"neighbors('DNS2', 2) returns >=1 URN",
[...nDns2.entries()],
(arr) => arr.length >= 1
);
const nDashCaddy = g.neighbors("DashCaddy", 1);
expect(
"neighbors('DashCaddy', 1) returns >=1 URN (Caddy is direct)",
[...nDashCaddy.entries()],
(arr) => arr.length >= 1
);
expect(
"neighbors on unknown entity returns empty Map",
g.neighbors("NotPresent", 2).size,
(s) => s === 0
);
const dashSearch = g.searchEntities("Dash", 5);
expect(
"searchEntities('Dash', 5) finds DashCaddy",
dashSearch.find((x) => x.entity === "DashCaddy"),
(v) => !!v
);
const mcpSearch = g.searchEntities("MCP", 5);
expect(
"searchEntities('MCP', 5) finds MCP",
mcpSearch.find((x) => x.entity === "MCP"),
(v) => !!v
);
const emptySearch = g.searchEntities("__no_such_token__", 5);
expect(
"searchEntities with no matches returns []",
emptySearch,
(v) => v.length === 0
);
// ---------- persistence roundtrip ----------
console.log("\n---- persistence roundtrip ----");
// Use the LIVE GRAPH_FILE (default). Back it up first so the test is
// non-destructive against /root/ump-recall/state/graph.json (which the
// build script just produced). We back up -> save fixture -> load ->
// restore from backup -> remove tmp dir.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "graph-test-"));
const liveFile = "/root/ump-recall/state/graph.json";
let backedUp = null;
try {
if (fs.existsSync(liveFile)) {
backedUp = fs.readFileSync(liveFile);
}
} catch (_) {}
const saveRes = await g.save(); // writes to module's GRAPH_FILE (the live one)
expect(
"save() wrote file",
fs.existsSync(saveRes.file),
(v) => v === true
);
const g2 = new Graph();
const loaded = await g2.load();
expect("load() returned true on existing file", loaded, (v) => v === true);
expect(
"roundtrip preserves node count",
g2.nodes.size,
(s) => s === g.nodes.size
);
expect(
"roundtrip preserves edge count",
g2.edges.size,
(s) => s === g.edges.size
);
expect(
"roundtrip preserves URN count",
g2._countUniqueUrns(),
(u) => u === g._countUniqueUrns()
);
expect(
"roundtrip preserves DNS2 frequency",
g2.nodes.get("DNS2")?.frequency,
(f) => f === g.nodes.get("DNS2")?.frequency
);
// Exercise searchEntities on the reloaded graph.
const reloadedSearch = g2.searchEntities("DNS");
expect(
"reloaded graph still finds DNS-prefix entities",
reloadedSearch.find((x) => x.entity === "DNS2"),
(v) => !!v
);
// ---------- top-N reporting ----------
console.log("\n---- reporting helpers ----");
const topE = g.topEntities(5);
expect(
"topEntities(5) returns at most 5 entries",
topE.length,
(n) => n <= 5 && n >= 1
);
const topX = g.topEdges(5);
expect("topEdges returns array", Array.isArray(topX), (v) => v === true);
// Cleanup tmp dir.
fs.rmSync(tmpDir, { recursive: true, force: true });
// Restore the live graph.json from backup (the test overwrote it with the
// 13-node fixture graph; this restores the production state).
if (backedUp) {
fs.writeFileSync(liveFile, backedUp);
}
console.log(`\n${pass}/${pass + fail} passed`);
if (fail > 0) process.exit(1);