// node modules/test/test-memory-graph.js // test-memory-graph: five confirmed defects in module-memory-graph.js. // // T1 addEdge() averaged weights, so every repeat of a strong edge pulled it down toward the // constant its caller passed. A relation that was used once at 0.9 and then observed at // 0.5 decayed to 0.5 for being used. Evidence must be monotonic: stronger wins. // T2 recordTask() keyed task nodes on Date.now(), so re-submitting the same task created a // brand new node every millisecond instead of reinforcing the existing one. // T3 recordFileAccess() recorded the graph's own store (/.memory_graph/*) as user files, // so the graph observed itself and grew every time it saved. // T4 failed tool calls were persisted at weight 0.8 and successes at 0.5, which made // showStats() rank failures as the strongest relationships in the graph. // T5 concept ids were built from a whitespace-only split, so ids carried punctuation // ("concept:parser," ) and never matched an id built from the same word elsewhere. // // Set MEMORY_GRAPH_PATH to test a copy of the module (used to prove the test is RED on the // pre-fix file). prune() is deliberately untouched and T6.5 pins that. const fs = require('fs'), path = require('path'); const ROOT = path.join(__dirname, '..', 'core'); const MG_FILE = process.env.MEMORY_GRAPH_PATH || path.join(ROOT, 'module-memory-graph.js'); const store = {}; global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; global.window = global; const load = (file, cls) => new Function(fs.readFileSync(file, 'utf8') + '\nreturn ' + cls + ';')(); let VFS, MG; try { VFS = load(path.join(ROOT, 'virtual-fs.js'), 'VirtualFileSystem'); MG = load(MG_FILE, 'MemoryGraphModule'); } catch (e) { console.log('FAIL could not load module — ' + e.message); process.exit(1); } if (typeof MG !== 'function') { console.log('FAIL module-memory-graph.js did not expose MemoryGraphModule (got ' + typeof MG + ')'); process.exit(1); } let fails = 0, passes = 0; function ok(name, cond, detail) { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); } function mk() { const out = []; const vfs = new VFS(); vfs.saveToStorage = () => true; const term = { print: (m, t) => out.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs }; const mg = new MG(); mg.init(term); return { mg, vfs, out, term }; } const text = arr => arr.map(o => o.text).join('\n'); // Legacy task ids are Date.now()-based: make sure two calls land in different milliseconds so // the RED run cannot pass by the two calls sharing one tick. const tick = ms => { const t = Date.now(); while (Date.now() - t < ms) { /* spin */ } }; const iso = new Date('2024-01-05T10:00:00.000Z').toISOString(); const sessionOf = d => 'session-session-' + Math.floor(new Date(d).getTime() / 86400000); // ─── T1: addEdge must not erode a strong edge ─── { const { mg } = mk(); mg.addEdge('a', 'b', 'used', 0.9); mg.addEdge('a', 'b', 'used', 0.5); mg.addEdge('a', 'b', 'used', 0.5); const e = mg._edges[0]; ok('T1.1 a strong edge is not eroded by repeated weak observations', !!e && e.weight === 0.9, 'weight ' + (e && e.weight)); ok('T1.2 repeats still count as use', !!e && e.use_count === 3, 'use_count ' + (e && e.use_count)); ok('T1.3 repeats do not duplicate the edge', mg._edges.length === 1, 'edges ' + mg._edges.length); mg.addEdge('a', 'b', 'used', 1); ok('T1.4 stronger evidence still raises the weight', e.weight === 1, 'weight ' + e.weight); } // ─── T2: task ids are content-derived, so identical tasks merge ─── { const { mg } = mk(); const TASK = 'Read the parser and fix the lexer bug'; mg.recordTask(TASK, 'ok', 'agent-a'); tick(3); mg.recordTask(TASK, 'ok', 'agent-a'); const tasks = () => Object.values(mg._nodes).filter(n => n.type === 'task'); ok('T2.1 identical re-submits collapse to one task node', tasks().length === 1, 'task nodes: ' + tasks().map(t => t.id).join(',')); ok('T2.2 the merged task records both uses', tasks().length === 1 && tasks()[0].access_count === 2, 'access_count ' + (tasks()[0] || {}).access_count); const ex = mg._edges.filter(e => e.type === 'executed'); ok('T2.3 one executed edge, reinforced', ex.length === 1 && ex[0].use_count === 2, 'edges ' + ex.length + ' use_count ' + (ex[0] || {}).use_count); tick(3); mg.recordTask(TASK, 'ok', 'agent-b'); ok('T2.4 a different agent keeps its own task node', tasks().length === 2, 'task nodes: ' + tasks().map(t => t.id).join(',')); tick(3); mg.recordTask('Deploy the release notes', 'ok', 'agent-a'); ok('T2.5 different text gives a different task id', tasks().length === 3, 'task nodes: ' + tasks().length); tick(3); mg.recordTask(' read the PARSER and fix the lexer bug ', 'ok', 'agent-a'); ok('T2.6 whitespace and case variants still dedupe', tasks().length === 3, 'task nodes: ' + tasks().map(t => t.label).join(' | ')); } // ─── T3: the graph must not record its own store ─── { const { mg } = mk(); const nodesBefore = Object.keys(mg._nodes).length; const edgesBefore = mg._edges.length; mg.recordFileAccess('/.memory_graph/nodes.json'); mg.recordFileAccess('/.memory_graph/edges.json', 'agent-a'); mg.recordFileAccess('/.memory_graph/'); mg.recordFileAccess('.memory_graph/export.json'); const selfFiles = Object.keys(mg._nodes).filter(k => k.indexOf('.memory_graph') !== -1); ok('T3.1 store paths are not recorded as file nodes', selfFiles.length === 0, selfFiles.join(',')); ok('T3.2 no extra nodes from the store', Object.keys(mg._nodes).length === nodesBefore, 'before ' + nodesBefore + ' after ' + Object.keys(mg._nodes).length); ok('T3.3 no extra edges from the store', mg._edges.length === edgesBefore, 'before ' + edgesBefore + ' after ' + mg._edges.length); mg.recordFileAccess('/src/app.js'); ok('T3.4 ordinary paths are still recorded', !!mg.getNode('file', '/src/app.js'), Object.keys(mg._nodes).join(',')); ok('T3.5 an ordinary path still gets its accessed edge', mg._edges.some(e => e.type === 'accessed' && e.target === 'file:/src/app.js')); } // ─── T4: a failure must not outrank a success ─── { const { mg, out } = mk(); mg.recordToolCall('read_file', true); mg.recordToolCall('write_file', false); const used = mg._edges.find(e => e.type === 'used'); const failed = mg._edges.find(e => e.type === 'failed'); ok('T4.1 both outcomes are recorded', !!used && !!failed, mg._edges.map(e => e.type + ':' + e.weight).join(',')); ok('T4.2 a failed tool call is weaker than a successful one', !!used && !!failed && failed.weight < used.weight, 'used ' + (used || {}).weight + ' failed ' + (failed || {}).weight); ok('T4.3 success keeps its weight', !!used && used.weight === 0.5, String((used || {}).weight)); const mark = out.length; mg.showStats(); const stats = out.slice(mark).map(o => o.text).join('\n'); const lines = stats.split('\n'); const at = lines.findIndex(l => l.indexOf('Strongest relationships') !== -1); const strong = at === -1 ? [] : lines.slice(at + 1, at + 4).filter(Boolean); ok('T4.4 showStats still lists strongest relationships', strong.length >= 2, stats.slice(0, 300)); ok('T4.5 the failure is not ranked above the success', strong.length >= 2 && strong[0].indexOf('used') !== -1 && strong[1].indexOf('failed') !== -1, strong.join(' | ')); } // ─── T5: concept ids are slugged ─── { const { mg } = mk(); mg.recordTask('Refactor: the Parser (v2!), then ship it.', 'done', 'agent-a'); const concepts = Object.values(mg._nodes).filter(n => n.type === 'concept'); ok('T5.1 task keywords become concept nodes', concepts.length > 0, 'none'); ok('T5.2 concept ids carry no punctuation', concepts.every(c => /^concept:[a-z0-9]+(-[a-z0-9]+)*$/.test(c.id)), concepts.map(c => c.id).join(',')); const involves = mg._edges.filter(e => e.type === 'involves'); ok('T5.3 every involves edge points at a real concept node', involves.length > 0 && involves.every(e => !!mg._nodes[e.target]), involves.map(e => e.target).join(',')); mg.recordConcept('Neural VFS, v2!', '/src/nvfs.js'); ok('T5.4 recordConcept slugifies its id', !!mg.getNode('concept', 'neural-vfs-v2'), Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0).join(',')); ok('T5.5 the concept→file edge uses the slugged id', mg._edges.some(e => e.type === 'maps_to' && e.source === 'concept:neural-vfs-v2')); } // ─── T6: graphs already stored keep loading, and prune() is untouched ─── { const { vfs } = mk(); const recent = new Date().toISOString(); const legacyNodes = { 'session:session-legacy': { id: 'session:session-legacy', type: 'session', label: 'Legacy session', metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' }, 'task:task-1700000000000': { id: 'task:task-1700000000000', type: 'task', label: 'legacy task', metadata: { full: 'legacy task' }, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' }, 'concept:orphan': { id: 'concept:orphan', type: 'concept', label: 'orphan', metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' } }; const legacyEdges = [ { source: 'session:session-legacy', target: 'task:task-1700000000000', type: 'executed', weight: 0.6, created: iso, last_used: recent, use_count: 1, source_agents: [] }, { source: 'session:session-legacy', target: 'tool:write_file', type: 'failed', weight: 0.8, created: iso, last_used: recent, use_count: 1, source_agents: ['agent-a'] } ]; vfs.mkdirRecursive('/.memory_graph'); vfs.fsWriteJSON('/.memory_graph/nodes.json', legacyNodes); vfs.fsWriteJSON('/.memory_graph/edges.json', legacyEdges); const out2 = []; const mg2 = new MG(); mg2.init({ print: (m, t) => out2.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs }); ok('T6.1 a legacy graph still loads', Object.keys(mg2._nodes).length >= 4, 'nodes ' + Object.keys(mg2._nodes).length); ok('T6.2 legacy Date.now() task ids are still addressable', !!mg2.getNode('task', 'task-1700000000000')); ok('T6.3 legacy edges keep their shape', mg2._edges.every(e => Array.isArray(e.source_agents) && typeof e.weight === 'number'), JSON.stringify(mg2._edges)); const legFailed = mg2._edges.find(e => e.type === 'failed'); // The cap is applied at READ time, not by rewriting the store: a load must not mutate // user data (the next debounced save would have made it permanent, silently). ok('T6.4a a legacy 0.8 failure is NOT rewritten on load', !!legFailed && legFailed.weight === 0.8, 'stored weight ' + (legFailed || {}).weight); ok('T6.4b it ranks at 0.2 through _effectiveWeight', !!legFailed && mg2._effectiveWeight(legFailed) === 0.2, 'effective ' + (legFailed && mg2._effectiveWeight(legFailed))); ok('T6.4c a success edge keeps its own weight', mg2._effectiveWeight({ type: 'used', weight: 0.5 }) === 0.5, String(mg2._effectiveWeight({ type: 'used', weight: 0.5 }))); ok('T6.5 unrelated legacy weights are untouched', (mg2._edges.find(e => e.type === 'executed') || {}).weight === 0.6, String((mg2._edges.find(e => e.type === 'executed') || {}).weight)); // prune() is correct and must stay correct: it drops zero-degree nodes and keeps the rest. mg2.addNode('concept', 'second-orphan', 'second orphan'); const orphanPresent = !!mg2._nodes['concept:second-orphan']; mg2.prune(); ok('T6.6 prune still drops zero-degree nodes (prune untouched)', orphanPresent && !mg2._nodes['concept:second-orphan'], Object.keys(mg2._nodes).join(',')); ok('T6.7 prune keeps edges that are still in use', mg2._edges.length === 2, 'edges ' + mg2._edges.length); } // T7. slugging must not erase non-Latin scripts: an a-z0-9 slug turned a Cyrillic task // into '' (no concept nodes at all) and German 'Ärger' into 'rger'. { const { mg } = mk(); ok('T7.1 cyrillic slug is not empty', mg._slug('Исправить парсер ошибок') === 'исправить-парсер-ошибок', mg._slug('Исправить парсер ошибок')); ok('T7.2 cjk slug is not empty', mg._slug('修复解析器的错误') === '修复解析器的错误', mg._slug('修复解析器的错误')); ok('T7.3 accents survive', mg._slug('Ärger mit Größe') === 'ärger-mit-größe', mg._slug('Ärger mit Größe')); ok('T7.4 ascii is unchanged', mg._slug('Neural VFS, v2!') === 'neural-vfs-v2', mg._slug('Neural VFS, v2!')); mg.recordTask('Исправить парсер ошибок', 'done', 'agent-ru'); const cyr = Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0); ok('T7.5 a cyrillic task still yields concept nodes', cyr.length > 0, JSON.stringify(cyr.slice(0, 3))); } console.log('test-memory-graph: ' + passes + ' passed, ' + fails + ' failed'); process.exit(fails ? 1 : 0);