sweet-search 2.5.13 → 2.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 +36 -9
- package/core/cli.js +41 -3
- package/core/embedding/embedding-local-model.js +106 -10
- package/core/embedding/embedding-service.js +59 -1
- package/core/embedding/model-client.mjs +257 -0
- package/core/embedding/model-server.mjs +217 -0
- package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
- package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
- package/core/incremental-indexing/application/operator-cli.mjs +14 -5
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
- package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
- package/core/incremental-indexing/application/reconciler.mjs +87 -15
- package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
- package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
- package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
- package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
- package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
- package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
- package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
- package/core/indexing/artifact-builder.js +1 -1
- package/core/indexing/dedup/dedup-phase.js +36 -17
- package/core/indexing/dedup/exemplar-selector.js +5 -0
- package/core/indexing/index-codebase-v21.js +37 -14
- package/core/indexing/index-maintainer.mjs +337 -6
- package/core/indexing/indexer-ann.js +27 -434
- package/core/indexing/indexer-build.js +30 -14
- package/core/indexing/indexer-manifest.js +0 -3
- package/core/indexing/indexer-phases.js +101 -25
- package/core/indexing/maintainer-launcher.mjs +22 -0
- package/core/indexing/maintainer-watcher.mjs +397 -0
- package/core/indexing/os-priority.mjs +160 -0
- package/core/indexing/rss-budget.mjs +425 -0
- package/core/indexing/streaming-vectors.js +450 -0
- package/core/infrastructure/config/platform.js +14 -10
- package/core/infrastructure/onnx-session-utils.js +37 -0
- package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
- package/core/ranking/late-interaction-index.js +58 -7
- package/core/search/daemon-registry.js +199 -0
- package/core/search/search-read-semantic.js +9 -3
- package/core/search/search-semantic.js +6 -29
- package/core/search/search-server.js +527 -27
- package/core/search/session-daemon-prewarm.mjs +110 -1
- package/core/search/sweet-search.js +0 -38
- package/core/vector-store/binary-hnsw-index.js +692 -78
- package/core/vector-store/index.js +1 -4
- package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
- package/eval/agent-read-workflows/bin/ss-read +2 -0
- package/mcp/tool-handlers.js +1 -2
- package/package.json +11 -8
- package/scripts/uninstall.js +2 -0
- package/core/vector-store/hnsw-index.js +0 -751
|
@@ -46,14 +46,13 @@ import { verifyStamp, writeStamp, formatStampMismatch } from '../infrastructure/
|
|
|
46
46
|
* persistManifest(manifest): Promise<void>,
|
|
47
47
|
* applyGraphDelta(file, parsed, epoch): Promise<{ ops }>,
|
|
48
48
|
* applyVectorDelta(file, chunks, hashes, epoch): Promise<{ ops }>,
|
|
49
|
-
* applyHNSWDelta(file, vectorOps, epoch): Promise<{ ops }>,
|
|
50
49
|
* applyBinaryHNSWDelta(file, vectorOps, epoch): Promise<{ ops }>,
|
|
51
50
|
* applyLIDelta(file, tokenOps, epoch): Promise<{ ops }>,
|
|
52
51
|
* applySparseGramDelta(file, gramOps, epoch): Promise<{ ops }>,
|
|
53
52
|
* // Any apply* call may also return either:
|
|
54
53
|
* // { manifest: {...tier descriptor...} }
|
|
55
54
|
* // or:
|
|
56
|
-
* // { manifestTiers: { sparseGram: {...},
|
|
55
|
+
* // { manifestTiers: { sparseGram: {...}, binaryHnsw: {...} } }
|
|
57
56
|
* // These descriptors are merged into the next epoch manifest.
|
|
58
57
|
* readMaintenanceState(ctx): Promise<object>|object,
|
|
59
58
|
* scheduleMaintenance(job): Promise<void>|void,
|
|
@@ -70,7 +69,6 @@ const DEFAULT_FILES_PER_TICK = 50;
|
|
|
70
69
|
const MANIFEST_TIER_KEYS = new Set([
|
|
71
70
|
'codeGraph',
|
|
72
71
|
'vectors',
|
|
73
|
-
'hnsw',
|
|
74
72
|
'binaryHnsw',
|
|
75
73
|
'lateInteraction',
|
|
76
74
|
'sparseGram',
|
|
@@ -171,6 +169,27 @@ export class Reconciler {
|
|
|
171
169
|
this._running = false;
|
|
172
170
|
}
|
|
173
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Lever E.1: batch per-tier writes per tick. When enabled, the reconciler
|
|
174
|
+
* opens a tick-scoped store context once at tick start (via the adapter's
|
|
175
|
+
* `beginTick` hook), threads it through every `apply*Delta`, and persists the
|
|
176
|
+
* batched HNSW + float artifacts once at tick finalize (via `finalizeTick`),
|
|
177
|
+
* BEFORE the manifest advances. Default off → exact current per-file behavior.
|
|
178
|
+
*
|
|
179
|
+
* The adapter must expose `beginTick`/`finalizeTick` for this to engage;
|
|
180
|
+
* absent those hooks, the per-file path is used regardless of the flag.
|
|
181
|
+
*
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
_batchTierWritesEnabled() {
|
|
185
|
+
// Default-ON (verified byte-identical with det-levels + crash-safe + soak==baseline);
|
|
186
|
+
// disable with SWEET_SEARCH_RECONCILE_BATCH_TIER_WRITES=0. Still requires the adapter
|
|
187
|
+
// to expose the tick hooks; absent those, the per-file path is used regardless.
|
|
188
|
+
return process.env.SWEET_SEARCH_RECONCILE_BATCH_TIER_WRITES !== '0'
|
|
189
|
+
&& typeof this.adapters.beginTick === 'function'
|
|
190
|
+
&& typeof this.adapters.finalizeTick === 'function';
|
|
191
|
+
}
|
|
192
|
+
|
|
174
193
|
progress(phase) {
|
|
175
194
|
this.onProgress?.(phase);
|
|
176
195
|
}
|
|
@@ -240,6 +259,8 @@ export class Reconciler {
|
|
|
240
259
|
let dirtyCursor = 0;
|
|
241
260
|
let deferredRequeued = false;
|
|
242
261
|
let manifestPublished = false;
|
|
262
|
+
const batched = this._batchTierWritesEnabled();
|
|
263
|
+
let tickCtx = null;
|
|
243
264
|
|
|
244
265
|
try {
|
|
245
266
|
dirty = await this.adapters.readDirtySet();
|
|
@@ -248,6 +269,14 @@ export class Reconciler {
|
|
|
248
269
|
|
|
249
270
|
counters.set('cpu_budget_total_ms', this.config.cpuBudgetMs);
|
|
250
271
|
|
|
272
|
+
// Lever E.1: open the tick-scoped store context once (load HNSW + float
|
|
273
|
+
// store, open RW/RO connections) before the per-file loop. Threaded into
|
|
274
|
+
// every apply*Delta so they accumulate ops instead of open/save per file.
|
|
275
|
+
if (batched) {
|
|
276
|
+
tickCtx = await this.adapters.beginTick({ epoch });
|
|
277
|
+
this.progress('reconciler:tick-begin');
|
|
278
|
+
}
|
|
279
|
+
|
|
251
280
|
// Track per-file outcomes for the tick summary.
|
|
252
281
|
const tierOps = {};
|
|
253
282
|
const manifestTiers = {};
|
|
@@ -266,7 +295,7 @@ export class Reconciler {
|
|
|
266
295
|
counters.observeContentUnchanged();
|
|
267
296
|
continue;
|
|
268
297
|
}
|
|
269
|
-
const fileRes = await this._reconcileOneFile(file, epoch, hashes);
|
|
298
|
+
const fileRes = await this._reconcileOneFile(file, epoch, hashes, tickCtx);
|
|
270
299
|
this.progress('reconciler:file:done');
|
|
271
300
|
filesProcessed.push({ file, ...fileRes });
|
|
272
301
|
mergeManifestTiers(manifestTiers, fileRes?.manifestTiers);
|
|
@@ -304,6 +333,23 @@ export class Reconciler {
|
|
|
304
333
|
}
|
|
305
334
|
}
|
|
306
335
|
|
|
336
|
+
// Lever E.1 PERSIST-BEFORE-ADVANCE: save the batched HNSW + float store
|
|
337
|
+
// ONCE here, BEFORE the manifest publishes. The adapter returns the set
|
|
338
|
+
// of files whose ops are in the persisted batch (and, on a partial batch
|
|
339
|
+
// cut, the files to requeue). Only persisted files are promoted into the
|
|
340
|
+
// merkle (the adapter reads the same context inside `persistManifest`),
|
|
341
|
+
// so a crash between the SQLite commits and this save can never leave a
|
|
342
|
+
// file marked indexed-but-missing-from-HNSW.
|
|
343
|
+
if (batched && tickCtx) {
|
|
344
|
+
const fin = await this.adapters.finalizeTick(tickCtx, { epoch }) || {};
|
|
345
|
+
this.progress('reconciler:tick-finalized');
|
|
346
|
+
const requeueFiles = Array.isArray(fin.requeueFiles) ? fin.requeueFiles : [];
|
|
347
|
+
if (requeueFiles.length > 0 && this.adapters.requeueDirtyFiles) {
|
|
348
|
+
counters.inc('dirty_paths_deferred', requeueFiles.length);
|
|
349
|
+
await this.adapters.requeueDirtyFiles(requeueFiles);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
307
353
|
// Publish the new manifest. Plan § 8.1 step 4: write to *.tmp,
|
|
308
354
|
// fsync, atomic rename, fsync parent dir. `writeManifest` already
|
|
309
355
|
// does that.
|
|
@@ -355,35 +401,61 @@ export class Reconciler {
|
|
|
355
401
|
}
|
|
356
402
|
throw err;
|
|
357
403
|
} finally {
|
|
404
|
+
// Lever E.1: always release the tick-scoped store context (close any
|
|
405
|
+
// still-open RW/RO connections). Idempotent: a successful finalizeTick
|
|
406
|
+
// already closed them; this is the crash/throw safety net. Best-effort.
|
|
407
|
+
if (batched && tickCtx && typeof this.adapters.disposeTick === 'function') {
|
|
408
|
+
try { await this.adapters.disposeTick(tickCtx); } catch { /* never throw from finally */ }
|
|
409
|
+
}
|
|
358
410
|
this._running = false;
|
|
359
411
|
}
|
|
360
412
|
}
|
|
361
413
|
|
|
362
|
-
async _reconcileOneFile(file, epoch, hashes) {
|
|
414
|
+
async _reconcileOneFile(file, epoch, hashes, tickCtx = null) {
|
|
363
415
|
// Dispatch to per-tier adapter methods. Adapters can return undefined
|
|
364
|
-
// when a tier has no work for this file.
|
|
416
|
+
// when a tier has no work for this file. `tickCtx` is the lever-E.1
|
|
417
|
+
// tick-scoped store context (null on the per-file path).
|
|
365
418
|
const ops = {};
|
|
366
419
|
this.progress('reconciler:graph:start');
|
|
367
|
-
const graph = await this.adapters.applyGraphDelta?.(file, hashes, epoch);
|
|
420
|
+
const graph = await this.adapters.applyGraphDelta?.(file, hashes, epoch, tickCtx);
|
|
368
421
|
this.progress('reconciler:graph:done');
|
|
369
422
|
const manifestTiers = {};
|
|
370
423
|
collectManifestTier(manifestTiers, 'codeGraph', graph);
|
|
371
424
|
if (graph?.ops?.graph_upsert != null) ops.graph_upsert = graph.ops.graph_upsert;
|
|
372
425
|
if (graph?.ops?.graph_tombstone != null) ops.graph_tombstone = graph.ops.graph_tombstone;
|
|
373
426
|
this.progress('reconciler:vector:start');
|
|
374
|
-
const vec = await this.adapters.applyVectorDelta?.(file, hashes?.chunks ?? [], hashes, epoch);
|
|
427
|
+
const vec = await this.adapters.applyVectorDelta?.(file, hashes?.chunks ?? [], hashes, epoch, tickCtx);
|
|
375
428
|
this.progress('reconciler:vector:done');
|
|
376
429
|
collectManifestTier(manifestTiers, 'vectors', vec);
|
|
377
430
|
if (vec?.ops?.vectors_upsert != null) ops.vectors_upsert = vec.ops.vectors_upsert;
|
|
378
431
|
if (vec?.ops?.vectors_delete != null) ops.vectors_delete = vec.ops.vectors_delete;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (
|
|
432
|
+
// Lever E.6: a cutoff-skipped file returns `skipped:true` from the vector
|
|
433
|
+
// adapter — no encode, no dense/HNSW/LI/graph writes. BUT sparse-grams are
|
|
434
|
+
// derived from RAW file content (not encoder inputs), so a content change
|
|
435
|
+
// with byte-identical encoder inputs still needs a sparse update or ss-grep
|
|
436
|
+
// goes stale. Apply ONLY the sparse-gram tier on cutoff-skip, then return.
|
|
437
|
+
if (vec?.skipped) {
|
|
438
|
+
this.progress('reconciler:sparse:start');
|
|
439
|
+
const sgSkip = await this.adapters.applySparseGramDelta?.(file, vec?.gramOps ?? [], epoch);
|
|
440
|
+
this.progress('reconciler:sparse:done');
|
|
441
|
+
collectManifestTier(manifestTiers, 'sparseGram', sgSkip);
|
|
442
|
+
if (sgSkip?.ops?.sparse_gram_delta_upsert != null) ops.sparse_gram_delta_upsert = sgSkip.ops.sparse_gram_delta_upsert;
|
|
443
|
+
return {
|
|
444
|
+
chunksTotal: vec?.chunksTotal ?? 0,
|
|
445
|
+
chunksEncoded: 0,
|
|
446
|
+
chunksReused: vec?.chunksReused ?? 0,
|
|
447
|
+
chunksStructStable: 0,
|
|
448
|
+
chunksTextUnchanged: 0,
|
|
449
|
+
chunksMetadataDirty: 0,
|
|
450
|
+
chunksDedupRepaired: 0,
|
|
451
|
+
treeSitterErrorNodes: graph?.treeSitterErrorNodes ?? 0,
|
|
452
|
+
skipped: true,
|
|
453
|
+
manifestTiers,
|
|
454
|
+
ops,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
385
457
|
this.progress('reconciler:binary-hnsw:start');
|
|
386
|
-
const bin = await this.adapters.applyBinaryHNSWDelta?.(file, vec?.vectorOps ?? [], epoch);
|
|
458
|
+
const bin = await this.adapters.applyBinaryHNSWDelta?.(file, vec?.vectorOps ?? [], epoch, tickCtx);
|
|
387
459
|
this.progress('reconciler:binary-hnsw:done');
|
|
388
460
|
collectManifestTier(manifestTiers, 'binaryHnsw', bin);
|
|
389
461
|
if (bin?.ops?.binary_hnsw_append != null) ops.binary_hnsw_append = bin.ops.binary_hnsw_append;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk-hash early-cutoff cache (lever E.6).
|
|
3
|
+
*
|
|
4
|
+
* Persists, per file, the set of encoder-input hashes that the file's chunks
|
|
5
|
+
* fed to the embedding + late-interaction encoders on the LAST successful
|
|
6
|
+
* reconcile of that file:
|
|
7
|
+
*
|
|
8
|
+
* { [relativePath]: { embeddingInputHashes: string[], liInputHashes: string[] } }
|
|
9
|
+
*
|
|
10
|
+
* Before re-embedding a *changed* file, the reconciler recomputes the same
|
|
11
|
+
* per-chunk hashes from the freshly parsed + graph-enriched chunks and compares
|
|
12
|
+
* them to the persisted set. If — and only if — BOTH the embedding-input and the
|
|
13
|
+
* li-input hash sets match exactly, the file's encode + all tier writes can be
|
|
14
|
+
* skipped: the encoders would produce byte-identical inputs and therefore
|
|
15
|
+
* byte-identical outputs.
|
|
16
|
+
*
|
|
17
|
+
* CORRECTNESS GATE (the §1.1 Tier-3 risk).
|
|
18
|
+
* The cutoff key is `embedding_input_hash` + `li_input_hash` (from
|
|
19
|
+
* `encoder-input.mjs::chunkInputHashes`). These fold in cross-file graph
|
|
20
|
+
* enrichment (scope + imports injected by `enrichChunksFromGraph`), so a change
|
|
21
|
+
* to a DEPENDENCY's symbols changes the dependent file's `embedding_text` and
|
|
22
|
+
* therefore its `embedding_input_hash` — which means the dependent file is NOT
|
|
23
|
+
* skipped. The key MUST NEVER be the file's own `chunk_text_hash` nor the raw
|
|
24
|
+
* `contentUnchanged` flag, both of which miss cross-file enrichment and would
|
|
25
|
+
* silently degrade recall. This module deliberately accepts ONLY the encoder-
|
|
26
|
+
* input hashes and exposes no API that takes a text/content hash, so the
|
|
27
|
+
* correctness gate is enforced structurally.
|
|
28
|
+
*
|
|
29
|
+
* This is a DOMAIN module: pure functions over plain data plus a tiny
|
|
30
|
+
* JSON-on-disk persistence helper. The reconciler (application layer) owns the
|
|
31
|
+
* decision of when to skip; this module only computes the key, compares, and
|
|
32
|
+
* persists.
|
|
33
|
+
*
|
|
34
|
+
* Gated by `SWEET_SEARCH_RECONCILE_CHUNK_CUTOFF` at the call site
|
|
35
|
+
* (production-reconciler). When the flag is off the cache is neither read nor
|
|
36
|
+
* written and behavior is exactly today's.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
|
|
42
|
+
import { denseInputHash, liInputHash } from './encoder-input.mjs';
|
|
43
|
+
|
|
44
|
+
export const CUTOFF_CACHE_FILENAME = 'reconcile-cutoff-cache.json';
|
|
45
|
+
export const CUTOFF_CACHE_VERSION = 1;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @returns {boolean} whether the chunk-cutoff lever is enabled.
|
|
49
|
+
*
|
|
50
|
+
* DEFAULT-ON (disable with SWEET_SEARCH_RECONCILE_CHUNK_CUTOFF=0). The cutoff key
|
|
51
|
+
* is the per-chunk encoder-input hashes (dense + LI), which fold in cross-file
|
|
52
|
+
* graph enrichment, so a file is skipped ONLY when its encoder inputs are
|
|
53
|
+
* byte-identical → byte-identical encoder outputs (verified recall-neutral). Set
|
|
54
|
+
* the flag to '0' to always re-encode changed files (today's behavior).
|
|
55
|
+
*/
|
|
56
|
+
export function chunkCutoffEnabled(env = process.env) {
|
|
57
|
+
return env.SWEET_SEARCH_RECONCILE_CHUNK_CUTOFF !== '0';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute the cutoff signature for a file's chunk set. The signature is the
|
|
62
|
+
* ordered list of per-chunk encoder-input hashes (dense + LI) — ordered so the
|
|
63
|
+
* comparison is sensitive to chunk reordering, not just set membership (a moved
|
|
64
|
+
* symbol is a real change to LI segment ordering).
|
|
65
|
+
*
|
|
66
|
+
* The chunks passed in MUST be the post-enrichment chunks (after
|
|
67
|
+
* `enrichChunksFromGraph`), so the dense hash reflects cross-file scope/imports.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array<object>} chunks enriched chunks for one file
|
|
70
|
+
* @returns {{embeddingInputHashes: string[], liInputHashes: string[]}}
|
|
71
|
+
*/
|
|
72
|
+
export function computeCutoffSignature(chunks) {
|
|
73
|
+
const embeddingInputHashes = [];
|
|
74
|
+
const liInputHashes = [];
|
|
75
|
+
for (const chunk of chunks || []) {
|
|
76
|
+
embeddingInputHashes.push(denseInputHash(chunk));
|
|
77
|
+
liInputHashes.push(liInputHash(chunk));
|
|
78
|
+
}
|
|
79
|
+
return { embeddingInputHashes, liInputHashes };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function arraysEqual(a, b) {
|
|
83
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
84
|
+
if (a.length !== b.length) return false;
|
|
85
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
86
|
+
if (a[i] !== b[i]) return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether a file's encode + tier writes can be skipped.
|
|
93
|
+
*
|
|
94
|
+
* Returns true only when BOTH the embedding-input and li-input hash lists match
|
|
95
|
+
* the persisted signature exactly (same hashes, same order). An empty current
|
|
96
|
+
* signature (no chunks) never matches a non-empty stored one and vice versa, so
|
|
97
|
+
* a file that lost or gained all chunks is never skipped.
|
|
98
|
+
*
|
|
99
|
+
* @param {{embeddingInputHashes: string[], liInputHashes: string[]}|null} previous
|
|
100
|
+
* @param {{embeddingInputHashes: string[], liInputHashes: string[]}} current
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function signaturesMatch(previous, current) {
|
|
104
|
+
if (!previous || !current) return false;
|
|
105
|
+
// Never skip a file whose chunk set went empty (deletion / total rewrite) —
|
|
106
|
+
// that path must flow through the normal retire logic.
|
|
107
|
+
if ((current.embeddingInputHashes?.length ?? 0) === 0) return false;
|
|
108
|
+
return arraysEqual(previous.embeddingInputHashes, current.embeddingInputHashes)
|
|
109
|
+
&& arraysEqual(previous.liInputHashes, current.liInputHashes);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cachePath(stateDir) {
|
|
113
|
+
return path.join(stateDir, CUTOFF_CACHE_FILENAME);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load the whole cutoff cache (best-effort). A missing / corrupt file yields an
|
|
118
|
+
* empty cache so the first run after enabling the flag re-embeds normally and
|
|
119
|
+
* then populates the cache.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} stateDir
|
|
122
|
+
* @returns {{version: number, files: Record<string, {embeddingInputHashes: string[], liInputHashes: string[]}>}}
|
|
123
|
+
*/
|
|
124
|
+
export function loadCutoffCache(stateDir) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(stateDir), 'utf8'));
|
|
127
|
+
if (parsed && parsed.version === CUTOFF_CACHE_VERSION && parsed.files && typeof parsed.files === 'object') {
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// missing / unreadable / version-skew → empty
|
|
132
|
+
}
|
|
133
|
+
return { version: CUTOFF_CACHE_VERSION, files: {} };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Look up one file's persisted signature.
|
|
138
|
+
*
|
|
139
|
+
* @param {{files: Record<string, object>}} cache
|
|
140
|
+
* @param {string} relativePath
|
|
141
|
+
* @returns {{embeddingInputHashes: string[], liInputHashes: string[]}|null}
|
|
142
|
+
*/
|
|
143
|
+
export function getFileSignature(cache, relativePath) {
|
|
144
|
+
return cache?.files?.[relativePath] || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Record a file's new signature into an in-memory cache object (mutates it).
|
|
149
|
+
* Persist with `saveCutoffCache` once at tick-finalize.
|
|
150
|
+
*
|
|
151
|
+
* @param {{files: Record<string, object>}} cache
|
|
152
|
+
* @param {string} relativePath
|
|
153
|
+
* @param {{embeddingInputHashes: string[], liInputHashes: string[]}} signature
|
|
154
|
+
*/
|
|
155
|
+
export function setFileSignature(cache, relativePath, signature) {
|
|
156
|
+
if (!cache.files) cache.files = {};
|
|
157
|
+
cache.files[relativePath] = {
|
|
158
|
+
embeddingInputHashes: [...(signature.embeddingInputHashes || [])],
|
|
159
|
+
liInputHashes: [...(signature.liInputHashes || [])],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Drop a file from the cache (deletion / retirement).
|
|
165
|
+
*
|
|
166
|
+
* @param {{files: Record<string, object>}} cache
|
|
167
|
+
* @param {string} relativePath
|
|
168
|
+
*/
|
|
169
|
+
export function deleteFileSignature(cache, relativePath) {
|
|
170
|
+
if (cache?.files) delete cache.files[relativePath];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Persist the cutoff cache atomically (temp-file + rename). Best-effort: a
|
|
175
|
+
* write failure leaves the previous cache in place, which only costs a redundant
|
|
176
|
+
* re-embed on the next tick — never a correctness loss.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} stateDir
|
|
179
|
+
* @param {{version: number, files: Record<string, object>}} cache
|
|
180
|
+
*/
|
|
181
|
+
export function saveCutoffCache(stateDir, cache) {
|
|
182
|
+
try {
|
|
183
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
184
|
+
const filePath = cachePath(stateDir);
|
|
185
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
186
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: CUTOFF_CACHE_VERSION, files: cache?.files || {} }));
|
|
187
|
+
fs.renameSync(tmp, filePath);
|
|
188
|
+
} catch {
|
|
189
|
+
// best-effort; never throw on the maintainer path
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -105,6 +105,88 @@ export function nextInterval(input) {
|
|
|
105
105
|
*/
|
|
106
106
|
const TIER_TABLE = Object.freeze({ low: 60_000, mid: 30_000, high: 20_000 });
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Memory-tier table for the footprint levers (idle-TTL / RSS-budget soft cap) —
|
|
110
|
+
* the lever-D analogue of TIER_TABLE. Detect once at startup from system RAM:
|
|
111
|
+
* small-RAM hosts (laptops / constrained CI — the ~16 GB cross-repo OOM case in
|
|
112
|
+
* `project_ss_daemon_footprint_safety`) auto-enable the footprint levers; roomy
|
|
113
|
+
* hosts keep them OFF (no footprint pressure, and idle-TTL's respawn latency
|
|
114
|
+
* would only cost them with nothing to reclaim). The env overrides
|
|
115
|
+
* (`SWEET_SEARCH_MAINTAINER_IDLE_TTL_MS`, `SWEET_SEARCH_RSS_BUDGET_FRACTION`)
|
|
116
|
+
* always win over the tier default.
|
|
117
|
+
*
|
|
118
|
+
* tight (≤12 GiB) → idle-TTL 10 min, RSS soft cap 0.55
|
|
119
|
+
* moderate (≤24 GiB) → idle-TTL 30 min, RSS soft cap 0.60
|
|
120
|
+
* roomy (>24 GiB) → idle-TTL OFF (0), no RSS cap (null)
|
|
121
|
+
*
|
|
122
|
+
* Pure: the caller injects `totalMemBytes` (os.totalmem()); a non-finite/absent
|
|
123
|
+
* value resolves to `roomy` — the safe, no-surprise default (levers stay off if
|
|
124
|
+
* detection ever fails).
|
|
125
|
+
*/
|
|
126
|
+
const MEMORY_TIER_TABLE = Object.freeze({
|
|
127
|
+
tight: { maxGiB: 12, idleTtlMs: 600_000, rssBudgetFraction: 0.55 },
|
|
128
|
+
moderate: { maxGiB: 24, idleTtlMs: 1_800_000, rssBudgetFraction: 0.60 },
|
|
129
|
+
roomy: { maxGiB: Infinity, idleTtlMs: 0, rssBudgetFraction: null },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {{ totalMemBytes?: number }} [options] totalMemBytes = os.totalmem(), injected by the caller
|
|
134
|
+
* @returns {{ tier:'tight'|'moderate'|'roomy', totalGiB:number, idleTtlMs:number, rssBudgetFraction:(number|null) }}
|
|
135
|
+
*/
|
|
136
|
+
export function resolveMaintainerMemoryProfile({ totalMemBytes } = {}) {
|
|
137
|
+
const giB = Number.isFinite(totalMemBytes) ? totalMemBytes / (1024 ** 3) : Infinity;
|
|
138
|
+
let tier = 'roomy';
|
|
139
|
+
if (giB <= MEMORY_TIER_TABLE.tight.maxGiB) tier = 'tight';
|
|
140
|
+
else if (giB <= MEMORY_TIER_TABLE.moderate.maxGiB) tier = 'moderate';
|
|
141
|
+
const p = MEMORY_TIER_TABLE[tier];
|
|
142
|
+
return {
|
|
143
|
+
tier,
|
|
144
|
+
totalGiB: Number.isFinite(giB) ? Math.round(giB * 10) / 10 : Infinity,
|
|
145
|
+
idleTtlMs: p.idleTtlMs,
|
|
146
|
+
rssBudgetFraction: p.rssBudgetFraction,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Backstop full-walk cadence for the event-driven watcher (lever C / group G6).
|
|
152
|
+
*
|
|
153
|
+
* When the `@parcel/watcher` event stream is the primary dirty-set producer,
|
|
154
|
+
* the full `scanDirtyAndEnqueue` stat-walk is demoted to a periodic backstop
|
|
155
|
+
* (it still runs on the first tick, on watcher overflow, and on a forced walk).
|
|
156
|
+
* This resolver returns that cadence in milliseconds, clamped to [5min, 15min]
|
|
157
|
+
* — the window the design (§4.C) accepts as the worst-case convergence latency
|
|
158
|
+
* for a gitignore/exclude change that produced no file event. G4 owns this file;
|
|
159
|
+
* G6 consumes the resolved value from the maintainer loop.
|
|
160
|
+
*
|
|
161
|
+
* Precedence (highest first):
|
|
162
|
+
* 1. `SWEET_SEARCH_MAINTAINER_BACKSTOP_WALK_MS` (milliseconds)
|
|
163
|
+
* 2. default 10 min
|
|
164
|
+
*
|
|
165
|
+
* Values are clamped into [BACKSTOP_MIN_MS, BACKSTOP_MAX_MS]; a non-finite or
|
|
166
|
+
* non-positive override falls back to the default.
|
|
167
|
+
*/
|
|
168
|
+
const BACKSTOP_MIN_MS = 300_000; // 5 min
|
|
169
|
+
const BACKSTOP_MAX_MS = 900_000; // 15 min
|
|
170
|
+
const BACKSTOP_DEFAULT_MS = 600_000; // 10 min
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
174
|
+
* @returns {{ intervalMs:number, source:'env-override-ms'|'default' }}
|
|
175
|
+
*/
|
|
176
|
+
export function backstopWalkIntervalMs({ env = process.env } = {}) {
|
|
177
|
+
const raw = env.SWEET_SEARCH_MAINTAINER_BACKSTOP_WALK_MS;
|
|
178
|
+
if (raw !== undefined && raw !== '') {
|
|
179
|
+
const ms = Number(raw);
|
|
180
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
181
|
+
return {
|
|
182
|
+
intervalMs: Math.min(Math.max(ms, BACKSTOP_MIN_MS), BACKSTOP_MAX_MS),
|
|
183
|
+
source: 'env-override-ms',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { intervalMs: BACKSTOP_DEFAULT_MS, source: 'default' };
|
|
188
|
+
}
|
|
189
|
+
|
|
108
190
|
/**
|
|
109
191
|
* `SWEET_SEARCH_RECONCILE_PROFILE` lets operators pin the startup interval
|
|
110
192
|
* by intent rather than by tier. `balanced` is a no-op (falls through to
|
|
@@ -251,5 +333,6 @@ export function reconcileEnablement(env = process.env) {
|
|
|
251
333
|
export const __testing = {
|
|
252
334
|
MIN_MS, MAX_MS, NOMINAL_MS,
|
|
253
335
|
TARGET_TICK_WALLCLOCK_FRACTION, MAX_RATIO_CHANGE_PER_TICK,
|
|
254
|
-
TIER_TABLE, PROFILE_TABLE,
|
|
336
|
+
TIER_TABLE, PROFILE_TABLE, MEMORY_TIER_TABLE,
|
|
337
|
+
BACKSTOP_MIN_MS, BACKSTOP_MAX_MS, BACKSTOP_DEFAULT_MS,
|
|
255
338
|
};
|
|
@@ -42,15 +42,12 @@ const DEFAULT_FIELDS = Object.freeze({
|
|
|
42
42
|
cpu_budget_total_ms: 0,
|
|
43
43
|
|
|
44
44
|
manifest_epoch_mismatch_total: 0,
|
|
45
|
-
hnsw_live_candidate_shortfall: 0,
|
|
46
45
|
});
|
|
47
46
|
|
|
48
47
|
function freshOpsTier() {
|
|
49
48
|
return {
|
|
50
49
|
graph_upsert: 0, graph_tombstone: 0,
|
|
51
50
|
vectors_upsert: 0, vectors_delete: 0,
|
|
52
|
-
hnsw_add: 0, hnsw_tombstone: 0,
|
|
53
|
-
hnsw_capacity_used: 0,
|
|
54
51
|
binary_hnsw_tombstone: 0, binary_hnsw_append: 0,
|
|
55
52
|
li_segment_append: 0, li_tombstone: 0,
|
|
56
53
|
sparse_gram_delta_upsert: 0,
|
|
@@ -61,7 +58,6 @@ function freshOpsTier() {
|
|
|
61
58
|
|
|
62
59
|
function freshWatermarks() {
|
|
63
60
|
return {
|
|
64
|
-
hnsw_tombstone_fraction: 0,
|
|
65
61
|
binary_hnsw_dead_doc_ratio: 0,
|
|
66
62
|
li_segments_over_threshold: [],
|
|
67
63
|
fts5_segment_count: 0,
|
|
@@ -8,8 +8,6 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Watermarks (plan § 6.2):
|
|
10
10
|
*
|
|
11
|
-
* - Float HNSW: `tombstone_fraction > 0.15` OR `delete_cycles > 1000`
|
|
12
|
-
* OR live-candidate shortfall observed → `float_hnsw` clean replacement.
|
|
13
11
|
* - Binary HNSW: `dead_doc_ratio > 0.30` → replacement.
|
|
14
12
|
* - LI segment: per-segment `stale_doc_ratio > 0.20` → per-segment recompaction.
|
|
15
13
|
* - Sparse-gram: `delta_size_ratio > 0.10` OR `delta_segment_count > 64`
|
|
@@ -23,8 +21,6 @@
|
|
|
23
21
|
*/
|
|
24
22
|
|
|
25
23
|
export const DEFAULT_WATERMARKS = Object.freeze({
|
|
26
|
-
hnswTombstoneFraction: 0.15,
|
|
27
|
-
hnswDeleteCycles: 1000,
|
|
28
24
|
binaryHnswDeadRatio: 0.30,
|
|
29
25
|
liSegmentStaleRatio: 0.20,
|
|
30
26
|
liSmallSegmentCount: 16,
|
|
@@ -51,8 +47,6 @@ export function loadWatermarkConfig(env = process.env) {
|
|
|
51
47
|
return Number.isFinite(parsed) ? parsed : def;
|
|
52
48
|
};
|
|
53
49
|
return {
|
|
54
|
-
hnswTombstoneFraction: num('SWEET_SEARCH_HNSW_TOMBSTONE_THRESHOLD', DEFAULT_WATERMARKS.hnswTombstoneFraction),
|
|
55
|
-
hnswDeleteCycles: num('SWEET_SEARCH_HNSW_DELETE_CYCLES', DEFAULT_WATERMARKS.hnswDeleteCycles),
|
|
56
50
|
binaryHnswDeadRatio: num('SWEET_SEARCH_BINARY_HNSW_DEAD_THRESHOLD', DEFAULT_WATERMARKS.binaryHnswDeadRatio),
|
|
57
51
|
liSegmentStaleRatio: num('SWEET_SEARCH_LI_SEGMENT_STALE_THRESHOLD', DEFAULT_WATERMARKS.liSegmentStaleRatio),
|
|
58
52
|
liSmallSegmentCount: num('SWEET_SEARCH_LI_SMALL_SEGMENT_THRESHOLD', DEFAULT_WATERMARKS.liSmallSegmentCount),
|
|
@@ -71,7 +65,6 @@ export function loadWatermarkConfig(env = process.env) {
|
|
|
71
65
|
* Tier-state input shape:
|
|
72
66
|
*
|
|
73
67
|
* {
|
|
74
|
-
* floatHnsw: { tombstoneFraction, deleteCycles, liveCandidateShortfall },
|
|
75
68
|
* binaryHnsw: { deadDocRatio },
|
|
76
69
|
* liSegments: Array<{ segmentId, staleDocRatio }>,
|
|
77
70
|
* sparseGram: { deltaSizeRatio, deltaSegmentCount },
|
|
@@ -88,23 +81,6 @@ export function loadWatermarkConfig(env = process.env) {
|
|
|
88
81
|
*/
|
|
89
82
|
export function evaluateWatermarks(state, config = DEFAULT_WATERMARKS) {
|
|
90
83
|
const jobs = [];
|
|
91
|
-
const floatH = state.floatHnsw || {};
|
|
92
|
-
if (
|
|
93
|
-
(floatH.tombstoneFraction ?? 0) > config.hnswTombstoneFraction
|
|
94
|
-
|| (floatH.deleteCycles ?? 0) > config.hnswDeleteCycles
|
|
95
|
-
|| floatH.liveCandidateShortfall === true
|
|
96
|
-
) {
|
|
97
|
-
jobs.push({
|
|
98
|
-
tier: 'float_hnsw',
|
|
99
|
-
reason: floatH.liveCandidateShortfall ? 'live_candidate_shortfall'
|
|
100
|
-
: (floatH.tombstoneFraction > config.hnswTombstoneFraction
|
|
101
|
-
? 'tombstone_watermark' : 'delete_cycles'),
|
|
102
|
-
payload: {
|
|
103
|
-
tombstoneFraction: floatH.tombstoneFraction ?? 0,
|
|
104
|
-
deleteCycles: floatH.deleteCycles ?? 0,
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
84
|
const binH = state.binaryHnsw || {};
|
|
109
85
|
// Reclaim on the dead-doc ratio (batched) OR on any *unexplained* divergence
|
|
110
86
|
// from codebase.db — a vector retired there but never stale-marked in the
|
|
@@ -17,7 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import Database from 'better-sqlite3';
|
|
19
19
|
|
|
20
|
-
import { loadBitmap, popcount
|
|
20
|
+
import { loadBitmap, popcount } from './tombstone-bitmap.mjs';
|
|
21
21
|
import { deltaSizeStats } from './sparse-gram-delta.mjs';
|
|
22
22
|
import { evaluateSegmentRatios, LI_SEGMENT_SIZE } from './li-segment-state.mjs';
|
|
23
23
|
import { fts5SegmentCount } from './sqlite-fts5.mjs';
|
|
@@ -60,29 +60,6 @@ export function readSparseGramState(stateDir) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
64
|
-
* Float HNSW: meta.json carries `idMap` (live ids — pruned on `remove()`)
|
|
65
|
-
* and the stale bitmap at `.idx.stale.bin` mirrors the soft-delete state.
|
|
66
|
-
*
|
|
67
|
-
* tombstoneFraction = popcount(bitmap) / (popcount + liveTotal)
|
|
68
|
-
*
|
|
69
|
-
* `liveCandidateShortfall` stays undefined here — that signal must come
|
|
70
|
-
* from a query-path counter (plan § 7.3), not from offline state.
|
|
71
|
-
*/
|
|
72
|
-
export function readFloatHnswState(stateDir) {
|
|
73
|
-
const empty = { tombstoneFraction: 0, deleteCycles: 0 };
|
|
74
|
-
const metaPath = path.join(stateDir, 'codebase-hnsw.meta.json');
|
|
75
|
-
if (!fs.existsSync(metaPath)) return empty;
|
|
76
|
-
const meta = readJson(metaPath);
|
|
77
|
-
if (!meta) return empty;
|
|
78
|
-
const idMap = Array.isArray(meta.idMap) ? meta.idMap : [];
|
|
79
|
-
const liveTotal = idMap.length;
|
|
80
|
-
let bitmap = null;
|
|
81
|
-
try { bitmap = loadBitmap(path.join(stateDir, 'codebase-hnsw.idx.stale.bin')); } catch { bitmap = null; }
|
|
82
|
-
const fraction = bitmap ? tombstoneFraction(bitmap, liveTotal) : 0;
|
|
83
|
-
return { tombstoneFraction: fraction, deleteCycles: 0 };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
63
|
/**
|
|
87
64
|
* Binary HNSW: meta.json's `vectorCount` is the row count in `vectors.json`
|
|
88
65
|
* (never pruned — see `binary-hnsw-index.js::save`). The bitmap at
|
|
@@ -266,14 +243,13 @@ export function readGraphGcState(stateDir) {
|
|
|
266
243
|
|
|
267
244
|
/**
|
|
268
245
|
* One-shot bundle for the reconciler adapter. Returns the full shape
|
|
269
|
-
* `evaluateWatermarks` expects: `fts5 / sparseGram /
|
|
246
|
+
* `evaluateWatermarks` expects: `fts5 / sparseGram /
|
|
270
247
|
* binaryHnsw / liSegments / liSegmentStats / vectors / graph`.
|
|
271
248
|
*/
|
|
272
249
|
export function readMaintenanceState(stateDir) {
|
|
273
250
|
return {
|
|
274
251
|
fts5: readFts5State(stateDir),
|
|
275
252
|
sparseGram: readSparseGramState(stateDir),
|
|
276
|
-
floatHnsw: readFloatHnswState(stateDir),
|
|
277
253
|
binaryHnsw: readBinaryHnswState(stateDir),
|
|
278
254
|
liSegments: readLiSegmentsState(stateDir),
|
|
279
255
|
liSegmentStats: readLiSegmentStats(stateDir),
|
|
@@ -15,9 +15,6 @@
|
|
|
15
15
|
* "publishedAt": "2026-05-15T23:00:00.000Z",
|
|
16
16
|
* "codeGraph": { "path": "code-graph.db", "epoch": 12847 },
|
|
17
17
|
* "vectors": { "path": "codebase.db", "epoch": 12847 },
|
|
18
|
-
* "hnsw": { "path": "codebase-hnsw.idx",
|
|
19
|
-
* "stale": "codebase-hnsw.idx.stale.bin",
|
|
20
|
-
* "epoch": 12847 },
|
|
21
18
|
* "binaryHnsw": { "path": "codebase-binary-hnsw.idx", "epoch": 12847 },
|
|
22
19
|
* "lateInteraction":{ "manifest": "codebase-late-interaction.db.segments/manifest.json",
|
|
23
20
|
* "epoch": 12847 },
|
|
@@ -61,11 +58,6 @@ export function zeroManifest(paths) {
|
|
|
61
58
|
publishedAt: new Date().toISOString(),
|
|
62
59
|
codeGraph: { path: paths.codeGraph || 'code-graph.db', epoch: 0 },
|
|
63
60
|
vectors: { path: paths.vectors || 'codebase.db', epoch: 0 },
|
|
64
|
-
hnsw: {
|
|
65
|
-
path: paths.hnsw || 'codebase-hnsw.idx',
|
|
66
|
-
stale: paths.hnswStale || 'codebase-hnsw.idx.stale.bin',
|
|
67
|
-
epoch: 0,
|
|
68
|
-
},
|
|
69
61
|
binaryHnsw: {
|
|
70
62
|
path: paths.binaryHnsw || 'codebase-binary-hnsw.idx',
|
|
71
63
|
epoch: 0,
|
|
@@ -160,7 +152,7 @@ export function buildNextManifest(prev, delta) {
|
|
|
160
152
|
publishedAt: new Date().toISOString(),
|
|
161
153
|
};
|
|
162
154
|
const tiers = delta.tiers || {};
|
|
163
|
-
for (const key of ['codeGraph', 'vectors', '
|
|
155
|
+
for (const key of ['codeGraph', 'vectors', 'binaryHnsw', 'lateInteraction', 'sparseGram']) {
|
|
164
156
|
if (!tiers[key]) {
|
|
165
157
|
// Carry forward the previous tier descriptor with the new epoch.
|
|
166
158
|
out[key] = { ...(prev[key] || {}), epoch: delta.epoch };
|