@hviana/sema 0.1.0 → 0.1.1
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/dist/example/demo.d.ts +1 -0
- package/dist/example/demo.js +39 -0
- package/dist/example/train_base.d.ts +87 -0
- package/dist/example/train_base.js +2216 -0
- package/dist/src/alphabet.d.ts +7 -0
- package/dist/src/alphabet.js +33 -0
- package/dist/src/alu/src/alu.d.ts +185 -0
- package/dist/src/alu/src/alu.js +440 -0
- package/dist/src/alu/src/expr.d.ts +61 -0
- package/dist/src/alu/src/expr.js +318 -0
- package/dist/src/alu/src/index.d.ts +11 -0
- package/dist/src/alu/src/index.js +19 -0
- package/dist/src/alu/src/kernel-arith.d.ts +16 -0
- package/dist/src/alu/src/kernel-arith.js +264 -0
- package/dist/src/alu/src/kernel-bits.d.ts +19 -0
- package/dist/src/alu/src/kernel-bits.js +152 -0
- package/dist/src/alu/src/kernel-logic.d.ts +4 -0
- package/dist/src/alu/src/kernel-logic.js +60 -0
- package/dist/src/alu/src/kernel-nd.d.ts +3 -0
- package/dist/src/alu/src/kernel-nd.js +208 -0
- package/dist/src/alu/src/kernel-numeric.d.ts +54 -0
- package/dist/src/alu/src/kernel-numeric.js +366 -0
- package/dist/src/alu/src/operation.d.ts +168 -0
- package/dist/src/alu/src/operation.js +189 -0
- package/dist/src/alu/src/parser.d.ts +212 -0
- package/dist/src/alu/src/parser.js +469 -0
- package/dist/src/alu/src/resonance.d.ts +55 -0
- package/dist/src/alu/src/resonance.js +126 -0
- package/dist/src/alu/src/text.d.ts +31 -0
- package/dist/src/alu/src/text.js +73 -0
- package/dist/src/alu/src/value.d.ts +109 -0
- package/dist/src/alu/src/value.js +300 -0
- package/dist/src/alu/test/alu.test.d.ts +1 -0
- package/dist/src/alu/test/alu.test.js +764 -0
- package/dist/src/bytes.d.ts +14 -0
- package/dist/src/bytes.js +59 -0
- package/dist/src/config.d.ts +114 -0
- package/dist/src/config.js +96 -0
- package/dist/src/derive/src/deduction.d.ts +125 -0
- package/dist/src/derive/src/deduction.js +155 -0
- package/dist/src/derive/src/index.d.ts +7 -0
- package/dist/src/derive/src/index.js +11 -0
- package/dist/src/derive/src/priority-queue.d.ts +20 -0
- package/dist/src/derive/src/priority-queue.js +73 -0
- package/dist/src/derive/src/rewrite.d.ts +56 -0
- package/dist/src/derive/src/rewrite.js +100 -0
- package/dist/src/derive/src/trie.d.ts +90 -0
- package/dist/src/derive/src/trie.js +217 -0
- package/dist/src/derive/test/derive.test.d.ts +1 -0
- package/dist/src/derive/test/derive.test.js +122 -0
- package/dist/src/extension.d.ts +37 -0
- package/dist/src/extension.js +7 -0
- package/dist/src/geometry.d.ts +137 -0
- package/dist/src/geometry.js +430 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +21 -0
- package/dist/src/ingest-cache.d.ts +41 -0
- package/dist/src/ingest-cache.js +161 -0
- package/dist/src/mind/articulation.d.ts +6 -0
- package/dist/src/mind/articulation.js +99 -0
- package/dist/src/mind/attention.d.ts +72 -0
- package/dist/src/mind/attention.js +894 -0
- package/dist/src/mind/canonical.d.ts +29 -0
- package/dist/src/mind/canonical.js +88 -0
- package/dist/src/mind/graph-search.d.ts +270 -0
- package/dist/src/mind/graph-search.js +847 -0
- package/dist/src/mind/index.d.ts +5 -0
- package/dist/src/mind/index.js +5 -0
- package/dist/src/mind/junction.d.ts +95 -0
- package/dist/src/mind/junction.js +262 -0
- package/dist/src/mind/learning.d.ts +47 -0
- package/dist/src/mind/learning.js +201 -0
- package/dist/src/mind/match.d.ts +111 -0
- package/dist/src/mind/match.js +422 -0
- package/dist/src/mind/mechanisms/alu.d.ts +4 -0
- package/dist/src/mind/mechanisms/alu.js +29 -0
- package/dist/src/mind/mechanisms/cast.d.ts +35 -0
- package/dist/src/mind/mechanisms/cast.js +447 -0
- package/dist/src/mind/mechanisms/confluence.d.ts +24 -0
- package/dist/src/mind/mechanisms/confluence.js +213 -0
- package/dist/src/mind/mechanisms/cover.d.ts +6 -0
- package/dist/src/mind/mechanisms/cover.js +179 -0
- package/dist/src/mind/mechanisms/extraction.d.ts +67 -0
- package/dist/src/mind/mechanisms/extraction.js +342 -0
- package/dist/src/mind/mechanisms/recall.d.ts +13 -0
- package/dist/src/mind/mechanisms/recall.js +151 -0
- package/dist/src/mind/mind.d.ts +147 -0
- package/dist/src/mind/mind.js +300 -0
- package/dist/src/mind/pipeline-mechanism.d.ts +142 -0
- package/dist/src/mind/pipeline-mechanism.js +213 -0
- package/dist/src/mind/pipeline.d.ts +20 -0
- package/dist/src/mind/pipeline.js +185 -0
- package/dist/src/mind/primitives.d.ts +43 -0
- package/dist/src/mind/primitives.js +162 -0
- package/dist/src/mind/rationale.d.ts +134 -0
- package/dist/src/mind/rationale.js +162 -0
- package/dist/src/mind/reasoning.d.ts +15 -0
- package/dist/src/mind/reasoning.js +162 -0
- package/dist/src/mind/recognition.d.ts +20 -0
- package/dist/src/mind/recognition.js +223 -0
- package/dist/src/mind/resonance.d.ts +23 -0
- package/dist/src/mind/resonance.js +0 -0
- package/dist/src/mind/trace.d.ts +15 -0
- package/dist/src/mind/trace.js +73 -0
- package/dist/src/mind/traverse.d.ts +100 -0
- package/dist/src/mind/traverse.js +447 -0
- package/dist/src/mind/types.d.ts +174 -0
- package/dist/src/mind/types.js +84 -0
- package/dist/src/rabitq-hnsw/src/database.d.ts +200 -0
- package/dist/src/rabitq-hnsw/src/database.js +388 -0
- package/dist/src/rabitq-hnsw/src/heap.d.ts +22 -0
- package/dist/src/rabitq-hnsw/src/heap.js +89 -0
- package/dist/src/rabitq-hnsw/src/hnsw.d.ts +125 -0
- package/dist/src/rabitq-hnsw/src/hnsw.js +474 -0
- package/dist/src/rabitq-hnsw/src/index.d.ts +10 -0
- package/dist/src/rabitq-hnsw/src/index.js +6 -0
- package/dist/src/rabitq-hnsw/src/prng.d.ts +19 -0
- package/dist/src/rabitq-hnsw/src/prng.js +36 -0
- package/dist/src/rabitq-hnsw/src/rabitq.d.ts +95 -0
- package/dist/src/rabitq-hnsw/src/rabitq.js +283 -0
- package/dist/src/rabitq-hnsw/src/store.d.ts +162 -0
- package/dist/src/rabitq-hnsw/src/store.js +825 -0
- package/dist/src/rabitq-hnsw/test/hnsw.test.d.ts +1 -0
- package/dist/src/rabitq-hnsw/test/hnsw.test.js +948 -0
- package/dist/src/store-sqlite.d.ts +149 -0
- package/dist/src/store-sqlite.js +702 -0
- package/dist/src/store.d.ts +638 -0
- package/dist/src/store.js +1618 -0
- package/dist/src/vec.d.ts +31 -0
- package/dist/src/vec.js +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
const NODES = (name) => `
|
|
3
|
+
CREATE TABLE ${name} (
|
|
4
|
+
id INTEGER PRIMARY KEY,
|
|
5
|
+
ext INTEGER,
|
|
6
|
+
level INTEGER NOT NULL,
|
|
7
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
code BLOB NOT NULL
|
|
9
|
+
)`;
|
|
10
|
+
const LINKS = (name) => `
|
|
11
|
+
CREATE TABLE ${name} (
|
|
12
|
+
node INTEGER NOT NULL,
|
|
13
|
+
layer INTEGER NOT NULL,
|
|
14
|
+
nbrs BLOB NOT NULL,
|
|
15
|
+
PRIMARY KEY (node, layer)
|
|
16
|
+
) WITHOUT ROWID`;
|
|
17
|
+
const EXT_INDEX = "CREATE UNIQUE INDEX idx_nodes_ext ON nodes(ext) WHERE ext IS NOT NULL";
|
|
18
|
+
/** Direct-mapped, typed-array-backed node cache: `slot = id % capacity`, a
|
|
19
|
+
* colliding insert overwrites. No per-entry heap objects, no recency
|
|
20
|
+
* bookkeeping — see the cache commentary in {@link Store}. */
|
|
21
|
+
class CodeSlab {
|
|
22
|
+
codeBytes;
|
|
23
|
+
keys; // node id per slot; -1 = empty
|
|
24
|
+
ext; // NaN = null ext
|
|
25
|
+
deleted;
|
|
26
|
+
codes; // capacity × codeBytes, flat
|
|
27
|
+
capacity;
|
|
28
|
+
constructor(capacity, codeBytes) {
|
|
29
|
+
this.codeBytes = codeBytes;
|
|
30
|
+
this.capacity = capacity;
|
|
31
|
+
this.keys = new Float64Array(capacity).fill(-1);
|
|
32
|
+
this.ext = new Float64Array(capacity);
|
|
33
|
+
this.deleted = new Uint8Array(capacity);
|
|
34
|
+
this.codes = new Uint8Array(capacity * codeBytes);
|
|
35
|
+
}
|
|
36
|
+
/** The cached record, or undefined. The returned NodeRec is a fresh small
|
|
37
|
+
* object whose code is a read-only VIEW into the slab (callers never
|
|
38
|
+
* mutate codes; an overwrite of this slot only happens on a later insert,
|
|
39
|
+
* after the view's one-operation lifetime). */
|
|
40
|
+
get(id) {
|
|
41
|
+
const slot = id % this.capacity;
|
|
42
|
+
if (this.keys[slot] !== id)
|
|
43
|
+
return undefined;
|
|
44
|
+
const e = this.ext[slot];
|
|
45
|
+
return {
|
|
46
|
+
code: this.codes.subarray(slot * this.codeBytes, (slot + 1) * this.codeBytes),
|
|
47
|
+
deleted: this.deleted[slot],
|
|
48
|
+
ext: Number.isNaN(e) ? null : e,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
has(id) {
|
|
52
|
+
return this.keys[id % this.capacity] === id;
|
|
53
|
+
}
|
|
54
|
+
set(id, code, deleted, ext) {
|
|
55
|
+
if (code.length !== this.codeBytes)
|
|
56
|
+
return; // foreign geometry — skip
|
|
57
|
+
const slot = id % this.capacity;
|
|
58
|
+
this.keys[slot] = id;
|
|
59
|
+
this.ext[slot] = ext === null ? NaN : ext;
|
|
60
|
+
this.deleted[slot] = deleted;
|
|
61
|
+
this.codes.set(code, slot * this.codeBytes);
|
|
62
|
+
}
|
|
63
|
+
/** Mark a cached id tombstoned in place (mirrors the row update). */
|
|
64
|
+
markDeleted(id) {
|
|
65
|
+
const slot = id % this.capacity;
|
|
66
|
+
if (this.keys[slot] === id) {
|
|
67
|
+
this.deleted[slot] = 1;
|
|
68
|
+
this.ext[slot] = NaN;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
clear() {
|
|
72
|
+
this.keys.fill(-1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Direct-mapped cache of decoded neighbour lists, same design as
|
|
76
|
+
* {@link CodeSlab}: `slot = (node·64+layer) % capacity`, fixed-width rows,
|
|
77
|
+
* a colliding insert overwrites, nothing heap-allocated per entry. */
|
|
78
|
+
class NbrSlab {
|
|
79
|
+
width;
|
|
80
|
+
keys; // node·64+layer per slot; -1 = empty
|
|
81
|
+
counts; // 0xff = cached "no list" (null)
|
|
82
|
+
data; // capacity × width, flat
|
|
83
|
+
capacity;
|
|
84
|
+
constructor(capacity, width) {
|
|
85
|
+
this.width = width;
|
|
86
|
+
this.capacity = capacity;
|
|
87
|
+
this.keys = new Float64Array(capacity).fill(-1);
|
|
88
|
+
this.counts = new Uint8Array(capacity);
|
|
89
|
+
this.data = new Uint32Array(capacity * width);
|
|
90
|
+
}
|
|
91
|
+
/** The cached list (a fresh copy — safe across later overwrites), null for
|
|
92
|
+
* a cached "no list here", undefined on a miss. */
|
|
93
|
+
get(key) {
|
|
94
|
+
const slot = key % this.capacity;
|
|
95
|
+
if (this.keys[slot] !== key)
|
|
96
|
+
return undefined;
|
|
97
|
+
const n = this.counts[slot];
|
|
98
|
+
if (n === 0xff)
|
|
99
|
+
return null;
|
|
100
|
+
const base = slot * this.width;
|
|
101
|
+
return this.data.slice(base, base + n);
|
|
102
|
+
}
|
|
103
|
+
set(key, ids) {
|
|
104
|
+
if (ids !== null && ids.length > this.width)
|
|
105
|
+
return; // oversize — skip
|
|
106
|
+
const slot = key % this.capacity;
|
|
107
|
+
this.keys[slot] = key;
|
|
108
|
+
if (ids === null) {
|
|
109
|
+
this.counts[slot] = 0xff;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.counts[slot] = ids.length;
|
|
113
|
+
const base = slot * this.width;
|
|
114
|
+
for (let i = 0; i < ids.length; i++)
|
|
115
|
+
this.data[base + i] = ids[i];
|
|
116
|
+
}
|
|
117
|
+
delete(key) {
|
|
118
|
+
const slot = key % this.capacity;
|
|
119
|
+
if (this.keys[slot] === key)
|
|
120
|
+
this.keys[slot] = -1;
|
|
121
|
+
}
|
|
122
|
+
clear() {
|
|
123
|
+
this.keys.fill(-1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export class Store {
|
|
127
|
+
db;
|
|
128
|
+
/**
|
|
129
|
+
* Number of row reads served from the database (node and neighbour-list
|
|
130
|
+
* fetches). This counts *storage* accesses only, so it is unaffected by any
|
|
131
|
+
* caching layer above it -- the honest measure of how the engine scales.
|
|
132
|
+
*/
|
|
133
|
+
reads = 0;
|
|
134
|
+
sInsNode;
|
|
135
|
+
sNode;
|
|
136
|
+
sIdByExt;
|
|
137
|
+
sTombstone;
|
|
138
|
+
sNbrs;
|
|
139
|
+
sSetNbrs;
|
|
140
|
+
sState;
|
|
141
|
+
// A node's 1-bit code is IMMUTABLE once written, yet building and searching the
|
|
142
|
+
// graph re-read the same hub nodes constantly: an insert touches ~ef distinct
|
|
143
|
+
// nodes, and across consecutive inserts (especially clustered data) those sets
|
|
144
|
+
// overlap heavily, so the same code is fetched again and again. Each fetch is a
|
|
145
|
+
// SQLite point-query that decodes the row and copies the BLOB, while the
|
|
146
|
+
// distance it feeds is ~0.04µs. So getNode, not arithmetic, dominates a build.
|
|
147
|
+
//
|
|
148
|
+
// A DIRECT-MAPPED SLAB CACHE removes it. Earlier revisions used a Map-based
|
|
149
|
+
// LRU of NodeRec objects; measured at trained-store scale (2.4M+ cached
|
|
150
|
+
// entries) the Map itself became the bottleneck — per-op hash cost, the
|
|
151
|
+
// delete+set recency churn, eviction bookkeeping, and millions of long-lived
|
|
152
|
+
// heap objects for the GC to trace. Filling the cache made ingest SLOWER
|
|
153
|
+
// than leaving it cold. The slab holds everything in a handful of typed
|
|
154
|
+
// arrays (keys, ext, deleted flags, and one flat code slab), slot = id mod
|
|
155
|
+
// capacity: a lookup is one modulo and one key compare; an insert on a
|
|
156
|
+
// colliding slot simply overwrites it (dense monotone ids make modulo a
|
|
157
|
+
// uniform spread, so collisions stay rare while occupancy < capacity); there
|
|
158
|
+
// is NO recency bookkeeping and NOTHING for the GC to trace. Two slabs:
|
|
159
|
+
// the MAIN one, and an UPPER-LAYER one for level ≥ 1 nodes — the ~12% of
|
|
160
|
+
// the collection every operation's descent touches — so layer-0 fan-out
|
|
161
|
+
// traffic can never evict the descent's working set however far the
|
|
162
|
+
// collection outgrows the budget.
|
|
163
|
+
//
|
|
164
|
+
// The honesty rules are unchanged from the LRU it replaces:
|
|
165
|
+
// • A LATENCY layer only — `reads` (the scalability witness) counts
|
|
166
|
+
// SQLite fall-throughs, so it is UNAFFECTED by the cache; the rabitq
|
|
167
|
+
// suite asserts scaling with the cache off.
|
|
168
|
+
// • BOUNDED BY THE SAME MEMORY BUDGET as the page cache: slot count is
|
|
169
|
+
// derived from `cacheSizeMb` and the code size (no second knob), the
|
|
170
|
+
// upper-layer slab takes at most half. cacheSizeMb=0 disables both.
|
|
171
|
+
// • The only mutable fields (deleted/ext) are updated in place by
|
|
172
|
+
// tombstone(); both slabs are dropped at compaction. A collision or a
|
|
173
|
+
// miss only repeats work, never changes a result.
|
|
174
|
+
cacheBudgetBytes;
|
|
175
|
+
/** Whether the shared memory budget is non-zero — consumers that trade RAM
|
|
176
|
+
* for speed (the graph's visited-tag array) key off this so the
|
|
177
|
+
* `cacheSizeMb: 0` flat-memory mode stays exactly flat. */
|
|
178
|
+
get cacheEnabled() {
|
|
179
|
+
return this.cacheBudgetBytes > 0;
|
|
180
|
+
}
|
|
181
|
+
slab = null; // main section (level 0)
|
|
182
|
+
slabHot = null; // upper-layer section (level ≥ 1)
|
|
183
|
+
nbrSlab = null; // neighbour-list cache
|
|
184
|
+
slabsDerived = false; // false until the first cacheRec sizes them
|
|
185
|
+
/**
|
|
186
|
+
* @param dbPath path to the SQLite file (":memory:" for transient).
|
|
187
|
+
* @param cacheSizeMb the ONE memory knob (MiB). It sizes BOTH SQLite's page
|
|
188
|
+
* cache AND the immutable-code LRU above (whose entry-capacity is derived
|
|
189
|
+
* from this budget and the code size — no second knob). Both are pure
|
|
190
|
+
* speed enhancements: correctness and the per-operation storage-read
|
|
191
|
+
* count do not depend on them. Pass 0 to run with essentially no cache,
|
|
192
|
+
* which is how the tests run so a poor access pattern can never hide
|
|
193
|
+
* behind a warm cache.
|
|
194
|
+
*/
|
|
195
|
+
constructor(dbPath, cacheSizeMb = 64) {
|
|
196
|
+
this.cacheBudgetBytes = Math.max(0, Math.floor(cacheSizeMb * 1024 * 1024));
|
|
197
|
+
this.db = new DatabaseSync(dbPath);
|
|
198
|
+
// WAL + NORMAL sync is crash-safe and fast; temp structures stay in memory.
|
|
199
|
+
// The page cache is the single memory budget; mmap is tied to it so "off"
|
|
200
|
+
// means every row read is an honest B-tree descent against the file.
|
|
201
|
+
const kb = Math.max(0, Math.floor(cacheSizeMb * 1024));
|
|
202
|
+
const cacheSize = kb > 0 ? `-${kb}` : "0";
|
|
203
|
+
const mmap = kb > 0 ? cacheSizeMb * 1024 * 1024 : 0;
|
|
204
|
+
// Rows here are one code (~dim/8 bytes) or one packed neighbour list —
|
|
205
|
+
// small against SQLite's default 4 KiB page, which then carries mostly
|
|
206
|
+
// slack. 1 KiB pages keep leaf-page fill high; must be set before the
|
|
207
|
+
// first table is created (no-op on an existing database).
|
|
208
|
+
this.db.exec("PRAGMA page_size = 1024;");
|
|
209
|
+
this.db.exec(`
|
|
210
|
+
PRAGMA journal_mode = WAL;
|
|
211
|
+
PRAGMA synchronous = NORMAL;
|
|
212
|
+
PRAGMA temp_store = MEMORY;
|
|
213
|
+
PRAGMA cache_size = ${cacheSize};
|
|
214
|
+
PRAGMA mmap_size = ${mmap};
|
|
215
|
+
PRAGMA foreign_keys = OFF;
|
|
216
|
+
`);
|
|
217
|
+
this.db.exec(`
|
|
218
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
219
|
+
id INTEGER PRIMARY KEY CHECK (id = 0),
|
|
220
|
+
dim INTEGER NOT NULL,
|
|
221
|
+
m INTEGER NOT NULL,
|
|
222
|
+
ef_construction INTEGER NOT NULL,
|
|
223
|
+
ef_search INTEGER NOT NULL,
|
|
224
|
+
query_bits INTEGER NOT NULL,
|
|
225
|
+
rotation_rounds INTEGER NOT NULL,
|
|
226
|
+
seed INTEGER NOT NULL,
|
|
227
|
+
centroid BLOB,
|
|
228
|
+
code_words INTEGER NOT NULL,
|
|
229
|
+
padded_dim INTEGER NOT NULL,
|
|
230
|
+
entry_point INTEGER NOT NULL DEFAULT -1,
|
|
231
|
+
max_level INTEGER NOT NULL DEFAULT -1,
|
|
232
|
+
live INTEGER NOT NULL DEFAULT 0,
|
|
233
|
+
total INTEGER NOT NULL DEFAULT 0,
|
|
234
|
+
rng INTEGER NOT NULL DEFAULT 0
|
|
235
|
+
);
|
|
236
|
+
`);
|
|
237
|
+
if (!this.tableExists("nodes")) {
|
|
238
|
+
this.db.exec(NODES("nodes"));
|
|
239
|
+
this.db.exec(EXT_INDEX);
|
|
240
|
+
this.db.exec(LINKS("links"));
|
|
241
|
+
}
|
|
242
|
+
this.prepareAll();
|
|
243
|
+
}
|
|
244
|
+
tableExists(name) {
|
|
245
|
+
const row = this.db
|
|
246
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
247
|
+
.get(name);
|
|
248
|
+
return row !== undefined;
|
|
249
|
+
}
|
|
250
|
+
/** (Re)compile the hot statements against the current schema. */
|
|
251
|
+
prepareAll() {
|
|
252
|
+
this.batchStmts.clear();
|
|
253
|
+
// Recompiled statements mean a table swap (compaction) — every cached
|
|
254
|
+
// decoded list may now describe a dropped table's rows.
|
|
255
|
+
this.nbrSlab?.clear();
|
|
256
|
+
this.sInsNode = this.db.prepare("INSERT INTO nodes(ext, level, code) VALUES (?, ?, ?)");
|
|
257
|
+
this.sNode = this.db.prepare("SELECT ext, level, deleted, code FROM nodes WHERE id = ?");
|
|
258
|
+
// Positional rows: the hot read path pays for a plain array instead of a
|
|
259
|
+
// per-row object with named properties.
|
|
260
|
+
this.sNode.setReturnArrays(true);
|
|
261
|
+
this.sIdByExt = this.db.prepare("SELECT id FROM nodes WHERE ext = ?");
|
|
262
|
+
this.sTombstone = this.db.prepare("UPDATE nodes SET deleted = 1, ext = NULL WHERE id = ?");
|
|
263
|
+
this.sNbrs = this.db.prepare("SELECT nbrs FROM links WHERE node = ? AND layer = ?");
|
|
264
|
+
this.sNbrs.setReturnArrays(true);
|
|
265
|
+
this.sSetNbrs = this.db.prepare("INSERT INTO links(node, layer, nbrs) VALUES (?, ?, ?) " +
|
|
266
|
+
"ON CONFLICT(node, layer) DO UPDATE SET nbrs = excluded.nbrs");
|
|
267
|
+
this.sState = this.db.prepare("UPDATE meta SET entry_point = ?, max_level = ?, live = ?, total = ?, rng = ? WHERE id = 0");
|
|
268
|
+
}
|
|
269
|
+
// ----------------------------- config / state ----------------------------
|
|
270
|
+
/** Load the persisted configuration, or null for a fresh database. */
|
|
271
|
+
loadConfig() {
|
|
272
|
+
const row = this.db.prepare("SELECT * FROM meta WHERE id = 0").get();
|
|
273
|
+
if (!row)
|
|
274
|
+
return null;
|
|
275
|
+
const cb = row.centroid;
|
|
276
|
+
let centroid = null;
|
|
277
|
+
if (cb) {
|
|
278
|
+
centroid = new Float64Array(row.dim);
|
|
279
|
+
new Uint8Array(centroid.buffer).set(cb);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
dim: row.dim,
|
|
283
|
+
m: row.m,
|
|
284
|
+
efConstruction: row.ef_construction,
|
|
285
|
+
efSearch: row.ef_search,
|
|
286
|
+
queryBits: row.query_bits,
|
|
287
|
+
rotationRounds: row.rotation_rounds,
|
|
288
|
+
seed: row.seed,
|
|
289
|
+
centroid,
|
|
290
|
+
codeWords: row.code_words,
|
|
291
|
+
paddedDim: row.padded_dim,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** Initialise the meta row for a new database. */
|
|
295
|
+
initConfig(c) {
|
|
296
|
+
const centroidBlob = c.centroid
|
|
297
|
+
? new Uint8Array(c.centroid.buffer, c.centroid.byteOffset, c.centroid.byteLength)
|
|
298
|
+
: null;
|
|
299
|
+
this.db
|
|
300
|
+
.prepare("INSERT INTO meta(id, dim, m, ef_construction, ef_search, query_bits, rotation_rounds, " +
|
|
301
|
+
"seed, centroid, code_words, padded_dim, rng) VALUES (0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
302
|
+
.run(c.dim, c.m, c.efConstruction, c.efSearch, c.queryBits, c.rotationRounds, c.seed, centroidBlob, c.codeWords, c.paddedDim, c.seed >>> 0);
|
|
303
|
+
}
|
|
304
|
+
loadState() {
|
|
305
|
+
const row = this.db
|
|
306
|
+
.prepare("SELECT entry_point, max_level, live, total, rng FROM meta WHERE id = 0")
|
|
307
|
+
.get();
|
|
308
|
+
return {
|
|
309
|
+
entryPoint: row.entry_point,
|
|
310
|
+
maxLevel: row.max_level,
|
|
311
|
+
live: row.live,
|
|
312
|
+
total: row.total,
|
|
313
|
+
rng: row.rng >>> 0,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
saveState(s) {
|
|
317
|
+
this.sState.run(s.entryPoint, s.maxLevel, s.live, s.total, s.rng | 0);
|
|
318
|
+
}
|
|
319
|
+
setEfSearch(ef) {
|
|
320
|
+
this.db.prepare("UPDATE meta SET ef_search = ? WHERE id = 0").run(ef);
|
|
321
|
+
}
|
|
322
|
+
setQueryBits(bits) {
|
|
323
|
+
this.db.prepare("UPDATE meta SET query_bits = ? WHERE id = 0").run(bits);
|
|
324
|
+
}
|
|
325
|
+
resetReads() {
|
|
326
|
+
this.reads = 0;
|
|
327
|
+
}
|
|
328
|
+
/** Stream every live external id in id order, in bounded-memory batches. */
|
|
329
|
+
*liveExts(batch = 1024) {
|
|
330
|
+
const q = this.db.prepare("SELECT id, ext FROM nodes WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?");
|
|
331
|
+
let cursor = 0;
|
|
332
|
+
for (;;) {
|
|
333
|
+
const rows = q.all(cursor, batch);
|
|
334
|
+
if (rows.length === 0)
|
|
335
|
+
break;
|
|
336
|
+
for (const r of rows)
|
|
337
|
+
yield r.ext;
|
|
338
|
+
cursor = rows[rows.length - 1].id;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// -------------------------------- nodes -----------------------------------
|
|
342
|
+
/** Size the two slabs from the shared memory budget and the actual code
|
|
343
|
+
* size (known on the first record cached): total slots = budget / bytes
|
|
344
|
+
* per slot, the upper-layer slab taking half. A slot is the code bytes
|
|
345
|
+
* plus 17 B of typed-array bookkeeping (key + ext + deleted). */
|
|
346
|
+
deriveSlabs(codeBytes) {
|
|
347
|
+
this.slabsDerived = true;
|
|
348
|
+
if (this.cacheBudgetBytes === 0)
|
|
349
|
+
return; // flat-memory mode — no slabs
|
|
350
|
+
const perSlot = codeBytes + 17;
|
|
351
|
+
const total = Math.max(2, Math.floor(this.cacheBudgetBytes / perSlot));
|
|
352
|
+
this.slabHot = new CodeSlab(total >> 1, codeBytes);
|
|
353
|
+
this.slab = new CodeSlab(total - (total >> 1), codeBytes);
|
|
354
|
+
// One-quarter of the budget for neighbour lists (neighbour ids).
|
|
355
|
+
// A layer-0 list is ~4·Mmax0 entries; we budget 4·16 ids per row.
|
|
356
|
+
this.nbrSlab = new NbrSlab(Math.max(1, total >> 2), 64);
|
|
357
|
+
}
|
|
358
|
+
/** Record a node in the slab cache. Upper-layer nodes (level ≥ 1) go to
|
|
359
|
+
* the pinned section layer-0 traffic never touches; a colliding slot is
|
|
360
|
+
* simply overwritten (direct-mapped eviction — no bookkeeping). */
|
|
361
|
+
cacheRec(id, code, deleted, ext, level) {
|
|
362
|
+
if (!this.slabsDerived)
|
|
363
|
+
this.deriveSlabs(code.byteLength);
|
|
364
|
+
const slab = level >= 1 ? this.slabHot : this.slab;
|
|
365
|
+
slab?.set(id, code, deleted, ext);
|
|
366
|
+
}
|
|
367
|
+
/** Insert a node, returning its assigned id (rowid). */
|
|
368
|
+
addNode(ext, level, code) {
|
|
369
|
+
const info = this.sInsNode.run(ext, level, code);
|
|
370
|
+
const id = Number(info.lastInsertRowid);
|
|
371
|
+
// The new node is read back immediately and repeatedly while it is wired in;
|
|
372
|
+
// seed its (immutable) code now so those reads hit the cache, not SQLite.
|
|
373
|
+
this.cacheRec(id, code, 0, ext, level);
|
|
374
|
+
return id;
|
|
375
|
+
}
|
|
376
|
+
/** Fetch a node's code (copied), tombstone flag and external id, or null.
|
|
377
|
+
* Served from the slab cache when present; a miss reads SQLite, counts
|
|
378
|
+
* one `reads` (the cache-independent scalability witness), and caches the
|
|
379
|
+
* result. The cache is a latency layer ONLY — `reads` does not count hits, so
|
|
380
|
+
* the index's storage-read scaling is identical with the cache off. */
|
|
381
|
+
getNode(id) {
|
|
382
|
+
// Pinned upper-layer section first, then the main slab.
|
|
383
|
+
const cached = this.slabHot?.get(id) ?? this.slab?.get(id);
|
|
384
|
+
if (cached !== undefined)
|
|
385
|
+
return cached;
|
|
386
|
+
this.reads++;
|
|
387
|
+
// Positional row: [ext, level, deleted, code]. node:sqlite hands back an
|
|
388
|
+
// OWNED Uint8Array per BLOB read (verified: mutating one read never shows
|
|
389
|
+
// in another), so the code is kept as returned — no defensive copy.
|
|
390
|
+
const row = this.sNode.get(id);
|
|
391
|
+
if (!row)
|
|
392
|
+
return null;
|
|
393
|
+
this.cacheRec(id, row[3], row[2], row[0], row[1]);
|
|
394
|
+
return { code: row[3], deleted: row[2], ext: row[0] };
|
|
395
|
+
}
|
|
396
|
+
idByExt(ext) {
|
|
397
|
+
const row = this.sIdByExt.get(ext);
|
|
398
|
+
return row ? row.id : null;
|
|
399
|
+
}
|
|
400
|
+
// Batched code fetch. An insert's search layer expands a popped node's
|
|
401
|
+
// whole neighbour list at once, and each cache-missing neighbour used to be
|
|
402
|
+
// its own point query — the statement dispatch (not the B-tree descent)
|
|
403
|
+
// dominated a trained-store build at ~50% of total CPU. One IN(...) query
|
|
404
|
+
// per expansion fetches all misses together; the per-row `reads` accounting
|
|
405
|
+
// is unchanged, so the cache-independent scalability witness is identical.
|
|
406
|
+
batchStmts = new Map();
|
|
407
|
+
static BATCH_MAX = 64;
|
|
408
|
+
batchStmt(n) {
|
|
409
|
+
let s = this.batchStmts.get(n);
|
|
410
|
+
if (s === undefined) {
|
|
411
|
+
s = this.db.prepare("SELECT id, ext, level, deleted, code FROM nodes WHERE id IN (" +
|
|
412
|
+
"?,".repeat(n - 1) + "?)");
|
|
413
|
+
s.setReturnArrays(true);
|
|
414
|
+
this.batchStmts.set(n, s);
|
|
415
|
+
}
|
|
416
|
+
return s;
|
|
417
|
+
}
|
|
418
|
+
/** Fetch several nodes into `out` (skipping ids already present). Serves
|
|
419
|
+
* from the code LRU first; the misses are read with one IN query per
|
|
420
|
+
* chunk. Ids with no row are simply absent from `out`. Each id that
|
|
421
|
+
* reaches SQLite counts one `reads`, exactly like a getNode miss. */
|
|
422
|
+
getNodesInto(ids, out, count = ids.length) {
|
|
423
|
+
let miss = null;
|
|
424
|
+
for (let i = 0; i < count; i++) {
|
|
425
|
+
const id = ids[i];
|
|
426
|
+
if (out.has(id))
|
|
427
|
+
continue;
|
|
428
|
+
const cached = this.slabHot?.get(id) ?? this.slab?.get(id);
|
|
429
|
+
if (cached !== undefined) {
|
|
430
|
+
out.set(id, cached);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
(miss ??= []).push(id);
|
|
434
|
+
}
|
|
435
|
+
if (miss === null)
|
|
436
|
+
return;
|
|
437
|
+
for (let o = 0; o < miss.length; o += Store.BATCH_MAX) {
|
|
438
|
+
const chunk = miss.slice(o, o + Store.BATCH_MAX);
|
|
439
|
+
this.reads += chunk.length;
|
|
440
|
+
// Positional rows: [id, ext, level, deleted, code]; the code BLOB is an
|
|
441
|
+
// owned buffer (see getNode), kept without a defensive copy.
|
|
442
|
+
const rows = this.batchStmt(chunk.length).all(...chunk);
|
|
443
|
+
for (const row of rows) {
|
|
444
|
+
this.cacheRec(row[0], row[4], row[3], row[1], row[2]);
|
|
445
|
+
out.set(row[0], { code: row[4], deleted: row[3], ext: row[1] });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** Pre-fill the code and neighbour-list caches with ONE sequential table
|
|
450
|
+
* scan each, up to their existing budget-derived caps. A cold session
|
|
451
|
+
* otherwise warms the caches through hundreds of thousands of RANDOM point
|
|
452
|
+
* reads spread across its first minutes of inserts/queries; a sequential
|
|
453
|
+
* scan streams the same rows at C speed in seconds. A pure latency
|
|
454
|
+
* optimisation with the exact same caps and coherence rules as demand
|
|
455
|
+
* filling — nothing about results, `reads` discipline, or memory ceilings
|
|
456
|
+
* changes. Two passes over `nodes` so the upper-layer pinned section is
|
|
457
|
+
* filled before layer-0 rows compete for the LRU. Returns rows warmed. */
|
|
458
|
+
warmCache() {
|
|
459
|
+
if (this.cacheBudgetBytes === 0)
|
|
460
|
+
return 0;
|
|
461
|
+
let warmed = 0;
|
|
462
|
+
// One scan over the node rows; cacheRec routes each to its slab (the
|
|
463
|
+
// sections are direct-mapped, so there is no fill-order competition).
|
|
464
|
+
{
|
|
465
|
+
const scan = this.db.prepare("SELECT id, ext, level, deleted, code FROM nodes");
|
|
466
|
+
scan.setReturnArrays(true);
|
|
467
|
+
for (const row of scan.iterate()) {
|
|
468
|
+
this.cacheRec(row[0], row[4], row[3], row[1], row[2]);
|
|
469
|
+
warmed++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Neighbour lists: one pass; nbrSlab is direct-mapped so fill order
|
|
473
|
+
// doesn't matter (later entries simply overwrite earlier collisions).
|
|
474
|
+
{
|
|
475
|
+
const scan = this.db.prepare("SELECT node, layer, nbrs FROM links");
|
|
476
|
+
scan.setReturnArrays(true);
|
|
477
|
+
for (const row of scan.iterate()) {
|
|
478
|
+
if (row[1] >= 64)
|
|
479
|
+
continue;
|
|
480
|
+
this.nbrSlab?.set(row[0] * 64 + row[1], Uint32Array.from(this.decodeNbrs(row[2])));
|
|
481
|
+
warmed++;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return warmed;
|
|
485
|
+
}
|
|
486
|
+
tombstone(id) {
|
|
487
|
+
this.sTombstone.run(id);
|
|
488
|
+
// Keep any cached record coherent with the row: a later hit must show the
|
|
489
|
+
// same deleted/ext a fresh SQLite read would (search routes through but never
|
|
490
|
+
// returns tombstoned nodes).
|
|
491
|
+
this.slab?.markDeleted(id);
|
|
492
|
+
this.slabHot?.markDeleted(id);
|
|
493
|
+
}
|
|
494
|
+
// -------------------------------- links -----------------------------------
|
|
495
|
+
// Neighbour lists are SETS to the graph algorithms: search visits every
|
|
496
|
+
// member and pruning recomputes distances from the codes, so storage order
|
|
497
|
+
// carries no information. That freedom pays for compression: the list is
|
|
498
|
+
// stored sorted as delta-VARINTS — consecutive graph ids are small deltas, so
|
|
499
|
+
// a neighbour costs ~1–2 bytes instead of a fixed 4, and the cost scales with
|
|
500
|
+
// log(collection) instead of a fixed word width. Links are written once per
|
|
501
|
+
// wiring update and read on every hop, and decoding is a linear byte scan —
|
|
502
|
+
// noise against the row fetch it rides on.
|
|
503
|
+
// DECODED NEIGHBOUR-LIST CACHE. A direct-mapped NbrSlab (same design as
|
|
504
|
+
// the code slab). Budget: one quarter of the shared cacheSizeMb, sized in
|
|
505
|
+
// deriveSlabs alongside the code slabs. Each list is a fresh copy from the
|
|
506
|
+
// slab, so callers are safe across later overwrites. `setNeighbors` writes
|
|
507
|
+
// through, so a hit always equals a fresh row read.
|
|
508
|
+
/** A node's neighbour ids at a layer, or null if it has no list there. */
|
|
509
|
+
getNeighbors(node, layer) {
|
|
510
|
+
if (layer < 64) {
|
|
511
|
+
const hit = this.nbrSlab?.get(node * 64 + layer);
|
|
512
|
+
if (hit !== undefined)
|
|
513
|
+
return hit;
|
|
514
|
+
}
|
|
515
|
+
this.reads++;
|
|
516
|
+
const row = this.sNbrs.get(node, layer);
|
|
517
|
+
if (!row) {
|
|
518
|
+
this.nbrSlab?.set(node * 64 + layer, null);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const blob = row[0];
|
|
522
|
+
// Entry count = terminator bytes (high bit clear): one cheap scan sizes
|
|
523
|
+
// the result exactly, so the hot path pays ONE allocation instead of a
|
|
524
|
+
// growable number[] plus a Uint32Array copy per graph hop.
|
|
525
|
+
let count = 0;
|
|
526
|
+
for (let i = 0; i < blob.length; i++) {
|
|
527
|
+
if ((blob[i] & 0x80) === 0)
|
|
528
|
+
count++;
|
|
529
|
+
}
|
|
530
|
+
const ids = new Uint32Array(count);
|
|
531
|
+
let acc = 0, shift = 0, prev = 0, n = 0;
|
|
532
|
+
for (let i = 0; i < blob.length; i++) {
|
|
533
|
+
const b = blob[i];
|
|
534
|
+
acc |= (b & 0x7f) << shift;
|
|
535
|
+
if (b & 0x80) {
|
|
536
|
+
shift += 7;
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
prev += acc;
|
|
540
|
+
ids[n++] = prev;
|
|
541
|
+
acc = 0;
|
|
542
|
+
shift = 0;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
this.nbrSlab?.set(node * 64 + layer, ids);
|
|
546
|
+
return ids;
|
|
547
|
+
}
|
|
548
|
+
setNeighbors(node, layer, ids) {
|
|
549
|
+
const s = ids.slice().sort((a, b) => a - b);
|
|
550
|
+
const buf = new Uint8Array(s.length * 5);
|
|
551
|
+
let o = 0, prev = 0;
|
|
552
|
+
for (let i = 0; i < s.length; i++) {
|
|
553
|
+
let d = s[i] - prev;
|
|
554
|
+
prev = s[i];
|
|
555
|
+
while (d >= 0x80) {
|
|
556
|
+
buf[o++] = (d & 0x7f) | 0x80;
|
|
557
|
+
d >>>= 7;
|
|
558
|
+
}
|
|
559
|
+
buf[o++] = d;
|
|
560
|
+
}
|
|
561
|
+
this.sSetNbrs.run(node, layer, buf.subarray(0, o));
|
|
562
|
+
// Write through the decoded-list cache: a later hit must equal what a
|
|
563
|
+
// fresh row read would decode (same sorted order).
|
|
564
|
+
// Write through the decoded-list cache: a later hit must equal what a
|
|
565
|
+
// fresh row read would decode (same sorted order).
|
|
566
|
+
if (layer < 64)
|
|
567
|
+
this.nbrSlab?.set(node * 64 + layer, Uint32Array.from(s));
|
|
568
|
+
}
|
|
569
|
+
// ----------------------------- transactions -------------------------------
|
|
570
|
+
begin() {
|
|
571
|
+
this.db.exec("BEGIN");
|
|
572
|
+
}
|
|
573
|
+
commit() {
|
|
574
|
+
this.db.exec("COMMIT");
|
|
575
|
+
}
|
|
576
|
+
rollback() {
|
|
577
|
+
this.db.exec("ROLLBACK");
|
|
578
|
+
}
|
|
579
|
+
// ------------------------------- compaction -------------------------------
|
|
580
|
+
// spliceCompact() reclaims tombstones by a GRAPH-PRESERVING structural copy:
|
|
581
|
+
// live node rows are copied verbatim (SAME internal ids — nothing external
|
|
582
|
+
// ever remaps), and each live neighbour list is rewritten with its dead
|
|
583
|
+
// members replaced by those members' own live neighbours (a 1-hop bridge, the
|
|
584
|
+
// standard tombstone splice). Codes are untouched — the index stays exactly
|
|
585
|
+
// as lossy as RaBitQ made it, never more — and the graph keeps the wiring the
|
|
586
|
+
// original inserts built, minus the dead routing. The previous compact
|
|
587
|
+
// replayed every live code through a full HNSW insert: O(live · ef · log N)
|
|
588
|
+
// storage reads — HOURS on a multi-million-node trained store, inside ONE
|
|
589
|
+
// transaction whose WAL grew by the whole rebuild. The splice is one
|
|
590
|
+
// streaming pass over the node and link rows (batched commits, bounded WAL).
|
|
591
|
+
/** Encode a neighbour id list to the delta-varint blob format. */
|
|
592
|
+
encodeNbrs(ids) {
|
|
593
|
+
const s = ids.slice().sort((a, b) => a - b);
|
|
594
|
+
const buf = new Uint8Array(s.length * 5);
|
|
595
|
+
let o = 0, prev = 0;
|
|
596
|
+
for (let i = 0; i < s.length; i++) {
|
|
597
|
+
let d = s[i] - prev;
|
|
598
|
+
prev = s[i];
|
|
599
|
+
while (d >= 0x80) {
|
|
600
|
+
buf[o++] = (d & 0x7f) | 0x80;
|
|
601
|
+
d >>>= 7;
|
|
602
|
+
}
|
|
603
|
+
buf[o++] = d;
|
|
604
|
+
}
|
|
605
|
+
return buf.subarray(0, o);
|
|
606
|
+
}
|
|
607
|
+
/** Decode a delta-varint neighbour blob into a number[]. */
|
|
608
|
+
decodeNbrs(blob) {
|
|
609
|
+
const ids = [];
|
|
610
|
+
let acc = 0, shift = 0, prev = 0;
|
|
611
|
+
for (let i = 0; i < blob.length; i++) {
|
|
612
|
+
const b = blob[i];
|
|
613
|
+
acc |= (b & 0x7f) << shift;
|
|
614
|
+
if (b & 0x80) {
|
|
615
|
+
shift += 7;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
prev += acc;
|
|
619
|
+
ids.push(prev);
|
|
620
|
+
acc = 0;
|
|
621
|
+
shift = 0;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return ids;
|
|
625
|
+
}
|
|
626
|
+
/** Tombstone-splice compaction. `M`/`Mmax0` cap a rewritten list on upper
|
|
627
|
+
* layers / layer 0. Returns the new global state scalars (internal ids are
|
|
628
|
+
* preserved, so `entry` survives unless it was itself dead). The caller
|
|
629
|
+
* persists state and vacuums. */
|
|
630
|
+
spliceCompact(M, Mmax0, entry) {
|
|
631
|
+
const db = this.db;
|
|
632
|
+
this.slab?.clear();
|
|
633
|
+
this.slabHot?.clear();
|
|
634
|
+
this.nbrSlab?.clear();
|
|
635
|
+
// Fresh target tables (leftovers from an interrupted compact are dropped).
|
|
636
|
+
db.exec(`
|
|
637
|
+
DROP TABLE IF EXISTS nodes_new;
|
|
638
|
+
DROP TABLE IF EXISTS links_new;
|
|
639
|
+
${NODES("nodes_new")};
|
|
640
|
+
${LINKS("links_new")};
|
|
641
|
+
`);
|
|
642
|
+
// Dead internal ids, sorted, for O(log n) membership. A neighbour id
|
|
643
|
+
// always references an existing row, so "not dead" ⇔ live.
|
|
644
|
+
const deadCount = db.prepare("SELECT COUNT(*) AS n FROM nodes WHERE deleted = 1").get().n;
|
|
645
|
+
const dead = new Float64Array(deadCount);
|
|
646
|
+
{
|
|
647
|
+
let i = 0;
|
|
648
|
+
for (const row of db.prepare("SELECT id FROM nodes WHERE deleted = 1 ORDER BY id").iterate())
|
|
649
|
+
dead[i++] = row.id;
|
|
650
|
+
}
|
|
651
|
+
const isDead = (id) => {
|
|
652
|
+
let lo = 0, hi = deadCount - 1;
|
|
653
|
+
while (lo <= hi) {
|
|
654
|
+
const mid = (lo + hi) >> 1;
|
|
655
|
+
const v = dead[mid];
|
|
656
|
+
if (v === id)
|
|
657
|
+
return true;
|
|
658
|
+
if (v < id)
|
|
659
|
+
lo = mid + 1;
|
|
660
|
+
else
|
|
661
|
+
hi = mid - 1;
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
};
|
|
665
|
+
// 1. Copy live node rows verbatim (same ids), in batched transactions so
|
|
666
|
+
// the WAL stays bounded by the batch, not the table.
|
|
667
|
+
const copy = db.prepare("INSERT INTO nodes_new (id, ext, level, deleted, code) " +
|
|
668
|
+
"SELECT id, ext, level, 0, code FROM nodes " +
|
|
669
|
+
"WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?");
|
|
670
|
+
const maxCopied = db.prepare("SELECT MAX(id) AS m FROM nodes_new");
|
|
671
|
+
const COPY_BATCH = 50_000;
|
|
672
|
+
let cursor = 0;
|
|
673
|
+
for (;;) {
|
|
674
|
+
db.exec("BEGIN");
|
|
675
|
+
const n = Number(copy.run(cursor, COPY_BATCH).changes);
|
|
676
|
+
db.exec("COMMIT");
|
|
677
|
+
if (n === 0)
|
|
678
|
+
break;
|
|
679
|
+
cursor = maxCopied.get().m ?? cursor;
|
|
680
|
+
if (n < COPY_BATCH)
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
// 2. Rewrite live neighbour lists. A list with no dead member is copied
|
|
684
|
+
// blob-verbatim (the common case). A dead member is replaced by its own
|
|
685
|
+
// live neighbours at the same layer (1-hop splice), appended after the
|
|
686
|
+
// surviving originals in ascending-id order and truncated at the layer
|
|
687
|
+
// cap — deterministic, and the originals (chosen by the build heuristic)
|
|
688
|
+
// always take precedence.
|
|
689
|
+
const scan = db.prepare("SELECT node, layer, nbrs FROM links " +
|
|
690
|
+
"WHERE node > ? OR (node = ? AND layer > ?) " +
|
|
691
|
+
"ORDER BY node, layer LIMIT ?");
|
|
692
|
+
const insNew = db.prepare("INSERT INTO links_new (node, layer, nbrs) VALUES (?, ?, ?)");
|
|
693
|
+
// Small cache of dead nodes' lists — a dead hub is spliced by many of its
|
|
694
|
+
// former neighbours in a row.
|
|
695
|
+
const deadLists = new Map();
|
|
696
|
+
const DEAD_CACHE_MAX = 65_536;
|
|
697
|
+
const deadListOf = (id, layer) => {
|
|
698
|
+
const key = id + ":" + layer;
|
|
699
|
+
const hit = deadLists.get(key);
|
|
700
|
+
if (hit !== undefined)
|
|
701
|
+
return hit;
|
|
702
|
+
const row = this.sNbrs.get(id, layer);
|
|
703
|
+
const ids = row ? this.decodeNbrs(row[0]) : [];
|
|
704
|
+
if (deadLists.size >= DEAD_CACHE_MAX)
|
|
705
|
+
deadLists.clear();
|
|
706
|
+
deadLists.set(key, ids);
|
|
707
|
+
return ids;
|
|
708
|
+
};
|
|
709
|
+
const SCAN_BATCH = 20_000;
|
|
710
|
+
let curNode = -1, curLayer = -1;
|
|
711
|
+
for (;;) {
|
|
712
|
+
const rows = scan.all(curNode, curNode, curLayer, SCAN_BATCH);
|
|
713
|
+
if (rows.length === 0)
|
|
714
|
+
break;
|
|
715
|
+
db.exec("BEGIN");
|
|
716
|
+
for (const row of rows) {
|
|
717
|
+
curNode = row.node;
|
|
718
|
+
curLayer = row.layer;
|
|
719
|
+
if (isDead(row.node))
|
|
720
|
+
continue; // dead nodes leave no list behind
|
|
721
|
+
const ids = this.decodeNbrs(row.nbrs);
|
|
722
|
+
let hasDead = false;
|
|
723
|
+
for (let i = 0; i < ids.length; i++) {
|
|
724
|
+
if (isDead(ids[i])) {
|
|
725
|
+
hasDead = true;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!hasDead) {
|
|
730
|
+
insNew.run(row.node, row.layer, row.nbrs);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const cap = row.layer === 0 ? Mmax0 : M;
|
|
734
|
+
const keep = [];
|
|
735
|
+
const seen = new Set([row.node]);
|
|
736
|
+
const deadHere = [];
|
|
737
|
+
for (const id of ids) {
|
|
738
|
+
if (isDead(id))
|
|
739
|
+
deadHere.push(id);
|
|
740
|
+
else if (!seen.has(id)) {
|
|
741
|
+
seen.add(id);
|
|
742
|
+
keep.push(id);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (keep.length < cap) {
|
|
746
|
+
// Bridge candidates: the dead members' own live neighbours, in
|
|
747
|
+
// ascending id order for determinism.
|
|
748
|
+
const bridge = [];
|
|
749
|
+
for (const d of deadHere) {
|
|
750
|
+
for (const n of deadListOf(d, row.layer)) {
|
|
751
|
+
if (!isDead(n) && !seen.has(n)) {
|
|
752
|
+
seen.add(n);
|
|
753
|
+
bridge.push(n);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
bridge.sort((a, b) => a - b);
|
|
758
|
+
for (const b of bridge) {
|
|
759
|
+
if (keep.length >= cap)
|
|
760
|
+
break;
|
|
761
|
+
keep.push(b);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (keep.length > 0) {
|
|
765
|
+
insNew.run(row.node, row.layer, this.encodeNbrs(keep));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
db.exec("COMMIT");
|
|
769
|
+
if (rows.length < SCAN_BATCH)
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
// 3. Atomic swap: the real tables flip to the compacted copies in one
|
|
773
|
+
// transaction (the partial-index name frees up when the old table
|
|
774
|
+
// drops, so it is recreated on the renamed table here).
|
|
775
|
+
db.exec(`
|
|
776
|
+
BEGIN;
|
|
777
|
+
DROP TABLE nodes;
|
|
778
|
+
DROP TABLE links;
|
|
779
|
+
ALTER TABLE nodes_new RENAME TO nodes;
|
|
780
|
+
ALTER TABLE links_new RENAME TO links;
|
|
781
|
+
${EXT_INDEX};
|
|
782
|
+
COMMIT;
|
|
783
|
+
`);
|
|
784
|
+
this.prepareAll();
|
|
785
|
+
const live = db.prepare("SELECT COUNT(*) AS n FROM nodes").get().n;
|
|
786
|
+
let newEntry = entry;
|
|
787
|
+
let maxLevel = -1;
|
|
788
|
+
if (live === 0) {
|
|
789
|
+
newEntry = -1;
|
|
790
|
+
}
|
|
791
|
+
else if (entry === -1 || isDead(entry)) {
|
|
792
|
+
// The entry point died: promote the lowest-id node of the top level —
|
|
793
|
+
// deterministic, corpus-determined.
|
|
794
|
+
const top = db.prepare("SELECT id, level FROM nodes ORDER BY level DESC, id ASC LIMIT 1").get();
|
|
795
|
+
newEntry = top.id;
|
|
796
|
+
maxLevel = top.level;
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
maxLevel = db.prepare("SELECT level FROM nodes WHERE id = ?").get(entry).level;
|
|
800
|
+
}
|
|
801
|
+
return { entry: newEntry, maxLevel, live };
|
|
802
|
+
}
|
|
803
|
+
/** Stream live external ids with internal id > `after`, in internal-id
|
|
804
|
+
* order, in bounded batches. Internal ids are assigned monotonically and
|
|
805
|
+
* PRESERVED by {@link spliceCompact}, so a caller can use the largest
|
|
806
|
+
* internal id it has seen as a durable incremental watermark. */
|
|
807
|
+
*liveExtsSince(after, batch = 1024) {
|
|
808
|
+
const q = this.db.prepare("SELECT id, ext FROM nodes WHERE deleted = 0 AND id > ? ORDER BY id LIMIT ?");
|
|
809
|
+
let cursor = after;
|
|
810
|
+
for (;;) {
|
|
811
|
+
const rows = q.all(cursor, batch);
|
|
812
|
+
if (rows.length === 0)
|
|
813
|
+
break;
|
|
814
|
+
for (const r of rows)
|
|
815
|
+
yield { ext: r.ext, internal: r.id };
|
|
816
|
+
cursor = rows[rows.length - 1].id;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
vacuum() {
|
|
820
|
+
this.db.exec("VACUUM");
|
|
821
|
+
}
|
|
822
|
+
close() {
|
|
823
|
+
this.db.close();
|
|
824
|
+
}
|
|
825
|
+
}
|