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.
Files changed (52) hide show
  1. package/README.md +36 -9
  2. package/core/cli.js +41 -3
  3. package/core/embedding/embedding-local-model.js +106 -10
  4. package/core/embedding/embedding-service.js +59 -1
  5. package/core/embedding/model-client.mjs +257 -0
  6. package/core/embedding/model-server.mjs +217 -0
  7. package/core/incremental-indexing/application/maintenance-handlers.mjs +19 -98
  8. package/core/incremental-indexing/application/maintenance-worker.mjs +46 -9
  9. package/core/incremental-indexing/application/operator-cli.mjs +14 -5
  10. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +40 -0
  11. package/core/incremental-indexing/application/production-reconciler.mjs +718 -54
  12. package/core/incremental-indexing/application/reconciler.mjs +87 -15
  13. package/core/incremental-indexing/domain/cutoff-cache.mjs +191 -0
  14. package/core/incremental-indexing/domain/interval-autotune.mjs +84 -1
  15. package/core/incremental-indexing/domain/reconcile-counters.mjs +0 -4
  16. package/core/incremental-indexing/domain/watermark-scheduler.mjs +0 -24
  17. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +2 -26
  18. package/core/incremental-indexing/infrastructure/manifest.mjs +1 -9
  19. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +72 -0
  20. package/core/indexing/artifact-builder.js +1 -1
  21. package/core/indexing/dedup/dedup-phase.js +36 -17
  22. package/core/indexing/dedup/exemplar-selector.js +5 -0
  23. package/core/indexing/index-codebase-v21.js +37 -14
  24. package/core/indexing/index-maintainer.mjs +337 -6
  25. package/core/indexing/indexer-ann.js +27 -434
  26. package/core/indexing/indexer-build.js +30 -14
  27. package/core/indexing/indexer-manifest.js +0 -3
  28. package/core/indexing/indexer-phases.js +101 -25
  29. package/core/indexing/maintainer-launcher.mjs +22 -0
  30. package/core/indexing/maintainer-watcher.mjs +397 -0
  31. package/core/indexing/os-priority.mjs +160 -0
  32. package/core/indexing/rss-budget.mjs +425 -0
  33. package/core/indexing/streaming-vectors.js +450 -0
  34. package/core/infrastructure/config/platform.js +14 -10
  35. package/core/infrastructure/onnx-session-utils.js +37 -0
  36. package/core/infrastructure/sparse-gram-delta-reader.js +11 -1
  37. package/core/ranking/late-interaction-index.js +58 -7
  38. package/core/search/daemon-registry.js +199 -0
  39. package/core/search/search-read-semantic.js +9 -3
  40. package/core/search/search-semantic.js +6 -29
  41. package/core/search/search-server.js +527 -27
  42. package/core/search/session-daemon-prewarm.mjs +110 -1
  43. package/core/search/sweet-search.js +0 -38
  44. package/core/vector-store/binary-hnsw-index.js +692 -78
  45. package/core/vector-store/index.js +1 -4
  46. package/eval/agent-read-workflows/bin/_ss-argparse.mjs +51 -5
  47. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +95 -44
  48. package/eval/agent-read-workflows/bin/ss-read +2 -0
  49. package/mcp/tool-handlers.js +1 -2
  50. package/package.json +11 -8
  51. package/scripts/uninstall.js +2 -0
  52. 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: {...}, hnsw: {...} } }
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
- this.progress('reconciler:hnsw:start');
380
- const hnsw = await this.adapters.applyHNSWDelta?.(file, vec?.vectorOps ?? [], epoch);
381
- this.progress('reconciler:hnsw:done');
382
- collectManifestTier(manifestTiers, 'hnsw', hnsw);
383
- if (hnsw?.ops?.hnsw_add != null) ops.hnsw_add = hnsw.ops.hnsw_add;
384
- if (hnsw?.ops?.hnsw_tombstone != null) ops.hnsw_tombstone = hnsw.ops.hnsw_tombstone;
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, tombstoneFraction } from './tombstone-bitmap.mjs';
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 / floatHnsw /
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', 'hnsw', 'binaryHnsw', 'lateInteraction', 'sparseGram']) {
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 };