@levalicious/server-memory 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -28
- package/dist/scripts/delete-document.js +91 -0
- package/dist/scripts/textrank-experiment.js +618 -0
- package/dist/server.js +127 -59
- package/dist/src/graphfile.js +118 -4
- package/dist/src/kb_load.js +396 -0
- package/dist/src/memoryfile.js +17 -0
- package/dist/src/merw.js +160 -0
- package/dist/src/stringtable.js +24 -6
- package/dist/tests/memory-server.test.js +129 -0
- package/dist/tests/test-utils.js +6 -0
- package/package.json +6 -2
package/dist/server.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { randomBytes } from 'crypto';
|
|
5
|
+
import fs from 'fs';
|
|
5
6
|
import path from 'path';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import { GraphFile, DIR_FORWARD, DIR_BACKWARD } from './src/graphfile.js';
|
|
8
9
|
import { StringTable } from './src/stringtable.js';
|
|
9
10
|
import { structuralSample } from './src/pagerank.js';
|
|
11
|
+
import { computeMerwPsi } from './src/merw.js';
|
|
12
|
+
import { validateExtension, loadDocument } from './src/kb_load.js';
|
|
10
13
|
// Define memory file path using environment variable with fallback
|
|
11
14
|
const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
|
|
12
15
|
// If MEMORY_FILE_PATH is just a filename, put it in the same directory as the script
|
|
@@ -104,7 +107,7 @@ function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
|
|
|
104
107
|
let i = cursor;
|
|
105
108
|
// Calculate overhead for wrapper: {"items":[],"nextCursor":null,"totalCount":123}
|
|
106
109
|
const wrapperTemplate = { items: [], nextCursor: null, totalCount: items.length };
|
|
107
|
-
|
|
110
|
+
const overhead = JSON.stringify(wrapperTemplate).length;
|
|
108
111
|
let charCount = overhead;
|
|
109
112
|
while (i < items.length) {
|
|
110
113
|
const itemJson = JSON.stringify(items[i]);
|
|
@@ -125,54 +128,13 @@ function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
|
|
|
125
128
|
};
|
|
126
129
|
}
|
|
127
130
|
function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
const emptyResult = {
|
|
133
|
-
entities: { items: [], nextCursor: null, totalCount: entityCount },
|
|
134
|
-
relations: { items: [], nextCursor: null, totalCount: relationCount }
|
|
135
|
-
};
|
|
136
|
-
let currentSize = JSON.stringify(emptyResult).length;
|
|
137
|
-
const resultEntities = [];
|
|
138
|
-
const resultRelations = [];
|
|
139
|
-
let entityIdx = entityCursor;
|
|
140
|
-
let relationIdx = relationCursor;
|
|
141
|
-
// Add entities until we hit the limit
|
|
142
|
-
while (entityIdx < graph.entities.length) {
|
|
143
|
-
const entity = graph.entities[entityIdx];
|
|
144
|
-
const entityJson = JSON.stringify(entity);
|
|
145
|
-
const addedChars = entityJson.length + (resultEntities.length > 0 ? 1 : 0);
|
|
146
|
-
if (currentSize + addedChars > MAX_CHARS) {
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
resultEntities.push(entity);
|
|
150
|
-
currentSize += addedChars;
|
|
151
|
-
entityIdx++;
|
|
152
|
-
}
|
|
153
|
-
// Add relations with remaining space
|
|
154
|
-
while (relationIdx < graph.relations.length) {
|
|
155
|
-
const relation = graph.relations[relationIdx];
|
|
156
|
-
const relationJson = JSON.stringify(relation);
|
|
157
|
-
const addedChars = relationJson.length + (resultRelations.length > 0 ? 1 : 0);
|
|
158
|
-
if (currentSize + addedChars > MAX_CHARS) {
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
resultRelations.push(relation);
|
|
162
|
-
currentSize += addedChars;
|
|
163
|
-
relationIdx++;
|
|
164
|
-
}
|
|
131
|
+
// Entities and relations have independent cursors, so paginate them
|
|
132
|
+
// independently — each gets the full budget. The caller already has
|
|
133
|
+
// previously-returned pages and only needs the next page of whichever
|
|
134
|
+
// section it is advancing.
|
|
165
135
|
return {
|
|
166
|
-
entities:
|
|
167
|
-
|
|
168
|
-
nextCursor: entityIdx < graph.entities.length ? entityIdx : null,
|
|
169
|
-
totalCount: entityCount
|
|
170
|
-
},
|
|
171
|
-
relations: {
|
|
172
|
-
items: resultRelations,
|
|
173
|
-
nextCursor: relationIdx < graph.relations.length ? relationIdx : null,
|
|
174
|
-
totalCount: relationCount
|
|
175
|
-
}
|
|
136
|
+
entities: paginateItems(graph.entities, entityCursor),
|
|
137
|
+
relations: paginateItems(graph.relations, relationCursor),
|
|
176
138
|
};
|
|
177
139
|
}
|
|
178
140
|
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
|
@@ -193,12 +155,20 @@ export class KnowledgeGraphManager {
|
|
|
193
155
|
this.gf = new GraphFile(graphPath, this.st);
|
|
194
156
|
this.nameIndex = new Map();
|
|
195
157
|
this.rebuildNameIndex();
|
|
196
|
-
// Run initial structural sampling if graph is non-empty
|
|
158
|
+
// Run initial structural sampling and MERW if graph is non-empty
|
|
197
159
|
if (this.nameIndex.size > 0) {
|
|
198
160
|
structuralSample(this.gf, 1, 0.85);
|
|
161
|
+
computeMerwPsi(this.gf);
|
|
199
162
|
this.gf.sync();
|
|
200
163
|
}
|
|
201
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Run the loadDocument pipeline under a shared (read) lock,
|
|
167
|
+
* so the StringTable is consistent during IDF derivation.
|
|
168
|
+
*/
|
|
169
|
+
prepareDocumentLoad(text, title, topK) {
|
|
170
|
+
return this.withReadLock(() => loadDocument(text, title, this.st, topK));
|
|
171
|
+
}
|
|
202
172
|
// --- Locking helpers ---
|
|
203
173
|
/**
|
|
204
174
|
* Acquire a shared (read) lock, refresh mappings (in case another process
|
|
@@ -288,11 +258,12 @@ export class KnowledgeGraphManager {
|
|
|
288
258
|
}
|
|
289
259
|
});
|
|
290
260
|
}
|
|
291
|
-
/** Re-run structural sampling (call after graph mutations) */
|
|
261
|
+
/** Re-run structural sampling and MERW eigenvector computation (call after graph mutations) */
|
|
292
262
|
resample() {
|
|
293
263
|
this.withWriteLock(() => {
|
|
294
264
|
if (this.nameIndex.size > 0) {
|
|
295
265
|
structuralSample(this.gf, 1, 0.85);
|
|
266
|
+
computeMerwPsi(this.gf);
|
|
296
267
|
}
|
|
297
268
|
});
|
|
298
269
|
}
|
|
@@ -528,7 +499,7 @@ export class KnowledgeGraphManager {
|
|
|
528
499
|
try {
|
|
529
500
|
regex = new RegExp(query, 'i');
|
|
530
501
|
}
|
|
531
|
-
catch
|
|
502
|
+
catch {
|
|
532
503
|
throw new Error(`Invalid regex pattern: ${query}`);
|
|
533
504
|
}
|
|
534
505
|
return this.withReadLock(() => {
|
|
@@ -818,7 +789,7 @@ export class KnowledgeGraphManager {
|
|
|
818
789
|
if (!offset)
|
|
819
790
|
break;
|
|
820
791
|
const edges = this.gf.getEdges(offset);
|
|
821
|
-
const
|
|
792
|
+
const candidates = [];
|
|
822
793
|
for (const edge of edges) {
|
|
823
794
|
if (direction === 'forward' && edge.direction !== DIR_FORWARD)
|
|
824
795
|
continue;
|
|
@@ -826,14 +797,46 @@ export class KnowledgeGraphManager {
|
|
|
826
797
|
continue;
|
|
827
798
|
const targetRec = this.gf.readEntity(edge.targetOffset);
|
|
828
799
|
const neighborName = this.st.get(BigInt(targetRec.nameId));
|
|
829
|
-
if (neighborName !== current)
|
|
830
|
-
|
|
800
|
+
if (neighborName !== current && this.nameIndex.has(neighborName)) {
|
|
801
|
+
candidates.push({ name: neighborName, psi: targetRec.psi });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Deduplicate: keep max psi per name (multiple edge types to same target)
|
|
805
|
+
const byName = new Map();
|
|
806
|
+
for (const c of candidates) {
|
|
807
|
+
const existing = byName.get(c.name);
|
|
808
|
+
if (existing === undefined || c.psi > existing) {
|
|
809
|
+
byName.set(c.name, c.psi);
|
|
810
|
+
}
|
|
831
811
|
}
|
|
832
|
-
|
|
833
|
-
if (neighborArr.length === 0)
|
|
812
|
+
if (byName.size === 0)
|
|
834
813
|
break;
|
|
835
|
-
const
|
|
836
|
-
|
|
814
|
+
const neighborArr = Array.from(byName.entries());
|
|
815
|
+
// MERW-weighted sampling: probability proportional to ψ_j
|
|
816
|
+
// (The ψ_i denominator is constant for all neighbors and cancels in normalization)
|
|
817
|
+
let totalPsi = 0;
|
|
818
|
+
for (const [, psi] of neighborArr)
|
|
819
|
+
totalPsi += psi;
|
|
820
|
+
let chosen;
|
|
821
|
+
if (totalPsi > 0) {
|
|
822
|
+
// Weighted sampling by psi
|
|
823
|
+
const r = random() * totalPsi;
|
|
824
|
+
let cumulative = 0;
|
|
825
|
+
chosen = neighborArr[neighborArr.length - 1][0]; // fallback
|
|
826
|
+
for (const [name, psi] of neighborArr) {
|
|
827
|
+
cumulative += psi;
|
|
828
|
+
if (r <= cumulative) {
|
|
829
|
+
chosen = name;
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
// psi not yet computed (all zero) — fall back to uniform
|
|
836
|
+
const idx = Math.floor(random() * neighborArr.length);
|
|
837
|
+
chosen = neighborArr[idx][0];
|
|
838
|
+
}
|
|
839
|
+
current = chosen;
|
|
837
840
|
pathNames.push(current);
|
|
838
841
|
}
|
|
839
842
|
return { entity: current, path: pathNames };
|
|
@@ -950,7 +953,7 @@ export function createServer(memoryFilePath) {
|
|
|
950
953
|
sizes: ["any"]
|
|
951
954
|
}
|
|
952
955
|
],
|
|
953
|
-
version: "0.0.
|
|
956
|
+
version: "0.0.15",
|
|
954
957
|
}, {
|
|
955
958
|
capabilities: {
|
|
956
959
|
tools: {},
|
|
@@ -1271,6 +1274,30 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
|
|
|
1271
1274
|
required: ["observations"],
|
|
1272
1275
|
},
|
|
1273
1276
|
},
|
|
1277
|
+
{
|
|
1278
|
+
name: "kb_load",
|
|
1279
|
+
description: `Load a plaintext document into the knowledge graph. Chunks the text into entities connected by a doubly-linked chain, runs sentence TextRank to identify the most important sentences, and creates an index entity that links directly to the chunks containing those sentences.
|
|
1280
|
+
|
|
1281
|
+
The file MUST be plaintext (.txt, .tex, .md, source code, etc.). For PDFs, use pdftotext first. For other binary formats, convert to text before calling this tool.`,
|
|
1282
|
+
inputSchema: {
|
|
1283
|
+
type: "object",
|
|
1284
|
+
properties: {
|
|
1285
|
+
filePath: {
|
|
1286
|
+
type: "string",
|
|
1287
|
+
description: "Absolute path to the plaintext file to load. Must have a plaintext extension (.txt, .tex, .md, .py, .ts, etc.).",
|
|
1288
|
+
},
|
|
1289
|
+
title: {
|
|
1290
|
+
type: "string",
|
|
1291
|
+
description: "Optional title for the document entity. Defaults to the filename without extension.",
|
|
1292
|
+
},
|
|
1293
|
+
topK: {
|
|
1294
|
+
type: "number",
|
|
1295
|
+
description: "Number of top-ranked sentences to highlight in the index (default: 15).",
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
required: ["filePath"],
|
|
1299
|
+
},
|
|
1300
|
+
},
|
|
1274
1301
|
],
|
|
1275
1302
|
};
|
|
1276
1303
|
});
|
|
@@ -1351,6 +1378,47 @@ Use this to build chains of reasoning that persist in the graph. Each thought ca
|
|
|
1351
1378
|
const result = await knowledgeGraphManager.addThought(args.observations, args.previousCtxId);
|
|
1352
1379
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1353
1380
|
}
|
|
1381
|
+
case "kb_load": {
|
|
1382
|
+
const filePath = args.filePath;
|
|
1383
|
+
// Validate extension
|
|
1384
|
+
validateExtension(filePath);
|
|
1385
|
+
// Read file
|
|
1386
|
+
let text;
|
|
1387
|
+
try {
|
|
1388
|
+
text = fs.readFileSync(filePath, 'utf-8');
|
|
1389
|
+
}
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
throw new Error(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
1392
|
+
}
|
|
1393
|
+
// Derive title
|
|
1394
|
+
const title = args.title ?? path.basename(filePath, path.extname(filePath));
|
|
1395
|
+
const topK = args.topK ?? 15;
|
|
1396
|
+
// Run the pipeline (reads string table under read lock)
|
|
1397
|
+
const loadResult = knowledgeGraphManager.prepareDocumentLoad(text, title, topK);
|
|
1398
|
+
// Insert into KB
|
|
1399
|
+
const entities = await knowledgeGraphManager.createEntities(loadResult.entities.map(e => ({
|
|
1400
|
+
name: e.name,
|
|
1401
|
+
entityType: e.entityType,
|
|
1402
|
+
observations: e.observations,
|
|
1403
|
+
})));
|
|
1404
|
+
const relations = await knowledgeGraphManager.createRelations(loadResult.relations.map(r => ({
|
|
1405
|
+
from: r.from,
|
|
1406
|
+
to: r.to,
|
|
1407
|
+
relationType: r.relationType,
|
|
1408
|
+
})));
|
|
1409
|
+
knowledgeGraphManager.resample();
|
|
1410
|
+
return {
|
|
1411
|
+
content: [{
|
|
1412
|
+
type: "text",
|
|
1413
|
+
text: JSON.stringify({
|
|
1414
|
+
document: title,
|
|
1415
|
+
stats: loadResult.stats,
|
|
1416
|
+
entitiesCreated: entities.length,
|
|
1417
|
+
relationsCreated: relations.length,
|
|
1418
|
+
}, null, 2),
|
|
1419
|
+
}],
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1354
1422
|
default:
|
|
1355
1423
|
throw new Error(`Unknown tool: ${name}`);
|
|
1356
1424
|
}
|
package/dist/src/graphfile.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* All records live in a MemoryFile (graph.mem). Variable-length strings are
|
|
5
5
|
* stored in a separate StringTable (strings.mem) and referenced by u32 ID.
|
|
6
6
|
*
|
|
7
|
+
* Versioning:
|
|
8
|
+
* MEMFILE_VERSION 1 = original 64-byte entity records (no psi field)
|
|
9
|
+
* MEMFILE_VERSION 2 = 72-byte entity records with f64 psi for MERW
|
|
10
|
+
* On open, version 1 files are migrated to version 2 automatically.
|
|
11
|
+
*
|
|
7
12
|
* Graph file layout:
|
|
8
13
|
* [memfile header: 32 bytes]
|
|
9
14
|
* [graph header block: first allocation]
|
|
@@ -12,7 +17,7 @@
|
|
|
12
17
|
* u64 walker_total total walker visits (global counter)
|
|
13
18
|
* [entity records, adj blocks, node log ...]
|
|
14
19
|
*
|
|
15
|
-
* EntityRecord:
|
|
20
|
+
* EntityRecord: 72 bytes fixed (v2)
|
|
16
21
|
* u32 name_id string table ID
|
|
17
22
|
* u32 type_id string table ID
|
|
18
23
|
* u64 adj_offset offset to AdjBlock (0 = no edges)
|
|
@@ -24,6 +29,7 @@
|
|
|
24
29
|
* u32 obs1_id string table ID (0 = empty)
|
|
25
30
|
* u64 structural_visits structural PageRank visit count
|
|
26
31
|
* u64 walker_visits walker PageRank visit count
|
|
32
|
+
* f64 psi MERW dominant eigenvector component
|
|
27
33
|
*
|
|
28
34
|
* AdjBlock:
|
|
29
35
|
* u32 count
|
|
@@ -44,15 +50,21 @@
|
|
|
44
50
|
* u32 capacity
|
|
45
51
|
* u64 offsets[capacity]
|
|
46
52
|
*/
|
|
53
|
+
import * as fs from 'fs';
|
|
47
54
|
import { MemoryFile } from './memoryfile.js';
|
|
48
55
|
// --- Constants ---
|
|
49
|
-
export const ENTITY_RECORD_SIZE =
|
|
56
|
+
export const ENTITY_RECORD_SIZE = 72;
|
|
57
|
+
const OLD_ENTITY_RECORD_SIZE = 64; // v1 layout without psi
|
|
50
58
|
export const ADJ_ENTRY_SIZE = 24; // 8 + 4 + 4 + 8, naturally aligned
|
|
51
59
|
const ADJ_HEADER_SIZE = 8; // count:u32 + capacity:u32
|
|
52
60
|
const NODE_LOG_HEADER_SIZE = 8; // count:u32 + capacity:u32
|
|
53
61
|
const GRAPH_HEADER_SIZE = 24; // node_log_offset:u64 + structural_total:u64 + walker_total:u64
|
|
54
62
|
const INITIAL_ADJ_CAPACITY = 4;
|
|
55
63
|
const INITIAL_LOG_CAPACITY = 256;
|
|
64
|
+
// Graph-layer version stored in the memfile header version field
|
|
65
|
+
const GRAPH_VERSION_V1 = 1; // original 64-byte entity records
|
|
66
|
+
const GRAPH_VERSION_V2 = 2; // 72-byte entity records with f64 psi
|
|
67
|
+
export const CURRENT_GRAPH_VERSION = GRAPH_VERSION_V2;
|
|
56
68
|
// Direction flags
|
|
57
69
|
export const DIR_FORWARD = 0n;
|
|
58
70
|
export const DIR_BACKWARD = 1n;
|
|
@@ -72,7 +84,8 @@ const E_OBS1_ID = 40;
|
|
|
72
84
|
// 4 bytes pad at 44
|
|
73
85
|
const E_STRUCTURAL_VISITS = 48; // u64: 48..55, 8-aligned
|
|
74
86
|
const E_WALKER_VISITS = 56; // u64: 56..63, 8-aligned
|
|
75
|
-
|
|
87
|
+
const E_PSI = 64; // f64: 64..71, 8-aligned (MERW eigenvector component)
|
|
88
|
+
// total = 72
|
|
76
89
|
// AdjEntry field offsets (within each entry)
|
|
77
90
|
const AE_TARGET_DIR = 0;
|
|
78
91
|
const AE_RELTYPE_ID = 8;
|
|
@@ -107,6 +120,7 @@ export function readEntityRecord(mf, offset) {
|
|
|
107
120
|
obs1Id: buf.readUInt32LE(E_OBS1_ID),
|
|
108
121
|
structuralVisits: buf.readBigUInt64LE(E_STRUCTURAL_VISITS),
|
|
109
122
|
walkerVisits: buf.readBigUInt64LE(E_WALKER_VISITS),
|
|
123
|
+
psi: buf.readDoubleLE(E_PSI),
|
|
110
124
|
};
|
|
111
125
|
}
|
|
112
126
|
export function writeEntityRecord(mf, rec) {
|
|
@@ -121,6 +135,7 @@ export function writeEntityRecord(mf, rec) {
|
|
|
121
135
|
buf.writeUInt32LE(rec.obs1Id, E_OBS1_ID);
|
|
122
136
|
buf.writeBigUInt64LE(rec.structuralVisits, E_STRUCTURAL_VISITS);
|
|
123
137
|
buf.writeBigUInt64LE(rec.walkerVisits, E_WALKER_VISITS);
|
|
138
|
+
buf.writeDoubleLE(rec.psi, E_PSI);
|
|
124
139
|
mf.write(rec.offset, buf);
|
|
125
140
|
}
|
|
126
141
|
export function readAdjBlock(mf, adjOffset) {
|
|
@@ -174,12 +189,98 @@ export class GraphFile {
|
|
|
174
189
|
this.st = stringTable;
|
|
175
190
|
const stats = this.mf.stats();
|
|
176
191
|
if (stats.allocated <= 32n) {
|
|
192
|
+
// Fresh DB — initialize with current version
|
|
177
193
|
this.graphHeaderOffset = this.initGraphHeader();
|
|
194
|
+
this.mf.setVersion(CURRENT_GRAPH_VERSION);
|
|
178
195
|
}
|
|
179
196
|
else {
|
|
180
|
-
//
|
|
197
|
+
// Existing DB — check version and migrate if needed
|
|
181
198
|
this.graphHeaderOffset = 40n;
|
|
199
|
+
const version = this.mf.getVersion();
|
|
200
|
+
if (version === GRAPH_VERSION_V1) {
|
|
201
|
+
this.migrateV1toV2(graphPath);
|
|
202
|
+
}
|
|
203
|
+
else if (version !== CURRENT_GRAPH_VERSION) {
|
|
204
|
+
throw new Error(`GraphFile: unknown version ${version} (expected ${GRAPH_VERSION_V1} or ${CURRENT_GRAPH_VERSION})`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Migrate a v1 graph file (64-byte entity records) to v2 (72-byte with psi).
|
|
210
|
+
*
|
|
211
|
+
* Strategy: read all entities and edges from the current (v1) file into memory,
|
|
212
|
+
* close it, rename it to .v1, create a fresh file, write everything back with
|
|
213
|
+
* the new layout.
|
|
214
|
+
*/
|
|
215
|
+
migrateV1toV2(graphPath) {
|
|
216
|
+
// 1. Read all entities and adjacency data from v1 layout
|
|
217
|
+
const offsets = this.getAllEntityOffsets();
|
|
218
|
+
const oldEntities = [];
|
|
219
|
+
for (const offset of offsets) {
|
|
220
|
+
// Read v1 entity (64 bytes) — the first 64 bytes match our struct minus psi
|
|
221
|
+
const buf = this.mf.read(offset, BigInt(OLD_ENTITY_RECORD_SIZE));
|
|
222
|
+
const rec = {
|
|
223
|
+
offset,
|
|
224
|
+
nameId: buf.readUInt32LE(E_NAME_ID),
|
|
225
|
+
typeId: buf.readUInt32LE(E_TYPE_ID),
|
|
226
|
+
adjOffset: buf.readBigUInt64LE(E_ADJ_OFFSET),
|
|
227
|
+
mtime: buf.readBigUInt64LE(E_MTIME),
|
|
228
|
+
obsMtime: buf.readBigUInt64LE(E_OBS_MTIME),
|
|
229
|
+
obsCount: buf.readUInt8(E_OBS_COUNT),
|
|
230
|
+
obs0Id: buf.readUInt32LE(E_OBS0_ID),
|
|
231
|
+
obs1Id: buf.readUInt32LE(E_OBS1_ID),
|
|
232
|
+
structuralVisits: buf.readBigUInt64LE(E_STRUCTURAL_VISITS),
|
|
233
|
+
walkerVisits: buf.readBigUInt64LE(E_WALKER_VISITS),
|
|
234
|
+
psi: 0,
|
|
235
|
+
};
|
|
236
|
+
const edges = rec.adjOffset !== 0n ? readAdjBlock(this.mf, rec.adjOffset).entries : [];
|
|
237
|
+
oldEntities.push({ rec, edges });
|
|
238
|
+
}
|
|
239
|
+
// 2. Read global counters
|
|
240
|
+
const structuralTotal = this.getStructuralTotal();
|
|
241
|
+
const walkerTotal = this.getWalkerTotal();
|
|
242
|
+
// 3. Close old file, rename to .v1 backup
|
|
243
|
+
this.mf.sync();
|
|
244
|
+
this.mf.close();
|
|
245
|
+
const backupPath = graphPath + '.v1';
|
|
246
|
+
fs.renameSync(graphPath, backupPath);
|
|
247
|
+
// 4. Create fresh v2 file
|
|
248
|
+
this.mf = new MemoryFile(graphPath, 65536);
|
|
249
|
+
this.mf.setVersion(CURRENT_GRAPH_VERSION);
|
|
250
|
+
this.graphHeaderOffset = this.initGraphHeader();
|
|
251
|
+
// 5. Write global counters
|
|
252
|
+
const ghBuf = Buffer.alloc(8);
|
|
253
|
+
ghBuf.writeBigUInt64LE(structuralTotal, 0);
|
|
254
|
+
this.mf.write(this.graphHeaderOffset + BigInt(GH_STRUCTURAL_TOTAL), ghBuf);
|
|
255
|
+
ghBuf.writeBigUInt64LE(walkerTotal, 0);
|
|
256
|
+
this.mf.write(this.graphHeaderOffset + BigInt(GH_WALKER_TOTAL), ghBuf);
|
|
257
|
+
// 6. Write all entities with new 72-byte layout, building offset remap
|
|
258
|
+
const offsetMap = new Map(); // old offset → new offset
|
|
259
|
+
for (const { rec } of oldEntities) {
|
|
260
|
+
const newOffset = this.mf.alloc(BigInt(ENTITY_RECORD_SIZE));
|
|
261
|
+
if (newOffset === 0n)
|
|
262
|
+
throw new Error('GraphFile migration: entity alloc failed');
|
|
263
|
+
offsetMap.set(rec.offset, newOffset);
|
|
264
|
+
const newRec = { ...rec, offset: newOffset, adjOffset: 0n, psi: 0 };
|
|
265
|
+
writeEntityRecord(this.mf, newRec);
|
|
266
|
+
this.nodeLogAppend(newOffset);
|
|
267
|
+
}
|
|
268
|
+
// 7. Rebuild adjacency blocks with remapped target offsets
|
|
269
|
+
for (const { rec, edges } of oldEntities) {
|
|
270
|
+
const newOffset = offsetMap.get(rec.offset);
|
|
271
|
+
for (const edge of edges) {
|
|
272
|
+
const newTarget = offsetMap.get(edge.targetOffset);
|
|
273
|
+
if (newTarget === undefined)
|
|
274
|
+
continue; // skip dangling refs
|
|
275
|
+
this.addEdge(newOffset, {
|
|
276
|
+
targetOffset: newTarget,
|
|
277
|
+
direction: edge.direction,
|
|
278
|
+
relTypeId: edge.relTypeId,
|
|
279
|
+
mtime: edge.mtime,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
182
282
|
}
|
|
283
|
+
this.mf.sync();
|
|
183
284
|
}
|
|
184
285
|
initGraphHeader() {
|
|
185
286
|
// Allocate graph header block
|
|
@@ -233,6 +334,7 @@ export class GraphFile {
|
|
|
233
334
|
obs1Id: 0,
|
|
234
335
|
structuralVisits: 0n,
|
|
235
336
|
walkerVisits: 0n,
|
|
337
|
+
psi: 0,
|
|
236
338
|
};
|
|
237
339
|
writeEntityRecord(this.mf, rec);
|
|
238
340
|
this.nodeLogAppend(offset);
|
|
@@ -534,6 +636,18 @@ export class GraphFile {
|
|
|
534
636
|
const rec = this.readEntity(entityOffset);
|
|
535
637
|
return Number(rec.walkerVisits) / Number(total);
|
|
536
638
|
}
|
|
639
|
+
// --- MERW eigenvector ---
|
|
640
|
+
/** Write the psi (MERW eigenvector component) for an entity. */
|
|
641
|
+
setPsi(entityOffset, psi) {
|
|
642
|
+
const buf = Buffer.alloc(8);
|
|
643
|
+
buf.writeDoubleLE(psi, 0);
|
|
644
|
+
this.mf.write(entityOffset + BigInt(E_PSI), buf);
|
|
645
|
+
}
|
|
646
|
+
/** Read the psi (MERW eigenvector component) for an entity. */
|
|
647
|
+
getPsi(entityOffset) {
|
|
648
|
+
const buf = this.mf.read(entityOffset + BigInt(E_PSI), 8n);
|
|
649
|
+
return buf.readDoubleLE(0);
|
|
650
|
+
}
|
|
537
651
|
// --- Lifecycle & Concurrency ---
|
|
538
652
|
/** Acquire a shared (read) lock on the graph file. */
|
|
539
653
|
lockShared() {
|