sweet-search 2.5.2 → 2.5.3
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/core/cli.js +24 -3
- package/core/graph/graph-expansion.js +215 -36
- package/core/graph/graph-extractor.js +196 -11
- package/core/graph/graph-search.js +395 -92
- package/core/graph/hcgs-generator.js +2 -1
- package/core/graph/index.js +2 -0
- package/core/graph/repo-map.js +28 -6
- package/core/graph/structural-answer-cues.js +168 -0
- package/core/graph/structural-callsite-hints.js +40 -0
- package/core/graph/structural-context-format.js +40 -0
- package/core/graph/structural-context.js +450 -0
- package/core/graph/structural-forward-push.js +156 -0
- package/core/graph/structural-header-context.js +19 -0
- package/core/graph/structural-importance.js +148 -0
- package/core/graph/structural-pagerank.js +197 -0
- package/core/graph/summary-manager.js +13 -9
- package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
- package/core/incremental-indexing/application/file-watcher.mjs +197 -0
- package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
- package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
- package/core/incremental-indexing/application/operator-cli.mjs +554 -0
- package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
- package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
- package/core/incremental-indexing/application/reconciler.mjs +477 -0
- package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
- package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
- package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
- package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
- package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
- package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
- package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
- package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
- package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
- package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
- package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
- package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
- package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
- package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
- package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
- package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
- package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
- package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
- package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
- package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
- package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
- package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
- package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
- package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
- package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
- package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
- package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
- package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
- package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
- package/core/indexing/admission-policy.js +139 -0
- package/core/indexing/artifact-builder.js +29 -12
- package/core/indexing/ast-chunker.js +107 -30
- package/core/indexing/dedup/exemplar-selector.js +19 -1
- package/core/indexing/gitignore-filter.js +223 -0
- package/core/indexing/incremental-tracker.js +99 -30
- package/core/indexing/index-codebase-v21.js +6 -5
- package/core/indexing/index-maintainer.mjs +698 -6
- package/core/indexing/indexer-ann.js +99 -15
- package/core/indexing/indexer-build.js +158 -45
- package/core/indexing/indexer-empty-baseline.js +80 -0
- package/core/indexing/indexer-manifest.js +66 -0
- package/core/indexing/indexer-phases.js +56 -23
- package/core/indexing/indexer-sparse-gram.js +54 -13
- package/core/indexing/indexer-utils.js +26 -208
- package/core/indexing/indexing-file-policy.js +32 -7
- package/core/indexing/maintainer-launcher.mjs +137 -0
- package/core/indexing/merkle-tracker.js +251 -244
- package/core/indexing/model-pool.js +46 -5
- package/core/infrastructure/code-graph-repository.js +758 -6
- package/core/infrastructure/code-graph-visibility.js +157 -0
- package/core/infrastructure/codebase-repository.js +100 -13
- package/core/infrastructure/config/search.js +1 -1
- package/core/infrastructure/db-utils.js +118 -0
- package/core/infrastructure/dedup-hashing.js +10 -13
- package/core/infrastructure/hardware-capability.js +17 -7
- package/core/infrastructure/index.js +8 -2
- package/core/infrastructure/language-patterns/maps.js +4 -1
- package/core/infrastructure/language-patterns/registry-core.js +56 -17
- package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
- package/core/infrastructure/language-patterns.js +69 -0
- package/core/infrastructure/model-registry.js +20 -0
- package/core/infrastructure/native-inference.js +7 -12
- package/core/infrastructure/native-resolver.js +52 -37
- package/core/infrastructure/native-sparse-gram.js +261 -20
- package/core/infrastructure/native-tokenizer.js +6 -15
- package/core/infrastructure/simd-distance.js +10 -16
- package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
- package/core/infrastructure/structural-alias-resolver.js +122 -0
- package/core/infrastructure/structural-candidate-ranker.js +34 -0
- package/core/infrastructure/structural-context-repository.js +472 -0
- package/core/infrastructure/structural-context-utils.js +51 -0
- package/core/infrastructure/structural-graph-signals.js +121 -0
- package/core/infrastructure/structural-qualified-resolution.js +15 -0
- package/core/infrastructure/structural-source-definitions.js +100 -0
- package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
- package/core/infrastructure/tree-sitter-provider.js +811 -37
- package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
- package/core/query/query-router.js +55 -5
- package/core/ranking/file-kind-ranking.js +2192 -15
- package/core/ranking/late-interaction-index.js +87 -12
- package/core/search/cli-decoration.js +290 -0
- package/core/search/context-expander.js +988 -78
- package/core/search/index.js +1 -0
- package/core/search/output-policy.js +275 -0
- package/core/search/search-anchor.js +499 -0
- package/core/search/search-boost.js +93 -1
- package/core/search/search-cli.js +61 -204
- package/core/search/search-hybrid.js +250 -10
- package/core/search/search-pattern-chunks.js +57 -8
- package/core/search/search-pattern-planner.js +68 -9
- package/core/search/search-pattern-prefilter.js +30 -10
- package/core/search/search-pattern-ripgrep.js +40 -4
- package/core/search/search-pattern-sparse-overlay.js +256 -0
- package/core/search/search-pattern.js +117 -29
- package/core/search/search-postprocess.js +479 -5
- package/core/search/search-read-semantic.js +260 -23
- package/core/search/search-read.js +82 -64
- package/core/search/search-reader-pin.js +71 -0
- package/core/search/search-rrf.js +279 -0
- package/core/search/search-semantic.js +110 -5
- package/core/search/search-server.js +130 -57
- package/core/search/search-trace.js +107 -0
- package/core/search/server-identity.js +93 -0
- package/core/search/session-daemon-prewarm.mjs +33 -10
- package/core/search/sweet-search.js +399 -7
- package/core/skills/sweet-index/SKILL.md +8 -6
- package/core/vector-store/binary-hnsw-index.js +194 -30
- package/core/vector-store/float-vector-store.js +96 -6
- package/core/vector-store/hnsw-index.js +220 -49
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
- package/eval/agent-read-workflows/bin/ss-find +15 -0
- package/eval/agent-read-workflows/bin/ss-grep +12 -0
- package/eval/agent-read-workflows/bin/ss-read +14 -0
- package/eval/agent-read-workflows/bin/ss-search +18 -0
- package/eval/agent-read-workflows/bin/ss-semantic +12 -0
- package/eval/agent-read-workflows/bin/ss-trace +11 -0
- package/mcp/read-tool.js +109 -0
- package/mcp/server.js +55 -15
- package/mcp/tool-handlers.js +14 -124
- package/mcp/trace-tool.js +81 -0
- package/package.json +25 -10
- package/scripts/hooks/intercept-read.mjs +55 -0
- package/scripts/hooks/remind-tools.mjs +40 -0
- package/scripts/init.js +698 -54
- package/scripts/inject-agent-instructions.js +431 -0
- package/scripts/install-prompt-reminders.js +188 -0
- package/scripts/install-tool-enforcement.js +220 -0
- package/scripts/smoke-test.js +12 -9
- package/scripts/uninstall.js +276 -18
- 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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
790
|
+
let stagedCalib = false;
|
|
689
791
|
if (this.useAsymmetric && this.centroid && this.signVector) {
|
|
690
|
-
|
|
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
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
const
|
|
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
|
-
//
|
|
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 &&
|
|
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
|
-
|
|
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
|