sweet-search 2.5.2 → 2.5.4

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 (155) hide show
  1. package/core/cli.js +24 -3
  2. package/core/graph/graph-expansion.js +215 -36
  3. package/core/graph/graph-extractor.js +196 -11
  4. package/core/graph/graph-search.js +395 -92
  5. package/core/graph/hcgs-generator.js +2 -1
  6. package/core/graph/index.js +2 -0
  7. package/core/graph/repo-map.js +28 -6
  8. package/core/graph/structural-answer-cues.js +168 -0
  9. package/core/graph/structural-callsite-hints.js +40 -0
  10. package/core/graph/structural-context-format.js +40 -0
  11. package/core/graph/structural-context.js +450 -0
  12. package/core/graph/structural-forward-push.js +156 -0
  13. package/core/graph/structural-header-context.js +19 -0
  14. package/core/graph/structural-importance.js +148 -0
  15. package/core/graph/structural-pagerank.js +197 -0
  16. package/core/graph/summary-manager.js +13 -9
  17. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  18. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  19. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  20. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  21. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  22. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  23. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  24. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  25. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  26. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  27. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  28. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  29. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  30. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  31. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  32. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  33. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  34. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  35. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  36. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  37. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  38. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  39. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  40. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  41. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  42. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  43. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  44. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  45. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  46. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  47. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  48. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  49. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  50. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  51. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  52. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  53. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  54. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  55. package/core/indexing/admission-policy.js +139 -0
  56. package/core/indexing/artifact-builder.js +29 -12
  57. package/core/indexing/ast-chunker.js +107 -30
  58. package/core/indexing/dedup/exemplar-selector.js +19 -1
  59. package/core/indexing/gitignore-filter.js +223 -0
  60. package/core/indexing/incremental-tracker.js +99 -30
  61. package/core/indexing/index-codebase-v21.js +6 -5
  62. package/core/indexing/index-maintainer.mjs +698 -6
  63. package/core/indexing/indexer-ann.js +99 -15
  64. package/core/indexing/indexer-build.js +158 -45
  65. package/core/indexing/indexer-empty-baseline.js +80 -0
  66. package/core/indexing/indexer-manifest.js +66 -0
  67. package/core/indexing/indexer-phases.js +56 -23
  68. package/core/indexing/indexer-sparse-gram.js +54 -13
  69. package/core/indexing/indexer-utils.js +26 -208
  70. package/core/indexing/indexing-file-policy.js +32 -7
  71. package/core/indexing/maintainer-launcher.mjs +137 -0
  72. package/core/indexing/merkle-tracker.js +251 -244
  73. package/core/indexing/model-pool.js +46 -5
  74. package/core/infrastructure/code-graph-repository.js +758 -6
  75. package/core/infrastructure/code-graph-visibility.js +157 -0
  76. package/core/infrastructure/codebase-repository.js +100 -13
  77. package/core/infrastructure/config/search.js +1 -1
  78. package/core/infrastructure/db-utils.js +118 -0
  79. package/core/infrastructure/dedup-hashing.js +10 -13
  80. package/core/infrastructure/hardware-capability.js +17 -7
  81. package/core/infrastructure/index.js +8 -2
  82. package/core/infrastructure/language-patterns/maps.js +4 -1
  83. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  84. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  85. package/core/infrastructure/language-patterns.js +69 -0
  86. package/core/infrastructure/model-registry.js +20 -0
  87. package/core/infrastructure/native-inference.js +7 -12
  88. package/core/infrastructure/native-resolver.js +52 -37
  89. package/core/infrastructure/native-sparse-gram.js +261 -20
  90. package/core/infrastructure/native-tokenizer.js +6 -15
  91. package/core/infrastructure/simd-distance.js +10 -16
  92. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  93. package/core/infrastructure/structural-alias-resolver.js +122 -0
  94. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  95. package/core/infrastructure/structural-context-repository.js +472 -0
  96. package/core/infrastructure/structural-context-utils.js +51 -0
  97. package/core/infrastructure/structural-graph-signals.js +121 -0
  98. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  99. package/core/infrastructure/structural-source-definitions.js +100 -0
  100. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  101. package/core/infrastructure/tree-sitter-provider.js +811 -37
  102. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  103. package/core/query/query-router.js +55 -5
  104. package/core/ranking/file-kind-ranking.js +2192 -15
  105. package/core/ranking/late-interaction-index.js +87 -12
  106. package/core/search/cli-decoration.js +290 -0
  107. package/core/search/context-expander.js +988 -78
  108. package/core/search/index.js +1 -0
  109. package/core/search/output-policy.js +275 -0
  110. package/core/search/search-anchor.js +499 -0
  111. package/core/search/search-boost.js +93 -1
  112. package/core/search/search-cli.js +61 -204
  113. package/core/search/search-hybrid.js +250 -10
  114. package/core/search/search-pattern-chunks.js +57 -8
  115. package/core/search/search-pattern-planner.js +68 -9
  116. package/core/search/search-pattern-prefilter.js +30 -10
  117. package/core/search/search-pattern-ripgrep.js +40 -4
  118. package/core/search/search-pattern-sparse-overlay.js +256 -0
  119. package/core/search/search-pattern.js +117 -29
  120. package/core/search/search-postprocess.js +479 -5
  121. package/core/search/search-read-semantic.js +260 -23
  122. package/core/search/search-read.js +82 -64
  123. package/core/search/search-reader-pin.js +71 -0
  124. package/core/search/search-rrf.js +279 -0
  125. package/core/search/search-semantic.js +110 -5
  126. package/core/search/search-server.js +130 -57
  127. package/core/search/search-trace.js +107 -0
  128. package/core/search/server-identity.js +93 -0
  129. package/core/search/session-daemon-prewarm.mjs +33 -10
  130. package/core/search/sweet-search.js +399 -7
  131. package/core/skills/sweet-index/SKILL.md +8 -6
  132. package/core/vector-store/binary-hnsw-index.js +194 -30
  133. package/core/vector-store/float-vector-store.js +96 -6
  134. package/core/vector-store/hnsw-index.js +220 -49
  135. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  136. package/eval/agent-read-workflows/bin/ss-find +15 -0
  137. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  138. package/eval/agent-read-workflows/bin/ss-read +14 -0
  139. package/eval/agent-read-workflows/bin/ss-search +18 -0
  140. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  141. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  142. package/mcp/read-tool.js +109 -0
  143. package/mcp/server.js +55 -15
  144. package/mcp/tool-handlers.js +14 -124
  145. package/mcp/trace-tool.js +81 -0
  146. package/package.json +25 -10
  147. package/scripts/hooks/intercept-read.mjs +55 -0
  148. package/scripts/hooks/remind-tools.mjs +40 -0
  149. package/scripts/init.js +698 -54
  150. package/scripts/inject-agent-instructions.js +431 -0
  151. package/scripts/install-prompt-reminders.js +188 -0
  152. package/scripts/install-tool-enforcement.js +220 -0
  153. package/scripts/smoke-test.js +12 -9
  154. package/scripts/uninstall.js +276 -18
  155. package/scripts/write-claude-rules.js +110 -0
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import fs from 'fs/promises';
30
- import { existsSync } from 'fs';
30
+ import { existsSync, statSync } from 'fs';
31
31
  import path from 'path';
32
32
  import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
33
33
  import {
@@ -37,6 +37,7 @@ import {
37
37
  } from '../infrastructure/quantization.js';
38
38
  import { wasmHammingDistance as hammingDistance } from '../infrastructure/simd-distance.js';
39
39
  import { TypedMinHeap, TypedMaxHeap, VisitedList } from './binary-heap.js';
40
+ import { loadBitmap, isSet } from '../infrastructure/tombstone-bitmap-reader.js';
40
41
 
41
42
  // Current quantization pipeline version. Bump when the encoding pipeline changes
42
43
  // (centroid subtraction, rotation, quantization scheme). Indexes built with a
@@ -60,6 +61,7 @@ export class BinaryHNSWIndex {
60
61
  this.maxElements = options.maxElements || BINARY_HNSW_CONFIG.maxElements;
61
62
 
62
63
  this.indexPath = options.indexPath || DB_PATHS.binaryHnswIndex;
64
+ this.stalePath = options.stalePath || `${this.indexPath}.stale.bin`;
63
65
 
64
66
  // Storage
65
67
  this.vectors = []; // Array of { id, binary: Uint8Array, metadata }
@@ -81,12 +83,15 @@ export class BinaryHNSWIndex {
81
83
  this.centroid = null; // Float32Array — dataset centroid
82
84
  this.signVector = null; // Float32Array — random ±1 for WHT rotation
83
85
  this.useAsymmetric = false; // Enabled after calibration
86
+ this._staleBitmapCache = null;
87
+ this._cleanBuild = false;
84
88
  }
85
89
 
86
90
  /** Reset to empty state for a fresh build (skips loading from disk). */
87
91
  resetForBuild() {
88
92
  this.vectors = [];
89
93
  this.idToIndex = new Map();
94
+ this.int8Vectors.clear();
90
95
  this.graph = [];
91
96
  this.entryPoint = -1;
92
97
  this.maxLevel = 0;
@@ -94,6 +99,12 @@ export class BinaryHNSWIndex {
94
99
  this.signVector = null;
95
100
  this.useAsymmetric = false;
96
101
  this.initialized = true;
102
+ this._staleBitmapCache = null;
103
+ this._cleanBuild = true;
104
+ }
105
+
106
+ _stalePathForIndex(indexPath = this.indexPath) {
107
+ return indexPath === this.indexPath ? this.stalePath : `${indexPath}.stale.bin`;
97
108
  }
98
109
 
99
110
  /**
@@ -422,6 +433,7 @@ export class BinaryHNSWIndex {
422
433
  await this.init();
423
434
 
424
435
  const start = performance.now();
436
+ const staleBitmap = this._loadStaleBitmap();
425
437
 
426
438
  if (this.vectors.length === 0) {
427
439
  return { results: [], latency_us: 0, k, total: 0 };
@@ -449,12 +461,12 @@ export class BinaryHNSWIndex {
449
461
  }
450
462
 
451
463
  // Adaptive ef: easy queries get a smaller budget, hard queries get more
452
- let ef = Math.max(k, this.efSearch);
464
+ let ef = Math.max(this._oversampleTarget(k, staleBitmap), this.efSearch);
453
465
  const greedyDist = hammingDistance(this.vectors[currentNode].binary, queryBinary);
454
466
  const maxDist = this.dimension * 8;
455
467
  const greedyQuality = 1 - (greedyDist / maxDist);
456
468
  if (greedyQuality > 0.85) {
457
- ef = Math.max(k, Math.round(ef * 0.6));
469
+ ef = Math.max(this._oversampleTarget(k, staleBitmap), Math.round(ef * 0.6));
458
470
  } else if (greedyQuality < 0.55) {
459
471
  ef = Math.round(ef * 1.5);
460
472
  }
@@ -463,6 +475,15 @@ export class BinaryHNSWIndex {
463
475
  const searchResult = this.searchLayerQuery(currentNode, queryBinary, ef, 0);
464
476
  let candidates = searchResult.candidates;
465
477
 
478
+ candidates = candidates.filter(c => !this._isIndexStale(c.idx, staleBitmap));
479
+ if (candidates.length < k && ef < this.vectors.length) {
480
+ const retryEf = Math.min(this.vectors.length, ef * 2);
481
+ if (retryEf > ef) {
482
+ const retry = this.searchLayerQuery(currentNode, queryBinary, retryEf, 0);
483
+ candidates = retry.candidates.filter(c => !this._isIndexStale(c.idx, staleBitmap));
484
+ }
485
+ }
486
+
466
487
  // Return top k
467
488
  const results = candidates.slice(0, k).map(c => ({
468
489
  id: this.vectors[c.idx].id,
@@ -478,13 +499,69 @@ export class BinaryHNSWIndex {
478
499
  latency_us: Math.round(latency * 1000),
479
500
  latency_ms: latency.toFixed(3),
480
501
  k,
481
- total: this.vectors.length,
502
+ total: this._liveVectorCount(staleBitmap),
482
503
  visitedNodes: searchResult.visitedCount,
483
504
  adaptiveEf: ef,
484
505
  useAsymmetric: this.useAsymmetric,
485
506
  };
486
507
  }
487
508
 
509
+ _loadStaleBitmap() {
510
+ if (!existsSync(this.stalePath)) {
511
+ this._staleBitmapCache = null;
512
+ return null;
513
+ }
514
+ let stat;
515
+ try {
516
+ stat = statSync(this.stalePath, { bigint: true });
517
+ } catch {
518
+ this._staleBitmapCache = null;
519
+ return null;
520
+ }
521
+ const statKey = `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`;
522
+
523
+ if (
524
+ this._staleBitmapCache
525
+ && this._staleBitmapCache.statKey === statKey
526
+ ) {
527
+ return this._staleBitmapCache.bitmap;
528
+ }
529
+
530
+ try {
531
+ const bitmap = loadBitmap(this.stalePath);
532
+ this._staleBitmapCache = { statKey, bitmap };
533
+ return bitmap;
534
+ } catch (err) {
535
+ if (process.env.SWEET_DEBUG) {
536
+ console.debug(`[BinaryHNSW] ignoring unreadable stale bitmap ${this.stalePath}: ${err.message}`);
537
+ }
538
+ this._staleBitmapCache = { statKey, bitmap: null };
539
+ return null;
540
+ }
541
+ }
542
+
543
+ _isIndexStale(idx, bitmap) {
544
+ return bitmap ? isSet(bitmap, idx) : false;
545
+ }
546
+
547
+ _liveVectorCount(bitmap) {
548
+ if (!bitmap) return this.vectors.length;
549
+ let live = 0;
550
+ for (let i = 0; i < this.vectors.length; i++) {
551
+ if (!this._isIndexStale(i, bitmap)) live++;
552
+ }
553
+ return live;
554
+ }
555
+
556
+ _oversampleTarget(k, bitmap) {
557
+ if (!bitmap) return k;
558
+ const live = this._liveVectorCount(bitmap);
559
+ const tombstoned = Math.max(0, this.vectors.length - live);
560
+ if (tombstoned === 0) return k;
561
+ const s = Math.max(0, Math.min(tombstoned / Math.max(1, this.vectors.length), 0.5));
562
+ return Math.min(Math.max(k + 64, Math.ceil(k / Math.max(0.05, 1 - s) * 2)), k * 20);
563
+ }
564
+
488
565
  /**
489
566
  * Greedy search for query vector
490
567
  */
@@ -633,16 +710,36 @@ export class BinaryHNSWIndex {
633
710
  * Get int8 vector for rescoring
634
711
  */
635
712
  getInt8Vector(id) {
713
+ const idx = this.idToIndex.get(id);
714
+ if (idx === undefined) return undefined;
715
+ if (this._isIndexStale(idx, this._loadStaleBitmap())) {
716
+ return undefined;
717
+ }
636
718
  return this.int8Vectors.get(id);
637
719
  }
638
720
 
639
721
  /**
640
- * Save index to disk
722
+ * Save index to disk.
723
+ *
724
+ * Publish semantics: every sidecar is written to a sibling
725
+ * `<path>.tmp.<pid>` then atomically renamed into its canonical
726
+ * name. Cross-process readers loading the index never observe a
727
+ * torn `(meta, vectors, graph, int8)` tuple — POSIX `rename` is
728
+ * atomic per-file, and the publish order below puts the
729
+ * descriptor (`.meta.json`) LAST so that any reader that observes
730
+ * the new meta.json is guaranteed to read the matching data
731
+ * sidecars alongside it.
732
+ *
733
+ * The brief window between data renames remains: a reader can
734
+ * still observe `(NEW vectors, OLD graph)` for a few microseconds.
735
+ * The size of the rebuilt index makes this window negligible on
736
+ * the existing single-writer process model; a deeper fix would
737
+ * publish all sidecars via a single packaged artifact under
738
+ * versioned manifest paths.
641
739
  */
642
740
  async save(indexPath = this.indexPath) {
643
741
  await fs.mkdir(path.dirname(indexPath), { recursive: true });
644
742
 
645
- // Save metadata
646
743
  const meta = {
647
744
  dimension: this.dimension,
648
745
  floatDimension: this.floatDimension,
@@ -658,47 +755,82 @@ export class BinaryHNSWIndex {
658
755
  savedAt: new Date().toISOString(),
659
756
  };
660
757
 
661
- const metaPath = indexPath.replace('.idx', '.meta.json');
662
- await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
663
-
664
- // Save vectors (binary + metadata)
665
758
  const vectorsData = this.vectors.map(v => ({
666
759
  id: v.id,
667
760
  binary: Array.from(v.binary),
668
761
  metadata: v.metadata,
669
762
  }));
670
763
 
764
+ const metaPath = indexPath.replace('.idx', '.meta.json');
671
765
  const vectorsPath = indexPath.replace('.idx', '.vectors.json');
672
- await fs.writeFile(vectorsPath, JSON.stringify(vectorsData));
673
-
674
- // Save graph structure
675
766
  const graphPath = indexPath.replace('.idx', '.graph.json');
676
- await fs.writeFile(graphPath, JSON.stringify(this.graph));
767
+ const int8Path = indexPath.replace('.idx', '.int8.json');
768
+ const calibPath = indexPath.replace('.idx', '.calibration.json');
769
+ const pidSuffix = `.tmp.${process.pid}`;
677
770
 
678
- // Save int8 vectors if any
771
+ // Stage all sidecars to sibling temp paths.
772
+ await fs.writeFile(metaPath + pidSuffix, JSON.stringify(meta, null, 2));
773
+ await fs.writeFile(vectorsPath + pidSuffix, JSON.stringify(vectorsData));
774
+ await fs.writeFile(graphPath + pidSuffix, JSON.stringify(this.graph));
775
+
776
+ let stagedInt8 = false;
679
777
  if (this.int8Vectors.size > 0) {
778
+ const liveIds = new Set(this.vectors.map(v => v.id));
680
779
  const int8Data = {};
681
780
  for (const [id, vec] of this.int8Vectors) {
781
+ if (!liveIds.has(id)) continue;
682
782
  int8Data[id] = Array.from(vec);
683
783
  }
684
- const int8Path = indexPath.replace('.idx', '.int8.json');
685
- await fs.writeFile(int8Path, JSON.stringify(int8Data));
784
+ if (Object.keys(int8Data).length > 0) {
785
+ await fs.writeFile(int8Path + pidSuffix, JSON.stringify(int8Data));
786
+ stagedInt8 = true;
787
+ }
686
788
  }
687
789
 
688
- // Save asymmetric calibration data (centroid + rotation signs)
790
+ let stagedCalib = false;
689
791
  if (this.useAsymmetric && this.centroid && this.signVector) {
690
- const calibPath = indexPath.replace('.idx', '.calibration.json');
691
- await fs.writeFile(calibPath, JSON.stringify({
792
+ await fs.writeFile(calibPath + pidSuffix, JSON.stringify({
692
793
  centroid: Array.from(this.centroid),
693
794
  signVector: Array.from(this.signVector),
694
795
  }));
796
+ stagedCalib = true;
797
+ }
798
+
799
+ // Atomic publish in safety order: data sidecars first, descriptor
800
+ // (.meta.json) LAST. Optional sidecars without staged content are
801
+ // unlinked from the canonical path so the canonical state matches
802
+ // the staged tuple exactly.
803
+ await fs.rename(vectorsPath + pidSuffix, vectorsPath);
804
+ await fs.rename(graphPath + pidSuffix, graphPath);
805
+ if (stagedInt8) await fs.rename(int8Path + pidSuffix, int8Path);
806
+ else await fs.rm(int8Path, { force: true });
807
+ if (stagedCalib) await fs.rename(calibPath + pidSuffix, calibPath);
808
+ else await fs.rm(calibPath, { force: true });
809
+ await fs.rename(metaPath + pidSuffix, metaPath);
810
+
811
+ if (this._cleanBuild) {
812
+ await fs.rm(this._stalePathForIndex(indexPath), { force: true });
813
+ this._staleBitmapCache = null;
814
+ this._cleanBuild = false;
695
815
  }
696
816
 
697
817
  console.log(`BinaryHNSW: Saved ${this.vectors.length} vectors to ${indexPath} (asymmetric=${this.useAsymmetric})`);
698
818
  }
699
819
 
700
820
  /**
701
- * Load index from disk
821
+ * Load index from disk.
822
+ *
823
+ * Torn-publish handling: `save()` publishes data sidecars and
824
+ * `.meta.json` via separate atomic renames. A reader that opens the
825
+ * index in the microsecond window between renames can observe a torn
826
+ * `(meta, vectors)` pair where `meta.entryPoint` references an index
827
+ * past `vectors.length`. The first `search()` call would then
828
+ * `TypeError` on `this.vectors[entryPoint].binary`. To keep fresh
829
+ * readers crash-free we re-read the pair up to three times on
830
+ * `(vectorCount, entryPoint)` inconsistency — the publish window
831
+ * closes in microseconds so a brief retry self-heals it. A persistent
832
+ * mismatch surfaces as an explicit load error rather than a deferred
833
+ * search crash.
702
834
  */
703
835
  async load(indexPath = this.indexPath) {
704
836
  const metaPath = indexPath.replace('.idx', '.meta.json');
@@ -710,11 +842,12 @@ export class BinaryHNSWIndex {
710
842
  throw new Error(`Index metadata not found: ${metaPath}`);
711
843
  }
712
844
 
713
- // Load metadata
714
- const meta = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
715
-
716
- // Validate pipeline version mismatched indexes must be rebuilt
717
- const storedVersion = meta.pipelineVersion || 1;
845
+ // Validate pipeline version first — a stale on-disk artifact from a
846
+ // previous quantization scheme is a callable-level error, not a
847
+ // torn-publish race, and the caller needs the specific message to
848
+ // route to the rebuild path.
849
+ const initialMeta = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
850
+ const storedVersion = initialMeta.pipelineVersion || 1;
718
851
  if (storedVersion !== PIPELINE_VERSION) {
719
852
  throw new Error(
720
853
  `Pipeline version mismatch: index=${storedVersion}, current=${PIPELINE_VERSION}. ` +
@@ -722,6 +855,27 @@ export class BinaryHNSWIndex {
722
855
  );
723
856
  }
724
857
 
858
+ let meta;
859
+ let vectorsData;
860
+ let attempt = 0;
861
+ while (true) {
862
+ meta = attempt === 0 ? initialMeta : JSON.parse(await fs.readFile(metaPath, 'utf-8'));
863
+ vectorsData = JSON.parse(await fs.readFile(vectorsPath, 'utf-8'));
864
+ const consistent = meta.vectorCount === vectorsData.length
865
+ && (meta.entryPoint === -1 || meta.entryPoint < vectorsData.length);
866
+ if (consistent) break;
867
+ if (attempt >= 2) {
868
+ throw new Error(
869
+ `BinaryHNSW: persistent meta/vectors inconsistency at ${indexPath} ` +
870
+ `(meta.vectorCount=${meta.vectorCount}, vectors.length=${vectorsData.length}, ` +
871
+ `entryPoint=${meta.entryPoint})`
872
+ );
873
+ }
874
+ // Brief delay to let an in-flight save() finish its second rename.
875
+ await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
876
+ attempt += 1;
877
+ }
878
+
725
879
  this.dimension = meta.dimension;
726
880
  this.floatDimension = meta.floatDimension;
727
881
  this.M = meta.M;
@@ -730,9 +884,10 @@ export class BinaryHNSWIndex {
730
884
  this.maxLevel = meta.maxLevel;
731
885
  this.entryPoint = meta.entryPoint;
732
886
  this.useAsymmetric = meta.useAsymmetric || false;
887
+ this.centroid = null;
888
+ this.signVector = null;
733
889
 
734
- // Load vectors
735
- const vectorsData = JSON.parse(await fs.readFile(vectorsPath, 'utf-8'));
890
+ // Vectors already read above as part of the consistency probe.
736
891
  this.vectors = vectorsData.map(v => ({
737
892
  id: v.id,
738
893
  binary: new Uint8Array(v.binary),
@@ -749,9 +904,9 @@ export class BinaryHNSWIndex {
749
904
  this.graph = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
750
905
 
751
906
  // Load int8 vectors if available
907
+ this.int8Vectors.clear();
752
908
  if (existsSync(int8Path)) {
753
909
  const int8Data = JSON.parse(await fs.readFile(int8Path, 'utf-8'));
754
- this.int8Vectors.clear();
755
910
  for (const [id, vec] of Object.entries(int8Data)) {
756
911
  this.int8Vectors.set(id, new Int8Array(vec));
757
912
  }
@@ -808,6 +963,9 @@ export class BinaryHNSWIndex {
808
963
  this.graph = [];
809
964
  this.entryPoint = -1;
810
965
  this.maxLevel = 0;
966
+ await fs.rm(this.stalePath, { force: true });
967
+ this._staleBitmapCache = null;
968
+ this._cleanBuild = false;
811
969
  }
812
970
  }
813
971
 
@@ -818,7 +976,7 @@ export class BinaryHNSWIndex {
818
976
  export async function createBinaryHNSWIndex(options = {}) {
819
977
  const index = new BinaryHNSWIndex(options);
820
978
 
821
- if (options.load !== false && existsSync(options.indexPath || DB_PATHS.binaryHnswIndex)) {
979
+ if (options.load !== false && binaryHnswArtifactsExist(options.indexPath || DB_PATHS.binaryHnswIndex)) {
822
980
  try {
823
981
  await index.load(options.indexPath);
824
982
  } catch (err) {
@@ -832,6 +990,12 @@ export async function createBinaryHNSWIndex(options = {}) {
832
990
  return index;
833
991
  }
834
992
 
993
+ function binaryHnswArtifactsExist(indexPath) {
994
+ return existsSync(indexPath.replace('.idx', '.meta.json'))
995
+ && existsSync(indexPath.replace('.idx', '.vectors.json'))
996
+ && existsSync(indexPath.replace('.idx', '.graph.json'));
997
+ }
998
+
835
999
  // =============================================================================
836
1000
  // CLI
837
1001
  // =============================================================================
@@ -25,7 +25,7 @@
25
25
  * 3. Batch dot product for Stage 2.5 candidates
26
26
  */
27
27
 
28
- import { readFile, writeFile } from 'fs/promises';
28
+ import { readFile, writeFile, rename } from 'fs/promises';
29
29
  import { existsSync } from 'fs';
30
30
  import path from 'path';
31
31
  import { float32BatchDot } from '../infrastructure/simd-distance.js';
@@ -81,19 +81,29 @@ export class FloatVectorStore {
81
81
 
82
82
  /**
83
83
  * Save to disk: binary file + ID map JSON.
84
+ *
85
+ * Both files are written via temp-file + atomic rename so a crash mid-write
86
+ * never leaves a half-written artifact, and a concurrent reader either sees
87
+ * the whole previous version or the whole new one. The binary is renamed
88
+ * first; if a crash interleaves the two renames, the next load sees a new
89
+ * binary against the old ID map — the count guard in `load()` rejects that
90
+ * mismatch and Stage 2.5 cleanly falls back to SQLite until re-reconciled.
91
+ *
84
92
  * @param {string} binPath - Path for binary file (e.g., foo.float-vectors.bin)
85
93
  */
86
94
  async save(binPath) {
87
95
  if (!this.loaded) throw new Error('FloatVectorStore: nothing to save');
88
96
 
89
- // Write binary file
90
- await writeFile(binPath, Buffer.from(this.buffer));
91
-
92
- // Write ID map
93
97
  const idsPath = binPath.replace(/\.bin$/, '.ids.json');
94
98
  const ids = new Array(this.count);
95
99
  for (const [id, idx] of this.idToIndex) ids[idx] = id;
96
- await writeFile(idsPath, JSON.stringify(ids));
100
+
101
+ const binTmp = `${binPath}.tmp.${process.pid}`;
102
+ const idsTmp = `${idsPath}.tmp.${process.pid}`;
103
+ await writeFile(binTmp, Buffer.from(this.buffer));
104
+ await writeFile(idsTmp, JSON.stringify(ids));
105
+ await rename(binTmp, binPath);
106
+ await rename(idsTmp, idsPath);
97
107
  }
98
108
 
99
109
  /**
@@ -131,6 +141,17 @@ export class FloatVectorStore {
131
141
  // Read ID map
132
142
  const idsJson = await readFile(idsPath, 'utf-8');
133
143
  const ids = JSON.parse(idsJson);
144
+
145
+ // Reject a torn binary/ID-map pair (e.g. a crash between the two atomic
146
+ // renames in save()). A mismatched count means the index→ID mapping would
147
+ // be wrong; refuse to load so Stage 2.5 falls back to SQLite instead.
148
+ if (ids.length !== this.count) {
149
+ this.buffer = null;
150
+ this.data = null;
151
+ this.count = 0;
152
+ return false;
153
+ }
154
+
134
155
  this.idToIndex = new Map();
135
156
  for (let i = 0; i < ids.length; i++) {
136
157
  this.idToIndex.set(ids[i], i);
@@ -140,6 +161,75 @@ export class FloatVectorStore {
140
161
  return true;
141
162
  }
142
163
 
164
+ /**
165
+ * Load an existing store, or initialise an empty mutable one at the given
166
+ * dimension when no store exists on disk yet. Used by the incremental
167
+ * reconcile path so the first add after an empty baseline produces a real
168
+ * float store rather than relying on the SQLite fallback.
169
+ *
170
+ * When a store loads, its on-disk dimension wins (it is authoritative for
171
+ * the vectors already stored); `dimension` only seeds a fresh empty store.
172
+ *
173
+ * @param {string} binPath
174
+ * @param {number} dimension
175
+ * @returns {Promise<boolean>} true if an existing store was loaded, false if initialised empty
176
+ */
177
+ async loadOrInit(binPath, dimension) {
178
+ let loaded = false;
179
+ try {
180
+ loaded = await this.load(binPath);
181
+ } catch {
182
+ loaded = false; // corrupt/torn store → treat as empty and rebuild from delta
183
+ }
184
+ if (loaded) return true;
185
+
186
+ this.dimension = dimension;
187
+ this.count = 0;
188
+ this.idToIndex = new Map();
189
+ this.data = new Float32Array(0);
190
+ this.buffer = null;
191
+ this.loaded = true;
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Apply incremental upserts/removes and rebuild the contiguous buffer.
197
+ *
198
+ * Writer-side only (the reconcile maintainer). The read hot path
199
+ * (get/batchScore over the contiguous buffer) is untouched; readers reload
200
+ * the rebuilt store from disk after the manifest publishes.
201
+ *
202
+ * Upserts replace any existing vector for the same ID. Removes drop the ID.
203
+ * The rebuilt buffer preserves no particular order — IDs are addressed via
204
+ * the idToIndex map, never by position.
205
+ *
206
+ * @param {{upserts?: Array<{id: string, vector: Float32Array|number[]}>, removeIds?: Iterable<string>}} delta
207
+ * @returns {{count: number}}
208
+ */
209
+ applyDelta({ upserts = [], removeIds = [] } = {}) {
210
+ // Materialise current id → vector. Subarrays view the old buffer; build()
211
+ // copies their values into a freshly allocated buffer before the old one
212
+ // is dropped, so the aliasing is safe.
213
+ const entries = new Map();
214
+ if (this.loaded && this.data && this.count > 0) {
215
+ for (const [id, idx] of this.idToIndex) {
216
+ const offset = idx * this.dimension;
217
+ entries.set(id, this.data.subarray(offset, offset + this.dimension));
218
+ }
219
+ }
220
+
221
+ for (const id of removeIds) entries.delete(id);
222
+ for (const { id, vector } of upserts) {
223
+ if (!id || !vector) continue;
224
+ entries.set(id, vector);
225
+ }
226
+
227
+ const list = [];
228
+ for (const [id, vector] of entries) list.push({ id, vector });
229
+ this.build(list, this.dimension);
230
+ return { count: this.count };
231
+ }
232
+
143
233
  /**
144
234
  * Get a float vector by string ID.
145
235
  * @param {string} id