rag-memory-epf-mcp 3.5.2 → 3.6.0
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 +10 -0
- package/dist/index.d.ts +50 -6
- package/dist/index.js +782 -188
- package/dist/src/backfillCoordinator.d.ts +59 -0
- package/dist/src/backfillCoordinator.js +552 -0
- package/dist/src/embeddingGate.d.ts +68 -0
- package/dist/src/embeddingGate.js +227 -0
- package/dist/src/migrations/migrations.js +71 -0
- package/dist/src/modelCache.d.ts +33 -0
- package/dist/src/modelCache.js +235 -0
- package/docs/UPDATING.md +121 -0
- package/package.json +8 -4
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type ReconState = 'pending' | 'running' | 'complete' | 'failed' | 'deferred' | 'n/a';
|
|
3
|
+
export interface CoverageSnapshot {
|
|
4
|
+
chunk: {
|
|
5
|
+
total: number;
|
|
6
|
+
embedded: number;
|
|
7
|
+
verified: number;
|
|
8
|
+
legacy_assumed: number;
|
|
9
|
+
};
|
|
10
|
+
entity: {
|
|
11
|
+
total: number;
|
|
12
|
+
embedded: number;
|
|
13
|
+
verified: number;
|
|
14
|
+
legacy_assumed: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface CoordinatorDeps {
|
|
18
|
+
db: () => Database.Database | null;
|
|
19
|
+
gateIsReady: () => boolean;
|
|
20
|
+
gateIsDisabled: () => boolean;
|
|
21
|
+
mode: () => 'lazy' | 'eager' | 'off';
|
|
22
|
+
grandfatherAllowed: () => boolean;
|
|
23
|
+
currentProfileId: () => number;
|
|
24
|
+
buildEntityInputHash: (entityId: string) => string | null;
|
|
25
|
+
hashEntityText: (text: string) => string;
|
|
26
|
+
chunkInputHash: (text: string) => string;
|
|
27
|
+
reembedEntity: (entityId: string) => Promise<boolean>;
|
|
28
|
+
reembedChunk: (rowid: number) => Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
export declare class BackfillCoordinator {
|
|
31
|
+
private readonly deps;
|
|
32
|
+
private recon;
|
|
33
|
+
private reconError?;
|
|
34
|
+
private reconPromise;
|
|
35
|
+
private kickTimer;
|
|
36
|
+
private sweepTimer;
|
|
37
|
+
private shuttingDown;
|
|
38
|
+
private scanning;
|
|
39
|
+
private scanPromise;
|
|
40
|
+
private rerunRequested;
|
|
41
|
+
private snapshot;
|
|
42
|
+
constructor(deps: CoordinatorDeps);
|
|
43
|
+
get reconState(): ReconState;
|
|
44
|
+
get reconLastError(): string | undefined;
|
|
45
|
+
get eligible(): boolean;
|
|
46
|
+
private countNullWithVector;
|
|
47
|
+
private countRepairables;
|
|
48
|
+
private sanitize;
|
|
49
|
+
runReconciliation(): Promise<void>;
|
|
50
|
+
private reconcileOnce;
|
|
51
|
+
private reconcileEntities;
|
|
52
|
+
private reconcileChunks;
|
|
53
|
+
kick(): void;
|
|
54
|
+
sweepStart(): void;
|
|
55
|
+
protected scanAndBackfill(): Promise<void>;
|
|
56
|
+
invalidateCoverage(): void;
|
|
57
|
+
coverage(): CoverageSnapshot;
|
|
58
|
+
shutdown(deadlineMs?: number): Promise<void>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// v3.6 lite install (spec 2026-07-18 v5 §3·§6b): BackfillCoordinator owns
|
|
2
|
+
// DB-side embedding recovery — nothing else (A′ boundary).
|
|
3
|
+
//
|
|
4
|
+
// Two phases share one eligibility barrier:
|
|
5
|
+
// reconciliation hash-only pass over legacy rows (provenance NULL). Runs in
|
|
6
|
+
// the background after connect; the model is NOT required.
|
|
7
|
+
// backfill re-embeds missing/stale rows via the gate. Automatic
|
|
8
|
+
// backfill (and vector search, enforced by callers) requires
|
|
9
|
+
// model_ready AND reconciliation ∈ {complete, n/a} — the
|
|
10
|
+
// barrier that prevents the "re-embed the whole legacy DB"
|
|
11
|
+
// race when the model becomes ready first (5R must-fix 1).
|
|
12
|
+
//
|
|
13
|
+
// Work queue = the DB itself (a row is "queued" iff it has no valid vector);
|
|
14
|
+
// kick() merely triggers a debounced scan. All reconciliation writes are
|
|
15
|
+
// per-row transactions with a CAS re-check so a foreground embed that lands
|
|
16
|
+
// first is never downgraded (6R note 3).
|
|
17
|
+
const RECON_BATCH = 50;
|
|
18
|
+
const KICK_DEBOUNCE_MS = 200;
|
|
19
|
+
const SWEEP_MS = 5 * 60_000;
|
|
20
|
+
export class BackfillCoordinator {
|
|
21
|
+
deps;
|
|
22
|
+
recon = 'pending';
|
|
23
|
+
reconError;
|
|
24
|
+
reconPromise = null;
|
|
25
|
+
kickTimer = null;
|
|
26
|
+
sweepTimer = null;
|
|
27
|
+
shuttingDown = false;
|
|
28
|
+
scanning = false;
|
|
29
|
+
scanPromise = null;
|
|
30
|
+
rerunRequested = false;
|
|
31
|
+
snapshot = null;
|
|
32
|
+
constructor(deps) {
|
|
33
|
+
this.deps = deps;
|
|
34
|
+
}
|
|
35
|
+
get reconState() { return this.recon; }
|
|
36
|
+
get reconLastError() { return this.reconError; }
|
|
37
|
+
// Shared eligibility barrier (spec §3): vector search AND automatic backfill.
|
|
38
|
+
// 'n/a' (fresh DB — nothing to reconcile) counts as satisfied (6R note 1).
|
|
39
|
+
get eligible() {
|
|
40
|
+
return this.deps.gateIsReady() && (this.recon === 'complete' || this.recon === 'n/a');
|
|
41
|
+
}
|
|
42
|
+
// "Unreconciled vector" = a row that actually HAS a vector and no provenance.
|
|
43
|
+
// Entity metadata is joined against entity_embeddings (beta B3): pre-v3.6
|
|
44
|
+
// non-atomic writes can leave metadata-without-vector and vector-without-
|
|
45
|
+
// metadata split states, and neither must be miscounted as reconciled work.
|
|
46
|
+
countNullWithVector(db) {
|
|
47
|
+
const ent = db.prepare(`SELECT COUNT(*) c FROM entity_embedding_metadata m JOIN entity_embeddings v ON v.rowid = m.rowid
|
|
48
|
+
WHERE m.provenance_state IS NULL`).get();
|
|
49
|
+
const chk = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
|
|
50
|
+
WHERE m.provenance_state IS NULL`).get();
|
|
51
|
+
return ent.c + chk.c;
|
|
52
|
+
}
|
|
53
|
+
// Sanitation (beta B3·B4) — runs on EVERY reconciliation entry, even when no
|
|
54
|
+
// NULL rows exist, so profile changes and legacy split states are repaired
|
|
55
|
+
// before vector eligibility can open:
|
|
56
|
+
// 1. entity vectors without metadata (orphans) -> delete vector
|
|
57
|
+
// 2. entity metadata without vector (split state) -> delete metadata (row becomes a backfill target)
|
|
58
|
+
// 3. provenance-stamped rows whose compatibility profile != current
|
|
59
|
+
// (old engine/model profile) -> delete-to-missing
|
|
60
|
+
// 4. chunk metadata stamped but vectorless -> normalize to missing (NULL provenance)
|
|
61
|
+
// Read-only repair check — used by off mode to classify deferred vs n/a
|
|
62
|
+
// without writing (beta 2R residual: split states and old profiles also
|
|
63
|
+
// count as pending repair work, not only provenance-NULL vectors).
|
|
64
|
+
countRepairables(db) {
|
|
65
|
+
const profileId = this.deps.currentProfileId();
|
|
66
|
+
const q = (sql, ...args) => db.prepare(sql).get(...args).c;
|
|
67
|
+
return this.countNullWithVector(db)
|
|
68
|
+
+ q(`SELECT COUNT(*) c FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata)`)
|
|
69
|
+
+ q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings)`)
|
|
70
|
+
+ q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE provenance_state IS NOT NULL AND profile_id IS NOT ?`, profileId)
|
|
71
|
+
+ q(`SELECT COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ?`, profileId)
|
|
72
|
+
// stamped chunk metadata whose vector is gone (beta 3R M3)
|
|
73
|
+
+ q(`SELECT COUNT(*) c FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks)`);
|
|
74
|
+
}
|
|
75
|
+
// Batched (beta 2R residual): each batch commits its own transaction and
|
|
76
|
+
// yields the event loop — a large DB or a profile switch must not stall the
|
|
77
|
+
// freshly connected server (the lite-install point of it all).
|
|
78
|
+
async sanitize(db) {
|
|
79
|
+
const profileId = this.deps.currentProfileId();
|
|
80
|
+
let touched = 0;
|
|
81
|
+
const batchTx = db.transaction((fn) => fn());
|
|
82
|
+
// Split-state cleanup, LIMIT-batched with yields (beta 3R non-blocker): a
|
|
83
|
+
// pathological DB must not stall the freshly connected server even here.
|
|
84
|
+
const batchedRun = async (sql) => {
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (this.shuttingDown)
|
|
87
|
+
throw new Error('shutdown during sanitation');
|
|
88
|
+
let changes = 0;
|
|
89
|
+
batchTx(() => { changes = db.prepare(sql).run().changes; });
|
|
90
|
+
touched += changes;
|
|
91
|
+
if (changes < RECON_BATCH)
|
|
92
|
+
return;
|
|
93
|
+
await new Promise(r => setImmediate(r));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await batchedRun(`DELETE FROM entity_embeddings WHERE rowid IN (
|
|
97
|
+
SELECT rowid FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata) LIMIT ${RECON_BATCH})`);
|
|
98
|
+
await batchedRun(`DELETE FROM entity_embedding_metadata WHERE rowid IN (
|
|
99
|
+
SELECT rowid FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings) LIMIT ${RECON_BATCH})`);
|
|
100
|
+
await batchedRun(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL
|
|
101
|
+
WHERE rowid IN (
|
|
102
|
+
SELECT rowid FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks) LIMIT ${RECON_BATCH})`);
|
|
103
|
+
// Old-profile rows: row deletes in bounded batches with yields.
|
|
104
|
+
for (;;) {
|
|
105
|
+
if (this.shuttingDown)
|
|
106
|
+
throw new Error('shutdown during sanitation');
|
|
107
|
+
const staleEnt = db.prepare(`SELECT m.rowid FROM entity_embedding_metadata m
|
|
108
|
+
WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? LIMIT ${RECON_BATCH}`).all(profileId);
|
|
109
|
+
if (staleEnt.length === 0)
|
|
110
|
+
break;
|
|
111
|
+
batchTx(() => {
|
|
112
|
+
for (const r of staleEnt) {
|
|
113
|
+
db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(r.rowid)}`);
|
|
114
|
+
db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(r.rowid);
|
|
115
|
+
touched++;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
await new Promise(r => setImmediate(r));
|
|
119
|
+
}
|
|
120
|
+
for (;;) {
|
|
121
|
+
if (this.shuttingDown)
|
|
122
|
+
throw new Error('shutdown during sanitation');
|
|
123
|
+
const staleChunk = db.prepare(`SELECT m.rowid FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
|
|
124
|
+
WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? LIMIT ${RECON_BATCH}`).all(profileId);
|
|
125
|
+
if (staleChunk.length === 0)
|
|
126
|
+
break;
|
|
127
|
+
batchTx(() => {
|
|
128
|
+
for (const r of staleChunk) {
|
|
129
|
+
db.exec(`DELETE FROM chunks WHERE rowid = ${Number(r.rowid)}`);
|
|
130
|
+
db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL WHERE rowid = ?`).run(r.rowid);
|
|
131
|
+
touched++;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
await new Promise(r => setImmediate(r));
|
|
135
|
+
}
|
|
136
|
+
if (touched > 0)
|
|
137
|
+
console.error(`🧾 provenance sanitation: ${touched} split-state/old-profile rows invalidated`);
|
|
138
|
+
return touched;
|
|
139
|
+
}
|
|
140
|
+
// Single-flight, idempotent. off mode defers (spec §9); fresh DBs go straight
|
|
141
|
+
// to 'n/a'. Row-level errors are fail-closed (vector deleted -> missing);
|
|
142
|
+
// systemic errors transition to 'failed' and keep eligibility shut.
|
|
143
|
+
runReconciliation() {
|
|
144
|
+
if (!this.reconPromise)
|
|
145
|
+
this.reconPromise = this.reconcileOnce();
|
|
146
|
+
return this.reconPromise;
|
|
147
|
+
}
|
|
148
|
+
async reconcileOnce() {
|
|
149
|
+
const db = this.deps.db();
|
|
150
|
+
if (!db) {
|
|
151
|
+
this.recon = 'failed';
|
|
152
|
+
this.reconError = 'db not initialized';
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (this.recon === 'complete' || this.recon === 'n/a')
|
|
156
|
+
return;
|
|
157
|
+
if (this.deps.mode() === 'off') {
|
|
158
|
+
// No writes in off mode — classification only (spec §3 N4). "Repair
|
|
159
|
+
// needed" includes split states and old profiles, not just NULL vectors
|
|
160
|
+
// (beta 2R residual).
|
|
161
|
+
this.recon = this.countRepairables(db) > 0 ? 'deferred' : 'n/a';
|
|
162
|
+
this.reconPromise = null; // a later lazy/eager restart may re-run
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await this.sanitize(db); // split states + old profiles first (beta B3·B4)
|
|
166
|
+
const legacy = this.countNullWithVector(db);
|
|
167
|
+
if (legacy === 0) {
|
|
168
|
+
this.recon = 'n/a';
|
|
169
|
+
this.kick();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.recon = 'running';
|
|
173
|
+
console.error(`🧾 provenance reconciliation: ${legacy} legacy vector rows to examine...`);
|
|
174
|
+
try {
|
|
175
|
+
await this.reconcileEntities(db);
|
|
176
|
+
await this.reconcileChunks(db);
|
|
177
|
+
const remaining = this.countNullWithVector(db);
|
|
178
|
+
if (remaining !== 0)
|
|
179
|
+
throw new Error(`complete invariant violated: ${remaining} unreconciled vectors remain`);
|
|
180
|
+
this.recon = 'complete';
|
|
181
|
+
this.snapshot = null;
|
|
182
|
+
console.error('✅ provenance reconciliation complete');
|
|
183
|
+
this.kick();
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (this.shuttingDown) {
|
|
187
|
+
// Interrupted by shutdown, not broken: next boot resumes the remaining
|
|
188
|
+
// NULL rows (per-row transactions make this crash-safe).
|
|
189
|
+
this.recon = 'pending';
|
|
190
|
+
this.reconPromise = null;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.recon = 'failed';
|
|
194
|
+
this.reconError = e instanceof Error ? e.message : String(e);
|
|
195
|
+
console.error(`❌ reconciliation failed — vector search stays disabled: ${this.reconError}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async reconcileEntities(db) {
|
|
199
|
+
const profileId = this.deps.currentProfileId();
|
|
200
|
+
const allow = this.deps.grandfatherAllowed();
|
|
201
|
+
// Malformed rows (NULL embedding_text / unbuildable current text) are
|
|
202
|
+
// dropped fail-closed AND recorded (spec §6b — beta 1R supplement).
|
|
203
|
+
const recordMalformed = db.prepare(`INSERT INTO embedding_backfill_failures (kind, target_id, input_hash, profile_id, attempts, last_error, updated_at)
|
|
204
|
+
VALUES ('entity', ?, NULL, ?, 0, ?, datetime('now'))
|
|
205
|
+
ON CONFLICT(kind, target_id) DO UPDATE SET last_error = excluded.last_error, updated_at = datetime('now')`);
|
|
206
|
+
for (;;) {
|
|
207
|
+
if (this.shuttingDown)
|
|
208
|
+
throw new Error('shutdown during reconciliation');
|
|
209
|
+
// Vector-join (beta B3): only rows that actually have a vector are
|
|
210
|
+
// grandfather candidates — metadata-without-vector was already removed by
|
|
211
|
+
// sanitize().
|
|
212
|
+
const rows = db.prepare(`SELECT m.rowid, m.entity_id, m.embedding_text FROM entity_embedding_metadata m
|
|
213
|
+
JOIN entity_embeddings v ON v.rowid = m.rowid
|
|
214
|
+
WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
|
|
215
|
+
if (rows.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
const dropTx = db.transaction(() => {
|
|
219
|
+
// CAS re-check inside the transaction: a foreground embed may have
|
|
220
|
+
// replaced this row with a verified vector in the meantime.
|
|
221
|
+
const cur = db.prepare(`SELECT provenance_state FROM entity_embedding_metadata WHERE rowid = ?`).get(row.rowid);
|
|
222
|
+
if (!cur || cur.provenance_state !== null)
|
|
223
|
+
return;
|
|
224
|
+
db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${row.rowid}`);
|
|
225
|
+
db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(row.rowid);
|
|
226
|
+
});
|
|
227
|
+
const stampTx = db.transaction((hash) => {
|
|
228
|
+
const cur = db.prepare(`SELECT provenance_state, embedding_text FROM entity_embedding_metadata WHERE rowid = ?`).get(row.rowid);
|
|
229
|
+
if (!cur || cur.provenance_state !== null || cur.embedding_text !== row.embedding_text)
|
|
230
|
+
return;
|
|
231
|
+
db.prepare(`UPDATE entity_embedding_metadata
|
|
232
|
+
SET input_hash = ?, profile_id = ?, provenance_state = 'legacy_assumed' WHERE rowid = ?`).run(hash, profileId, row.rowid);
|
|
233
|
+
});
|
|
234
|
+
// Malformed-row observability (beta 2R residual): the vector drop and
|
|
235
|
+
// the failure record commit in ONE transaction — a crash between the
|
|
236
|
+
// two cannot lose the record.
|
|
237
|
+
const dropAndRecord = db.transaction((reason) => {
|
|
238
|
+
dropTx();
|
|
239
|
+
recordMalformed.run(row.entity_id, profileId, reason);
|
|
240
|
+
});
|
|
241
|
+
try {
|
|
242
|
+
if (!allow) {
|
|
243
|
+
dropTx();
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (row.embedding_text === null) {
|
|
247
|
+
dropAndRecord('reconciliation: stored embedding_text is NULL');
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const currentHash = this.deps.buildEntityInputHash(row.entity_id);
|
|
251
|
+
if (currentHash === null) {
|
|
252
|
+
dropAndRecord('reconciliation: current entity text unbuildable (malformed observations?)');
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const storedHash = this.deps.hashEntityText(row.embedding_text);
|
|
256
|
+
if (storedHash === currentHash)
|
|
257
|
+
stampTx(currentHash);
|
|
258
|
+
else
|
|
259
|
+
dropTx(); // stale (e.g. old deleteObservations residue) -> missing, normal backfill target
|
|
260
|
+
}
|
|
261
|
+
catch (rowErr) {
|
|
262
|
+
// Row-level isolation: try to invalidate; if even that fails, escalate.
|
|
263
|
+
try {
|
|
264
|
+
dropTx();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Error(`row-level reconciliation failure on ${row.entity_id}: ${rowErr instanceof Error ? rowErr.message : rowErr}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await new Promise(r => setImmediate(r));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async reconcileChunks(db) {
|
|
275
|
+
const profileId = this.deps.currentProfileId();
|
|
276
|
+
const allow = this.deps.grandfatherAllowed();
|
|
277
|
+
for (;;) {
|
|
278
|
+
if (this.shuttingDown)
|
|
279
|
+
throw new Error('shutdown during reconciliation');
|
|
280
|
+
const rows = db.prepare(`SELECT m.rowid, m.text FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
|
|
281
|
+
WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
|
|
282
|
+
if (rows.length === 0)
|
|
283
|
+
return;
|
|
284
|
+
for (const row of rows) {
|
|
285
|
+
const dropTx = db.transaction(() => {
|
|
286
|
+
const cur = db.prepare(`SELECT provenance_state FROM chunk_metadata WHERE rowid = ?`)
|
|
287
|
+
.get(row.rowid);
|
|
288
|
+
if (!cur || cur.provenance_state !== null)
|
|
289
|
+
return;
|
|
290
|
+
// Vector removed; provenance stays NULL. The barrier only protects
|
|
291
|
+
// VECTOR-BEARING NULL rows — vectorless rows are always legitimate
|
|
292
|
+
// backfill targets (spec §6c "벡터 없음").
|
|
293
|
+
db.exec(`DELETE FROM chunks WHERE rowid = ${row.rowid}`);
|
|
294
|
+
db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL WHERE rowid = ?`)
|
|
295
|
+
.run(row.rowid);
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
if (!allow || row.text === null) {
|
|
299
|
+
dropTx();
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// chunk_metadata.text is never updated in place (runtime code is
|
|
303
|
+
// INSERT/DELETE only — regression-locked in migration12 test), so the
|
|
304
|
+
// stored vector's input IS the current text (spec §6b).
|
|
305
|
+
const hash = this.deps.chunkInputHash(row.text);
|
|
306
|
+
const stampTx = db.transaction(() => {
|
|
307
|
+
const cur = db.prepare(`SELECT provenance_state FROM chunk_metadata WHERE rowid = ?`)
|
|
308
|
+
.get(row.rowid);
|
|
309
|
+
if (!cur || cur.provenance_state !== null)
|
|
310
|
+
return;
|
|
311
|
+
db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'legacy_assumed'
|
|
312
|
+
WHERE rowid = ?`).run(hash, profileId, row.rowid);
|
|
313
|
+
});
|
|
314
|
+
stampTx();
|
|
315
|
+
}
|
|
316
|
+
catch (rowErr) {
|
|
317
|
+
try {
|
|
318
|
+
dropTx();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
throw new Error(`row-level chunk reconciliation failure on rowid ${row.rowid}: ${rowErr instanceof Error ? rowErr.message : rowErr}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
await new Promise(r => setImmediate(r));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Debounced automatic-backfill trigger. No-op unless the shared barrier is
|
|
329
|
+
// open; disabled mode never scans, never records failures (spec §3 N4).
|
|
330
|
+
kick() {
|
|
331
|
+
if (this.shuttingDown || this.deps.gateIsDisabled())
|
|
332
|
+
return;
|
|
333
|
+
if (!this.eligible)
|
|
334
|
+
return;
|
|
335
|
+
// A kick landing during a running scan must not be LOST (beta 4R M2): the
|
|
336
|
+
// running scan works from a snapshot and may miss rows created after it —
|
|
337
|
+
// record the wake-up and re-run once the current scan settles, without
|
|
338
|
+
// overwriting the tracked scanPromise.
|
|
339
|
+
if (this.scanPromise || this.scanning) {
|
|
340
|
+
this.rerunRequested = true;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (this.kickTimer)
|
|
344
|
+
return;
|
|
345
|
+
this.kickTimer = setTimeout(() => {
|
|
346
|
+
this.kickTimer = null;
|
|
347
|
+
// Tracked so shutdown can await the whole scan, not just poll `scanning`
|
|
348
|
+
// (beta 2R B2).
|
|
349
|
+
this.scanPromise = this.scanAndBackfill()
|
|
350
|
+
.catch(e => console.error(`⚠️ backfill scan error: ${e instanceof Error ? e.message : e}`))
|
|
351
|
+
.finally(() => {
|
|
352
|
+
this.scanPromise = null;
|
|
353
|
+
if (this.rerunRequested && !this.shuttingDown) {
|
|
354
|
+
this.rerunRequested = false;
|
|
355
|
+
this.kick();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}, KICK_DEBOUNCE_MS);
|
|
359
|
+
this.kickTimer.unref?.();
|
|
360
|
+
}
|
|
361
|
+
sweepStart() {
|
|
362
|
+
if (this.deps.mode() === 'off')
|
|
363
|
+
return;
|
|
364
|
+
if (this.sweepTimer)
|
|
365
|
+
return;
|
|
366
|
+
this.sweepTimer = setInterval(() => this.kick(), SWEEP_MS);
|
|
367
|
+
this.sweepTimer.unref?.();
|
|
368
|
+
}
|
|
369
|
+
// Automatic backfill (spec §6c): targets = no vector ∪ input-hash mismatch ∪
|
|
370
|
+
// compatibility-profile mismatch — but NEVER a vector-bearing provenance-NULL
|
|
371
|
+
// row (those belong to reconciliation; 5R barrier). Chunks first (hybridSearch
|
|
372
|
+
// coverage recovers before entity/graph quality). Failures are recorded with
|
|
373
|
+
// a per-target attempts cap; 3 consecutive DISTINCT-target failures abort the
|
|
374
|
+
// batch as a systemic model problem (the gate's retry policy owns recovery).
|
|
375
|
+
async scanAndBackfill() {
|
|
376
|
+
if (this.scanning)
|
|
377
|
+
return;
|
|
378
|
+
this.scanning = true;
|
|
379
|
+
try {
|
|
380
|
+
const db = this.deps.db();
|
|
381
|
+
if (!db || !this.eligible)
|
|
382
|
+
return;
|
|
383
|
+
const profileId = this.deps.currentProfileId();
|
|
384
|
+
const capStmt = db.prepare(`SELECT attempts, input_hash, profile_id FROM embedding_backfill_failures WHERE kind = ? AND target_id = ?`);
|
|
385
|
+
const failStmt = db.prepare(`INSERT INTO embedding_backfill_failures (kind, target_id, input_hash, profile_id, attempts, last_error, updated_at)
|
|
386
|
+
VALUES (?, ?, ?, ?, 1, ?, datetime('now'))
|
|
387
|
+
ON CONFLICT(kind, target_id) DO UPDATE SET
|
|
388
|
+
attempts = attempts + 1, input_hash = excluded.input_hash,
|
|
389
|
+
profile_id = excluded.profile_id, last_error = excluded.last_error, updated_at = datetime('now')`);
|
|
390
|
+
const clearStmt = db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = ? AND target_id = ?`);
|
|
391
|
+
const FAIL_CAP = 5;
|
|
392
|
+
let consecutive = 0;
|
|
393
|
+
let lastFailedTarget = null;
|
|
394
|
+
const shouldSkip = (kind, targetId, currentHash) => {
|
|
395
|
+
const f = capStmt.get(kind, targetId);
|
|
396
|
+
if (!f)
|
|
397
|
+
return false;
|
|
398
|
+
// Reset-on-change: content or profile moved on -> the failure record is stale.
|
|
399
|
+
if (f.input_hash !== currentHash || f.profile_id !== profileId) {
|
|
400
|
+
clearStmt.run(kind, targetId);
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
return f.attempts >= FAIL_CAP;
|
|
404
|
+
};
|
|
405
|
+
const recordResult = async (kind, targetId, currentHash, run) => {
|
|
406
|
+
try {
|
|
407
|
+
const ok = await run();
|
|
408
|
+
// beta 2R B2: an inference can outlive both settle deadlines. After
|
|
409
|
+
// ANY await, no DB statement may run once shutdown started — the DB
|
|
410
|
+
// handle may already be closed.
|
|
411
|
+
if (this.shuttingDown)
|
|
412
|
+
return 'abort';
|
|
413
|
+
if (ok) {
|
|
414
|
+
clearStmt.run(kind, targetId);
|
|
415
|
+
consecutive = 0;
|
|
416
|
+
return 'ok';
|
|
417
|
+
}
|
|
418
|
+
failStmt.run(kind, targetId, currentHash, profileId, 'reembed returned false');
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
if (this.shuttingDown)
|
|
422
|
+
return 'abort';
|
|
423
|
+
failStmt.run(kind, targetId, currentHash, profileId, e instanceof Error ? e.message.slice(0, 300) : String(e));
|
|
424
|
+
}
|
|
425
|
+
if (lastFailedTarget !== targetId) {
|
|
426
|
+
consecutive++;
|
|
427
|
+
lastFailedTarget = targetId;
|
|
428
|
+
}
|
|
429
|
+
return consecutive >= 3 ? 'abort' : 'fail';
|
|
430
|
+
};
|
|
431
|
+
// Phase 1: chunks.
|
|
432
|
+
const chunkRows = db.prepare(`SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state,
|
|
433
|
+
EXISTS(SELECT 1 FROM chunks v WHERE v.rowid = m.rowid) AS has_vec
|
|
434
|
+
FROM chunk_metadata m WHERE m.text IS NOT NULL`).all();
|
|
435
|
+
let processed = 0;
|
|
436
|
+
for (const row of chunkRows) {
|
|
437
|
+
if (this.shuttingDown || !this.eligible)
|
|
438
|
+
return;
|
|
439
|
+
const currentHash = this.deps.chunkInputHash(row.text);
|
|
440
|
+
const isTarget = !row.has_vec
|
|
441
|
+
|| (row.provenance_state !== null && (row.profile_id !== profileId || row.input_hash !== currentHash));
|
|
442
|
+
if (!isTarget)
|
|
443
|
+
continue;
|
|
444
|
+
const targetId = String(row.rowid);
|
|
445
|
+
if (shouldSkip('chunk', targetId, currentHash))
|
|
446
|
+
continue;
|
|
447
|
+
const outcome = await recordResult('chunk', targetId, currentHash, () => this.deps.reembedChunk(row.rowid));
|
|
448
|
+
if (outcome === 'abort') {
|
|
449
|
+
console.error('⚠️ backfill aborted: 3 consecutive distinct-target failures (systemic)');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (++processed % 8 === 0)
|
|
453
|
+
await new Promise(r => setImmediate(r));
|
|
454
|
+
}
|
|
455
|
+
// Phase 2: entities.
|
|
456
|
+
const entityRows = db.prepare(`SELECT e.id, m.input_hash, m.profile_id, m.provenance_state, m.rowid AS meta_rowid,
|
|
457
|
+
CASE WHEN m.rowid IS NOT NULL AND EXISTS(SELECT 1 FROM entity_embeddings v WHERE v.rowid = m.rowid) THEN 1 ELSE 0 END AS has_vec
|
|
458
|
+
FROM entities e LEFT JOIN entity_embedding_metadata m ON m.entity_id = e.id`).all();
|
|
459
|
+
for (const row of entityRows) {
|
|
460
|
+
if (this.shuttingDown || !this.eligible)
|
|
461
|
+
return;
|
|
462
|
+
const currentHash = this.deps.buildEntityInputHash(row.id);
|
|
463
|
+
if (currentHash === null)
|
|
464
|
+
continue; // malformed entity: leave to explicit repair
|
|
465
|
+
// Vector existence checked explicitly (beta B3): metadata alone is not
|
|
466
|
+
// proof of an embedded row.
|
|
467
|
+
const isTarget = row.meta_rowid === null || !row.has_vec
|
|
468
|
+
|| (row.provenance_state !== null && (row.profile_id !== profileId || row.input_hash !== currentHash));
|
|
469
|
+
if (!isTarget)
|
|
470
|
+
continue;
|
|
471
|
+
if (shouldSkip('entity', row.id, currentHash))
|
|
472
|
+
continue;
|
|
473
|
+
const outcome = await recordResult('entity', row.id, currentHash, () => this.deps.reembedEntity(row.id));
|
|
474
|
+
if (outcome === 'abort') {
|
|
475
|
+
console.error('⚠️ backfill aborted: 3 consecutive distinct-target failures (systemic)');
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (++processed % 8 === 0)
|
|
479
|
+
await new Promise(r => setImmediate(r));
|
|
480
|
+
}
|
|
481
|
+
if (processed > 0) {
|
|
482
|
+
this.snapshot = null;
|
|
483
|
+
console.error(`✅ backfill pass complete (${processed} rows examined for re-embedding)`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
this.scanning = false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
invalidateCoverage() { this.snapshot = null; }
|
|
491
|
+
coverage() {
|
|
492
|
+
const db = this.deps.db();
|
|
493
|
+
if (!db)
|
|
494
|
+
return { chunk: { total: 0, embedded: 0, verified: 0, legacy_assumed: 0 },
|
|
495
|
+
entity: { total: 0, embedded: 0, verified: 0, legacy_assumed: 0 } };
|
|
496
|
+
if (this.snapshot)
|
|
497
|
+
return this.snapshot;
|
|
498
|
+
const chunkTotal = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata`).get().c;
|
|
499
|
+
const chunkBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
|
|
500
|
+
GROUP BY m.provenance_state`).all();
|
|
501
|
+
const entTotal = db.prepare(`SELECT COUNT(*) c FROM entities`).get().c;
|
|
502
|
+
// Vector-join (beta B3): metadata without an actual vector is missing, not embedded.
|
|
503
|
+
const entBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM entity_embedding_metadata m
|
|
504
|
+
JOIN entity_embeddings v ON v.rowid = m.rowid GROUP BY m.provenance_state`).all();
|
|
505
|
+
const pick = (rows, s) => rows.find(r => r.s === s)?.c ?? 0;
|
|
506
|
+
// Searchable = verified + legacy_assumed. NULL rows (pre-reconciliation or
|
|
507
|
+
// deferred in off mode) are NOT counted as embedded (6R note 4).
|
|
508
|
+
const chunkVerified = pick(chunkBy, 'verified');
|
|
509
|
+
const chunkLegacy = pick(chunkBy, 'legacy_assumed');
|
|
510
|
+
const entVerified = pick(entBy, 'verified');
|
|
511
|
+
const entLegacy = pick(entBy, 'legacy_assumed');
|
|
512
|
+
this.snapshot = {
|
|
513
|
+
chunk: { total: chunkTotal, embedded: chunkVerified + chunkLegacy, verified: chunkVerified, legacy_assumed: chunkLegacy },
|
|
514
|
+
entity: { total: entTotal, embedded: entVerified + entLegacy, verified: entVerified, legacy_assumed: entLegacy },
|
|
515
|
+
};
|
|
516
|
+
return this.snapshot;
|
|
517
|
+
}
|
|
518
|
+
// spec §3 shutdown order (beta B1): block new batches, let the current row
|
|
519
|
+
// transaction finish, settle BOTH the backfill loop and an in-flight
|
|
520
|
+
// reconciliation pass, then the caller closes the DB — nothing may touch a
|
|
521
|
+
// closed handle afterwards.
|
|
522
|
+
async shutdown(deadlineMs = 5000) {
|
|
523
|
+
this.shuttingDown = true;
|
|
524
|
+
if (this.kickTimer) {
|
|
525
|
+
clearTimeout(this.kickTimer);
|
|
526
|
+
this.kickTimer = null;
|
|
527
|
+
}
|
|
528
|
+
if (this.sweepTimer) {
|
|
529
|
+
clearInterval(this.sweepTimer);
|
|
530
|
+
this.sweepTimer = null;
|
|
531
|
+
}
|
|
532
|
+
const deadline = Date.now() + deadlineMs;
|
|
533
|
+
if (this.reconPromise) {
|
|
534
|
+
await Promise.race([
|
|
535
|
+
this.reconPromise.catch(() => { }),
|
|
536
|
+
new Promise(r => setTimeout(r, Math.max(0, deadline - Date.now()))),
|
|
537
|
+
]);
|
|
538
|
+
}
|
|
539
|
+
if (this.scanPromise) {
|
|
540
|
+
await Promise.race([
|
|
541
|
+
this.scanPromise,
|
|
542
|
+
new Promise(r => setTimeout(r, Math.max(0, deadline - Date.now()))),
|
|
543
|
+
]);
|
|
544
|
+
}
|
|
545
|
+
while (this.scanning && Date.now() < deadline) {
|
|
546
|
+
await new Promise(r => setTimeout(r, 20));
|
|
547
|
+
}
|
|
548
|
+
// Past the deadline a scan may still be pending on a slow inference — the
|
|
549
|
+
// shuttingDown guards inside recordResult() make any late completion a
|
|
550
|
+
// DB-write no-op (beta 2R B2).
|
|
551
|
+
}
|
|
552
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type ModelState = 'disabled' | 'idle' | 'loading' | 'downloading' | 'ready' | 'failed';
|
|
2
|
+
export type EmbedPriority = 'interactive' | 'bulk' | 'backfill';
|
|
3
|
+
export type EmbedFn = (text: string, dims: number, isQuery: boolean) => Promise<Float32Array>;
|
|
4
|
+
export declare class GateDisabledError extends Error {
|
|
5
|
+
readonly code = "EMBEDDINGS_DISABLED";
|
|
6
|
+
readonly state = "disabled";
|
|
7
|
+
constructor();
|
|
8
|
+
}
|
|
9
|
+
export declare class GateNotReadyError extends Error {
|
|
10
|
+
readonly state: ModelState;
|
|
11
|
+
readonly retryAfterMs?: number | undefined;
|
|
12
|
+
readonly code = "MODEL_NOT_READY";
|
|
13
|
+
constructor(state: ModelState, retryAfterMs?: number | undefined);
|
|
14
|
+
}
|
|
15
|
+
export declare class TerminalConfigError extends Error {
|
|
16
|
+
readonly terminal = true;
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
export interface GateOptions {
|
|
20
|
+
mode: 'lazy' | 'eager' | 'off';
|
|
21
|
+
loadModel: () => Promise<EmbedFn>;
|
|
22
|
+
onReady?: () => void;
|
|
23
|
+
onStateChange?: (s: ModelState) => void;
|
|
24
|
+
backoffMs?: number[];
|
|
25
|
+
}
|
|
26
|
+
export declare class EmbeddingGate {
|
|
27
|
+
private readonly opts;
|
|
28
|
+
private state;
|
|
29
|
+
private embedFn;
|
|
30
|
+
private startPromise;
|
|
31
|
+
private queue;
|
|
32
|
+
private running;
|
|
33
|
+
private seq;
|
|
34
|
+
private generation;
|
|
35
|
+
private shuttingDown;
|
|
36
|
+
private retryTimer;
|
|
37
|
+
private attempt;
|
|
38
|
+
private failedInputs;
|
|
39
|
+
private demotions;
|
|
40
|
+
private terminalFailure;
|
|
41
|
+
readonly abort: AbortController;
|
|
42
|
+
private readySince?;
|
|
43
|
+
private lastError?;
|
|
44
|
+
private retryAt?;
|
|
45
|
+
private readonly backoff;
|
|
46
|
+
constructor(opts: GateOptions);
|
|
47
|
+
get status(): {
|
|
48
|
+
state: ModelState;
|
|
49
|
+
readySince: string | undefined;
|
|
50
|
+
lastError: string | undefined;
|
|
51
|
+
retryAt: string | undefined;
|
|
52
|
+
};
|
|
53
|
+
get isReady(): boolean;
|
|
54
|
+
get isDisabled(): boolean;
|
|
55
|
+
private setState;
|
|
56
|
+
start(): Promise<void>;
|
|
57
|
+
markDownloading(): void;
|
|
58
|
+
private loadOnce;
|
|
59
|
+
private scheduleRetry;
|
|
60
|
+
embed(text: string, o: {
|
|
61
|
+
dims?: number;
|
|
62
|
+
isQuery?: boolean;
|
|
63
|
+
priority: EmbedPriority;
|
|
64
|
+
}): Promise<Float32Array>;
|
|
65
|
+
private pump;
|
|
66
|
+
get loadInFlight(): boolean;
|
|
67
|
+
shutdown(deadlineMs?: number): Promise<void>;
|
|
68
|
+
}
|