aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-memory-graph.js
blob: d4fe37a95fab72c631423807613ab2e4f3da2a71 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
// 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');
    ok('T6.4 a legacy 0.8 failure is capped below success on load',
        !!legFailed && legFailed.weight < 0.5, 'weight ' + (legFailed || {}).weight);
    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);