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.
320 lines
8.1 KiB
JavaScript
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);
|