@soulcraft/cor 3.0.15 → 3.0.17
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/dist/graph/NativeGraphAdjacencyIndex.d.ts +7 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +57 -7
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +41 -1
- package/dist/hnsw/NativeDiskAnnWrapper.js +251 -2
- package/dist/native/types.d.ts +7 -0
- package/dist/utils/NativeColumnStore.d.ts +6 -0
- package/dist/utils/NativeColumnStore.js +61 -11
- package/dist/utils/NativeMetadataIndex.d.ts +40 -0
- package/dist/utils/NativeMetadataIndex.js +354 -20
- package/dist/utils/derivedFamilies.d.ts +48 -0
- package/dist/utils/derivedFamilies.js +128 -0
- package/dist/utils/invariantReport.d.ts +29 -0
- package/dist/utils/invariantReport.js +62 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +23 -1
- package/package.json +2 -2
|
@@ -17,6 +17,7 @@ import type { NativeGraphAdjacencyInstance } from '../native/types.js';
|
|
|
17
17
|
import type { GraphIndexProvider } from '../providerContracts.js';
|
|
18
18
|
import type { EntityIdMapperLike } from '../utils/columnStoreTypes.js';
|
|
19
19
|
import type { MigrationCoordinator, MigrationStatus } from '../migration/MigrationCoordinator.js';
|
|
20
|
+
import type { ProviderInvariantReport } from '@soulcraft/brainy';
|
|
20
21
|
export interface GraphIndexConfig {
|
|
21
22
|
maxIndexSize?: number;
|
|
22
23
|
rebuildThreshold?: number;
|
|
@@ -262,6 +263,12 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
|
|
|
262
263
|
* genuinely EMPTY — distinguished from "edges exist on disk but didn't load"
|
|
263
264
|
* (which must still rebuild) via the persisted SSTable + memtable counts.
|
|
264
265
|
*/
|
|
266
|
+
/**
|
|
267
|
+
* ADR-004 §6 — the graph provider's invariant self-report. NEVER throws;
|
|
268
|
+
* bounded (one native stats read + two flags). Brainy aggregates via
|
|
269
|
+
* `validateIndexConsistency`; `heal: 'rebuild'` maps to {@link rebuild}.
|
|
270
|
+
*/
|
|
271
|
+
validateInvariants(): Promise<ProviderInvariantReport>;
|
|
265
272
|
isReady(): boolean;
|
|
266
273
|
/**
|
|
267
274
|
* @description **#18 migration lock — feature-detected by Brainy.** `true` while
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import { dirname } from 'node:path';
|
|
16
16
|
import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
|
|
17
17
|
import { loadNativeModule } from '../native/index.js';
|
|
18
|
+
import { composeInvariantReport } from '../utils/invariantReport.js';
|
|
19
|
+
import { declareDerivedFamilies, withFamilyRetirement, GRAPH_FAMILIES } from '../utils/derivedFamilies.js';
|
|
18
20
|
import { getGlobalCache, prodLog } from '@soulcraft/brainy/internals';
|
|
19
21
|
/**
|
|
20
22
|
* Fallback verb-type index for unknown / missing verb-type strings.
|
|
@@ -216,6 +218,8 @@ export class GraphAdjacencyIndex {
|
|
|
216
218
|
* an index that is never `init()`-ed still cold-loads on first touch.
|
|
217
219
|
*/
|
|
218
220
|
async init() {
|
|
221
|
+
// ADR-004 §7: declare the graph families (feature-detected ≥8.3.0).
|
|
222
|
+
await declareDerivedFamilies(this.storage, GRAPH_FAMILIES);
|
|
219
223
|
await this.ensureInitialized();
|
|
220
224
|
}
|
|
221
225
|
/**
|
|
@@ -561,6 +565,43 @@ export class GraphAdjacencyIndex {
|
|
|
561
565
|
* genuinely EMPTY — distinguished from "edges exist on disk but didn't load"
|
|
562
566
|
* (which must still rebuild) via the persisted SSTable + memtable counts.
|
|
563
567
|
*/
|
|
568
|
+
/**
|
|
569
|
+
* ADR-004 §6 — the graph provider's invariant self-report. NEVER throws;
|
|
570
|
+
* bounded (one native stats read + two flags). Brainy aggregates via
|
|
571
|
+
* `validateIndexConsistency`; `heal: 'rebuild'` maps to {@link rebuild}.
|
|
572
|
+
*/
|
|
573
|
+
async validateInvariants() {
|
|
574
|
+
return composeInvariantReport('graph', () => this.isReady(), [
|
|
575
|
+
() => {
|
|
576
|
+
const raw = this.native.getStats();
|
|
577
|
+
const s = (typeof raw === 'string' ? JSON.parse(raw) : raw);
|
|
578
|
+
if (s.loadComplete === undefined) {
|
|
579
|
+
return {
|
|
580
|
+
name: 'manifest-residency',
|
|
581
|
+
holds: true,
|
|
582
|
+
detail: 'binary predates residency stats (pre-3.0.13) — unverifiable here',
|
|
583
|
+
heal: 'none',
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
name: 'manifest-residency',
|
|
588
|
+
holds: s.loadComplete,
|
|
589
|
+
detail: s.loadComplete
|
|
590
|
+
? 'every manifest-named SSTable resident across all four trees'
|
|
591
|
+
: 'a manifest-named SSTable is NOT resident — the graph would serve a subset of its edges',
|
|
592
|
+
heal: 'rebuild',
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
() => ({
|
|
596
|
+
name: 'canonical-coverage',
|
|
597
|
+
holds: !this.canonicalUnindexed,
|
|
598
|
+
detail: this.canonicalUnindexed
|
|
599
|
+
? 'canonical verbs exist with no durable graph state (migration/restore shape) — a rebuild is required'
|
|
600
|
+
: 'no canonical-vs-durable coverage gap detected',
|
|
601
|
+
heal: 'rebuild',
|
|
602
|
+
}),
|
|
603
|
+
]);
|
|
604
|
+
}
|
|
564
605
|
isReady() {
|
|
565
606
|
if (this.native.isReady())
|
|
566
607
|
return true;
|
|
@@ -974,13 +1015,22 @@ export class GraphAdjacencyIndex {
|
|
|
974
1015
|
// just leaves orphans, never a missing-blob error.
|
|
975
1016
|
await this.saveTreeManifest(treeName, prefix);
|
|
976
1017
|
// 3. Delete old blobs (best-effort orphan cleanup AFTER the
|
|
977
|
-
// manifest no longer references them)
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1018
|
+
// manifest no longer references them) — inside an owner-
|
|
1019
|
+
// sanctioned retirement window (ADR-004 §7): compaction is the
|
|
1020
|
+
// ONE legitimate deleter of graph-lsm members, so the family is
|
|
1021
|
+
// unregistered around exactly this loop and re-registered in a
|
|
1022
|
+
// finally (self-healing: every init re-declares). Failures stay
|
|
1023
|
+
// harmless — the worst outcome is leaked disk space.
|
|
1024
|
+
await withFamilyRetirement(this.storage, GRAPH_FAMILIES[0], async () => {
|
|
1025
|
+
await Promise.all(result.oldIds.map((oldId) => {
|
|
1026
|
+
const oldBlobKey = this.blobKey(treeName, oldId);
|
|
1027
|
+
return this.storage
|
|
1028
|
+
.deleteBinaryBlob(oldBlobKey)
|
|
1029
|
+
.catch(() => {
|
|
1030
|
+
/* Orphaned blobs acceptable */
|
|
1031
|
+
});
|
|
1032
|
+
}));
|
|
1033
|
+
});
|
|
984
1034
|
const elapsed = Date.now() - startTime;
|
|
985
1035
|
prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
|
|
986
1036
|
if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
* brainy 8.0's canonical `'vector'` provider key. Whenever cor is
|
|
71
71
|
* installed, brainy's vector index slot resolves to Adaptive DiskANN.
|
|
72
72
|
*/
|
|
73
|
-
import type { Vector, VectorDocument, DistanceFunction, StorageAdapter } from '@soulcraft/brainy';
|
|
73
|
+
import type { Vector, VectorDocument, DistanceFunction, StorageAdapter, ProviderInvariantReport } from '@soulcraft/brainy';
|
|
74
74
|
import type { VectorIndexProvider } from '../providerContracts.js';
|
|
75
75
|
import type { EntityIdMapperLike } from '../utils/columnStoreTypes.js';
|
|
76
76
|
import type { MigrationCoordinator, MigrationStatus } from '../migration/MigrationCoordinator.js';
|
|
@@ -237,6 +237,14 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
|
|
|
237
237
|
* index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
|
|
238
238
|
*/
|
|
239
239
|
private durableBaseLoadFailed;
|
|
240
|
+
/**
|
|
241
|
+
* L0 segments named by the persisted manifest that FAILED to load at
|
|
242
|
+
* cold open (skipped with a warn; entries remain in canonical and heal
|
|
243
|
+
* via consolidation). Feeds the ADR-004 §6 `segments-resident`
|
|
244
|
+
* invariant so the deliberately-tolerated gap is VISIBLE cross-layer
|
|
245
|
+
* instead of only a boot-time warn line.
|
|
246
|
+
*/
|
|
247
|
+
private skippedManifestSegments;
|
|
240
248
|
/** Serializes manifest writes: the storage adapter has no per-key write
|
|
241
249
|
* ordering, so two in-flight saves (a flush racing a consolidation) could
|
|
242
250
|
* physically land out of order and persist a STALE segment list — losing a
|
|
@@ -377,6 +385,14 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
|
|
|
377
385
|
* (see {@link loadSegmentManifest}).
|
|
378
386
|
* @returns `true` when a cold-open rebuild would be redundant.
|
|
379
387
|
*/
|
|
388
|
+
/**
|
|
389
|
+
* ADR-004 §6 — the vector provider's invariant self-report. NEVER
|
|
390
|
+
* throws; bounded (flags + fault-discriminating stats on ≤257 sidecar
|
|
391
|
+
* paths — the L0 hard cap bounds the loop). `heal: 'rebuild'` maps to
|
|
392
|
+
* {@link rebuild}; the skipped-segment gap is deliberately
|
|
393
|
+
* consolidation-healed and reports `heal: 'none'`.
|
|
394
|
+
*/
|
|
395
|
+
validateInvariants(): Promise<ProviderInvariantReport>;
|
|
380
396
|
isReady(): boolean;
|
|
381
397
|
/**
|
|
382
398
|
* @description **#18 migration lock — structured progress** Brainy relays into
|
|
@@ -504,6 +520,30 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
|
|
|
504
520
|
private flushDeltaToL0;
|
|
505
521
|
/** The flush body — see {@link flushDeltaToL0}. */
|
|
506
522
|
private runFlushToL0;
|
|
523
|
+
/**
|
|
524
|
+
* ADR-004 §7 — verify the `main.familygen` stamp the native publish
|
|
525
|
+
* writes (3.0.16+). The stamp commits the base + sidecar SET with each
|
|
526
|
+
* member's exact byte size; a member missing or at the wrong size means
|
|
527
|
+
* the family on disk is torn (interrupted swap or external mutation)
|
|
528
|
+
* and must NOT be served. Stamp absence is a pre-stamp layout — legacy
|
|
529
|
+
* per-file tolerance applies. A read/stat FAULT is `unverifiable`, never
|
|
530
|
+
* conflated with absence (§4): we proceed on the per-file checks and
|
|
531
|
+
* report it, but never trigger a spurious rebuild from a flaky probe.
|
|
532
|
+
*/
|
|
533
|
+
/**
|
|
534
|
+
* Sibling path with the LAST extension swapped — the exact semantics of
|
|
535
|
+
* Rust's `Path::set_extension`, which names every family member. This
|
|
536
|
+
* MUST NOT assume the base ends in `.dkann`: production indexPaths come
|
|
537
|
+
* from `getBinaryBlobPath` and FileSystemStorage appends `.bin`
|
|
538
|
+
* (`main.dkann.bin`), so the sidecars/stamp are `main.dkann.slotmap` /
|
|
539
|
+
* `main.dkann.familygen` there, while bare `main.dkann` paths (tests,
|
|
540
|
+
* benchmarks) yield `main.slotmap` / `main.familygen`. A `.dkann`-only
|
|
541
|
+
* regex silently no-ops on the production shape — the 3.0.16 gate
|
|
542
|
+
* caught the stamp verifier READING THE BINARY BASE as its own stamp
|
|
543
|
+
* (incoherent verdict on every healthy brain ⇒ rebuild loop).
|
|
544
|
+
*/
|
|
545
|
+
private siblingPath;
|
|
546
|
+
private verifyFamilyStamp;
|
|
507
547
|
private tryOpenExisting;
|
|
508
548
|
/**
|
|
509
549
|
* Remove any leftover `main.slots.json` written by a pre-#72 RC build.
|
|
@@ -70,10 +70,13 @@
|
|
|
70
70
|
* brainy 8.0's canonical `'vector'` provider key. Whenever cor is
|
|
71
71
|
* installed, brainy's vector index slot resolves to Adaptive DiskANN.
|
|
72
72
|
*/
|
|
73
|
+
import { composeInvariantReport } from '../utils/invariantReport.js';
|
|
74
|
+
import { declareDerivedFamilies, VECTOR_FAMILIES } from '../utils/derivedFamilies.js';
|
|
75
|
+
import { statSync, readFileSync } from 'node:fs';
|
|
73
76
|
import { loadNativeModule } from '../native/index.js';
|
|
74
77
|
import { prodLog } from '@soulcraft/brainy/internals';
|
|
75
78
|
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
76
|
-
import { dirname } from 'node:path';
|
|
79
|
+
import { dirname, join } from 'node:path';
|
|
77
80
|
import { autoModeForHeader, selectModeFromResourceManager, } from './AdaptiveDiskAnnModeSelector.js';
|
|
78
81
|
/**
|
|
79
82
|
* Node-count threshold above which `useMmapAdjacency: 'auto'`
|
|
@@ -374,6 +377,14 @@ export class NativeDiskAnnWrapper {
|
|
|
374
377
|
* index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
|
|
375
378
|
*/
|
|
376
379
|
durableBaseLoadFailed = false;
|
|
380
|
+
/**
|
|
381
|
+
* L0 segments named by the persisted manifest that FAILED to load at
|
|
382
|
+
* cold open (skipped with a warn; entries remain in canonical and heal
|
|
383
|
+
* via consolidation). Feeds the ADR-004 §6 `segments-resident`
|
|
384
|
+
* invariant so the deliberately-tolerated gap is VISIBLE cross-layer
|
|
385
|
+
* instead of only a boot-time warn line.
|
|
386
|
+
*/
|
|
387
|
+
skippedManifestSegments = [];
|
|
377
388
|
/** Serializes manifest writes: the storage adapter has no per-key write
|
|
378
389
|
* ordering, so two in-flight saves (a flush racing a consolidation) could
|
|
379
390
|
* physically land out of order and persist a STALE segment list — losing a
|
|
@@ -851,6 +862,126 @@ export class NativeDiskAnnWrapper {
|
|
|
851
862
|
* (see {@link loadSegmentManifest}).
|
|
852
863
|
* @returns `true` when a cold-open rebuild would be redundant.
|
|
853
864
|
*/
|
|
865
|
+
/**
|
|
866
|
+
* ADR-004 §6 — the vector provider's invariant self-report. NEVER
|
|
867
|
+
* throws; bounded (flags + fault-discriminating stats on ≤257 sidecar
|
|
868
|
+
* paths — the L0 hard cap bounds the loop). `heal: 'rebuild'` maps to
|
|
869
|
+
* {@link rebuild}; the skipped-segment gap is deliberately
|
|
870
|
+
* consolidation-healed and reports `heal: 'none'`.
|
|
871
|
+
*/
|
|
872
|
+
async validateInvariants() {
|
|
873
|
+
return composeInvariantReport('vector', () => this.isReady(), [
|
|
874
|
+
() => ({
|
|
875
|
+
name: 'base-resident',
|
|
876
|
+
holds: !this.durableBaseLoadFailed,
|
|
877
|
+
detail: this.durableBaseLoadFailed
|
|
878
|
+
? 'a durable base index exists but failed to load (corrupt base or missing sidecar) — searches would silently miss its vectors'
|
|
879
|
+
: 'durable base (if any) loaded',
|
|
880
|
+
heal: 'rebuild',
|
|
881
|
+
}),
|
|
882
|
+
() => ({
|
|
883
|
+
name: 'segments-resident',
|
|
884
|
+
holds: this.skippedManifestSegments.length === 0,
|
|
885
|
+
detail: this.skippedManifestSegments.length === 0
|
|
886
|
+
? `${this.l0Segments.length} manifest-named L0 segment(s), all loaded`
|
|
887
|
+
: `${this.skippedManifestSegments.length} manifest-named L0 segment(s) ` +
|
|
888
|
+
`failed to load (${this.skippedManifestSegments.join(', ')}) — their ` +
|
|
889
|
+
`entries remain in canonical and heal via consolidation`,
|
|
890
|
+
expected: this.l0Segments.length + this.skippedManifestSegments.length,
|
|
891
|
+
actual: this.l0Segments.length,
|
|
892
|
+
heal: 'none',
|
|
893
|
+
}),
|
|
894
|
+
() => {
|
|
895
|
+
// sidecars-coherent: base + every loaded segment has both sidecars.
|
|
896
|
+
// Fault-discriminating stat (ADR §4): a stat FAULT is reported as a
|
|
897
|
+
// failing check, never as absence.
|
|
898
|
+
const missing = [];
|
|
899
|
+
const faulted = [];
|
|
900
|
+
const probe = (path) => {
|
|
901
|
+
try {
|
|
902
|
+
statSync(path);
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
const code = error.code;
|
|
906
|
+
if (code === 'ENOENT')
|
|
907
|
+
missing.push(path);
|
|
908
|
+
else
|
|
909
|
+
faulted.push(`${path} (${code})`);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
// Last-extension swap (Rust set_extension semantics) — a
|
|
913
|
+
// .dkann-only regex no-ops on production's `.dkann.bin` paths and
|
|
914
|
+
// made this check vacuously probe the base file twice.
|
|
915
|
+
const sidecarsOf = (dkannPath) => [
|
|
916
|
+
this.siblingPath(dkannPath, 'slotmap'),
|
|
917
|
+
this.siblingPath(dkannPath, 'slotrev'),
|
|
918
|
+
];
|
|
919
|
+
if (this.native && this.config.indexPath) {
|
|
920
|
+
for (const s of sidecarsOf(this.config.indexPath))
|
|
921
|
+
probe(s);
|
|
922
|
+
}
|
|
923
|
+
for (const seg of this.l0Segments) {
|
|
924
|
+
for (const s of sidecarsOf(this.segPath(seg.file)))
|
|
925
|
+
probe(s);
|
|
926
|
+
}
|
|
927
|
+
if (faulted.length > 0) {
|
|
928
|
+
return {
|
|
929
|
+
name: 'sidecars-coherent',
|
|
930
|
+
holds: false,
|
|
931
|
+
detail: `sidecar stat FAULTED (not absent) — coherence unverifiable: ${faulted.join(', ')}`,
|
|
932
|
+
heal: 'none',
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
name: 'sidecars-coherent',
|
|
937
|
+
holds: missing.length === 0,
|
|
938
|
+
detail: missing.length === 0
|
|
939
|
+
? 'every loaded base/segment has both sidecars on disk'
|
|
940
|
+
: `${missing.length} sidecar(s) missing for LOADED members: ${missing.join(', ')} — ` +
|
|
941
|
+
`the family is incoherent on disk (a restart would fail these loads)`,
|
|
942
|
+
heal: 'rebuild',
|
|
943
|
+
};
|
|
944
|
+
},
|
|
945
|
+
() => {
|
|
946
|
+
// family-coherent (§7): the familygen stamp — when the 3.0.16+
|
|
947
|
+
// publish wrote one — must find every member it committed at its
|
|
948
|
+
// EXACT byte size, live on disk. Absence is a pre-stamp layout,
|
|
949
|
+
// not a violation; a stat/read fault is unverifiable (heal
|
|
950
|
+
// 'none' — a flaky probe never triggers a spurious rebuild).
|
|
951
|
+
const family = this.verifyFamilyStamp();
|
|
952
|
+
switch (family.state) {
|
|
953
|
+
case 'coherent':
|
|
954
|
+
return {
|
|
955
|
+
name: 'family-coherent',
|
|
956
|
+
holds: true,
|
|
957
|
+
detail: `familygen stamp verified — every member at its committed size (generation ${family.generation})`,
|
|
958
|
+
heal: 'rebuild',
|
|
959
|
+
};
|
|
960
|
+
case 'absent':
|
|
961
|
+
return {
|
|
962
|
+
name: 'family-coherent',
|
|
963
|
+
holds: true,
|
|
964
|
+
detail: 'no familygen stamp (pre-3.0.16 publish) — per-file checks apply',
|
|
965
|
+
heal: 'rebuild',
|
|
966
|
+
};
|
|
967
|
+
case 'unverifiable':
|
|
968
|
+
return {
|
|
969
|
+
name: 'family-coherent',
|
|
970
|
+
holds: false,
|
|
971
|
+
detail: `family coherence UNVERIFIABLE: ${family.violations.join('; ')}`,
|
|
972
|
+
heal: 'none',
|
|
973
|
+
};
|
|
974
|
+
case 'incoherent':
|
|
975
|
+
return {
|
|
976
|
+
name: 'family-coherent',
|
|
977
|
+
holds: false,
|
|
978
|
+
detail: `familygen stamp violated — the family on disk is torn: ${family.violations.join('; ')}`,
|
|
979
|
+
heal: 'rebuild',
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
]);
|
|
984
|
+
}
|
|
854
985
|
isReady() {
|
|
855
986
|
if (this.durableBaseLoadFailed)
|
|
856
987
|
return false;
|
|
@@ -1355,6 +1486,9 @@ export class NativeDiskAnnWrapper {
|
|
|
1355
1486
|
* still gets the segments lazily; only the pre-gate timing needs init().)
|
|
1356
1487
|
*/
|
|
1357
1488
|
async init() {
|
|
1489
|
+
// ADR-004 §7: declare the vector families (feature-detected ≥8.3.0;
|
|
1490
|
+
// idempotent; loud-but-non-blocking).
|
|
1491
|
+
await declareDerivedFamilies(this.storage, VECTOR_FAMILIES);
|
|
1358
1492
|
await this.ensureSegments();
|
|
1359
1493
|
}
|
|
1360
1494
|
/** Memoized segment-manifest cold-load (+ canonical-coverage probe). */
|
|
@@ -1431,6 +1565,7 @@ export class NativeDiskAnnWrapper {
|
|
|
1431
1565
|
}
|
|
1432
1566
|
if (!manifest)
|
|
1433
1567
|
return;
|
|
1568
|
+
this.skippedManifestSegments = [];
|
|
1434
1569
|
this.manifestNextId = Math.max(1, Number(manifest.nextId) || 1);
|
|
1435
1570
|
const bindings = loadNativeModule();
|
|
1436
1571
|
const NativeDiskANN = bindings.NativeDiskAnn;
|
|
@@ -1444,6 +1579,7 @@ export class NativeDiskAnnWrapper {
|
|
|
1444
1579
|
this.l0Segments.push({ ...entry, native });
|
|
1445
1580
|
}
|
|
1446
1581
|
catch (error) {
|
|
1582
|
+
this.skippedManifestSegments.push(entry.file);
|
|
1447
1583
|
prodLog.warn(`NativeDiskAnnWrapper: skipping unreadable L0 segment ${entry.file} ` +
|
|
1448
1584
|
`(${error instanceof Error ? error.message : String(error)}) — its ` +
|
|
1449
1585
|
`entries remain in canonical storage; consolidation heals the set`);
|
|
@@ -1592,7 +1728,107 @@ export class NativeDiskAnnWrapper {
|
|
|
1592
1728
|
});
|
|
1593
1729
|
}
|
|
1594
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* ADR-004 §7 — verify the `main.familygen` stamp the native publish
|
|
1733
|
+
* writes (3.0.16+). The stamp commits the base + sidecar SET with each
|
|
1734
|
+
* member's exact byte size; a member missing or at the wrong size means
|
|
1735
|
+
* the family on disk is torn (interrupted swap or external mutation)
|
|
1736
|
+
* and must NOT be served. Stamp absence is a pre-stamp layout — legacy
|
|
1737
|
+
* per-file tolerance applies. A read/stat FAULT is `unverifiable`, never
|
|
1738
|
+
* conflated with absence (§4): we proceed on the per-file checks and
|
|
1739
|
+
* report it, but never trigger a spurious rebuild from a flaky probe.
|
|
1740
|
+
*/
|
|
1741
|
+
/**
|
|
1742
|
+
* Sibling path with the LAST extension swapped — the exact semantics of
|
|
1743
|
+
* Rust's `Path::set_extension`, which names every family member. This
|
|
1744
|
+
* MUST NOT assume the base ends in `.dkann`: production indexPaths come
|
|
1745
|
+
* from `getBinaryBlobPath` and FileSystemStorage appends `.bin`
|
|
1746
|
+
* (`main.dkann.bin`), so the sidecars/stamp are `main.dkann.slotmap` /
|
|
1747
|
+
* `main.dkann.familygen` there, while bare `main.dkann` paths (tests,
|
|
1748
|
+
* benchmarks) yield `main.slotmap` / `main.familygen`. A `.dkann`-only
|
|
1749
|
+
* regex silently no-ops on the production shape — the 3.0.16 gate
|
|
1750
|
+
* caught the stamp verifier READING THE BINARY BASE as its own stamp
|
|
1751
|
+
* (incoherent verdict on every healthy brain ⇒ rebuild loop).
|
|
1752
|
+
*/
|
|
1753
|
+
siblingPath(indexPath, ext) {
|
|
1754
|
+
return indexPath.replace(/\.[^./\\]+$/, `.${ext}`);
|
|
1755
|
+
}
|
|
1756
|
+
verifyFamilyStamp() {
|
|
1757
|
+
const indexPath = this.config.indexPath;
|
|
1758
|
+
if (!indexPath)
|
|
1759
|
+
return { state: 'absent' };
|
|
1760
|
+
const stampPath = this.siblingPath(indexPath, 'familygen');
|
|
1761
|
+
let raw;
|
|
1762
|
+
try {
|
|
1763
|
+
raw = readFileSync(stampPath, 'utf8');
|
|
1764
|
+
}
|
|
1765
|
+
catch (error) {
|
|
1766
|
+
const code = error.code;
|
|
1767
|
+
if (code === 'ENOENT')
|
|
1768
|
+
return { state: 'absent' };
|
|
1769
|
+
return {
|
|
1770
|
+
state: 'unverifiable',
|
|
1771
|
+
violations: [`stamp read FAULTED (${code}) — coherence unverifiable`],
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
let stamp;
|
|
1775
|
+
try {
|
|
1776
|
+
stamp = JSON.parse(raw);
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
return {
|
|
1780
|
+
state: 'incoherent',
|
|
1781
|
+
violations: [`stamp ${stampPath} present but unparseable — cannot prove family coherence`],
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
if (typeof stamp?.members !== 'object' || stamp.members === null) {
|
|
1785
|
+
return {
|
|
1786
|
+
state: 'incoherent',
|
|
1787
|
+
violations: [`stamp ${stampPath} has no members map — cannot prove family coherence`],
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
const dir = dirname(indexPath);
|
|
1791
|
+
const violations = [];
|
|
1792
|
+
const faults = [];
|
|
1793
|
+
for (const [name, expected] of Object.entries(stamp.members)) {
|
|
1794
|
+
try {
|
|
1795
|
+
const actual = statSync(join(dir, name)).size;
|
|
1796
|
+
if (actual !== expected) {
|
|
1797
|
+
violations.push(`${name}: size ${actual} != committed ${expected}`);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
catch (error) {
|
|
1801
|
+
const code = error.code;
|
|
1802
|
+
if (code === 'ENOENT') {
|
|
1803
|
+
violations.push(`${name}: missing (committed ${expected} bytes)`);
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
faults.push(`${name}: stat FAULTED (${code})`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
if (violations.length > 0)
|
|
1811
|
+
return { state: 'incoherent', violations };
|
|
1812
|
+
if (faults.length > 0)
|
|
1813
|
+
return { state: 'unverifiable', violations: faults };
|
|
1814
|
+
return { state: 'coherent', generation: stamp.generation ?? 0 };
|
|
1815
|
+
}
|
|
1595
1816
|
tryOpenExisting() {
|
|
1817
|
+
// §7: an incoherent family must not be served — refuse before the
|
|
1818
|
+
// open so a torn set reads as a FAILED durable load (not-ready ⇒
|
|
1819
|
+
// brainy's gate rebuilds), never as a quietly-wrong index.
|
|
1820
|
+
const family = this.verifyFamilyStamp();
|
|
1821
|
+
if (family.state === 'incoherent') {
|
|
1822
|
+
prodLog.error(`NativeDiskAnnWrapper: familygen stamp says the vector family is ` +
|
|
1823
|
+
`INCOHERENT — refusing to open. ${family.violations.join('; ')}`);
|
|
1824
|
+
this.native = null;
|
|
1825
|
+
this.durableBaseLoadFailed = true;
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (family.state === 'unverifiable') {
|
|
1829
|
+
prodLog.error(`NativeDiskAnnWrapper: family coherence UNVERIFIABLE (stat/read fault) — ` +
|
|
1830
|
+
`opening on per-file checks only. ${family.violations.join('; ')}`);
|
|
1831
|
+
}
|
|
1596
1832
|
try {
|
|
1597
1833
|
const bindings = loadNativeModule();
|
|
1598
1834
|
// napi-rs exports the class as `NativeDiskAnn` (PascalCase
|
|
@@ -1664,7 +1900,20 @@ export class NativeDiskAnnWrapper {
|
|
|
1664
1900
|
*/
|
|
1665
1901
|
removeLegacySlotsSidecar() {
|
|
1666
1902
|
try {
|
|
1667
|
-
|
|
1903
|
+
// THE DELETER (CORTEX-BLOB-LIFECYCLE-CONTRACT, identified 2026-07-13
|
|
1904
|
+
// by the §7 family-coherent gate): this derivation was a
|
|
1905
|
+
// `.dkann$`-anchored replace, which NO-OPS on production paths
|
|
1906
|
+
// (getBinaryBlobPath appends `.bin` → `main.dkann.bin`) — so
|
|
1907
|
+
// `legacy === indexPath` and every successful cold open DELETED THE
|
|
1908
|
+
// BASE INDEX it had just mmap-opened. The live inode kept serving,
|
|
1909
|
+
// the next open found no base, and the brain rebuilt — the exact
|
|
1910
|
+
// main.dkann-missing/sidecars-present signature on all four
|
|
1911
|
+
// memory-vm brains. Last-extension swap + a hard never-the-base
|
|
1912
|
+
// guard make the recurrence structurally impossible.
|
|
1913
|
+
const indexPath = this.config.indexPath;
|
|
1914
|
+
const legacy = this.siblingPath(indexPath, 'slots.json');
|
|
1915
|
+
if (legacy === indexPath)
|
|
1916
|
+
return;
|
|
1668
1917
|
if (existsSync(legacy))
|
|
1669
1918
|
rmSync(legacy, { force: true });
|
|
1670
1919
|
}
|
package/dist/native/types.d.ts
CHANGED
|
@@ -923,6 +923,13 @@ export interface NativeMetadataIndexInstance {
|
|
|
923
923
|
uuidToInt(uuid: string): number | null;
|
|
924
924
|
intToUuid(intId: number): string | null;
|
|
925
925
|
intsToUuids(ints: number[]): string[];
|
|
926
|
+
/**
|
|
927
|
+
* Resolve an entity int, ASSIGNING through the canonical allocator when
|
|
928
|
+
* unmapped (3.0.16+; the same allocator addToIndex uses, durable via the
|
|
929
|
+
* mapper's delta log). Lets a boot-heal vector rebuild mint ids for
|
|
930
|
+
* canonical-committed entities whose index ops were lost.
|
|
931
|
+
*/
|
|
932
|
+
getOrAssignInt(uuid: string): bigint;
|
|
926
933
|
loadFieldRegistry(json: string): void;
|
|
927
934
|
saveFieldRegistry(): string;
|
|
928
935
|
loadFieldIndex(field: string, json: string): void;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* `.cidx` segment value types and ordering. See ADR-001 for the native string
|
|
31
31
|
* collation contract (code-point order, matching `compareCodePoints`).
|
|
32
32
|
*/
|
|
33
|
+
import type { ProviderInvariantReport } from '@soulcraft/brainy';
|
|
33
34
|
import type { StorageAdapter } from '@soulcraft/brainy';
|
|
34
35
|
import type { ColumnStoreProvider, EntityIdMapperLike } from './columnStoreTypes.js';
|
|
35
36
|
import { ValueType } from './columnStoreTypes.js';
|
|
@@ -142,6 +143,11 @@ export declare class NativeColumnStore implements ColumnStoreProvider {
|
|
|
142
143
|
* would have been invisible.
|
|
143
144
|
* @param field - Field about to be served.
|
|
144
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* ADR-004 §6 — the column store's invariant self-report. NEVER throws;
|
|
148
|
+
* bounded (set sizes + per-field segment counts, no IO).
|
|
149
|
+
*/
|
|
150
|
+
validateInvariants(): Promise<ProviderInvariantReport>;
|
|
145
151
|
private assertFieldServable;
|
|
146
152
|
/**
|
|
147
153
|
* @description Point filter: entity int ids whose `field` equals `value`.
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* collation contract (code-point order, matching `compareCodePoints`).
|
|
32
32
|
*/
|
|
33
33
|
import { existsSync } from 'node:fs';
|
|
34
|
+
import { composeInvariantReport } from './invariantReport.js';
|
|
35
|
+
import { declareDerivedFamilies, withFamilyRetirement, COLUMN_FAMILIES } from './derivedFamilies.js';
|
|
34
36
|
import { prodLog } from '@soulcraft/brainy/internals';
|
|
35
37
|
import { ValueType } from './columnStoreTypes.js';
|
|
36
38
|
import { ColumnManifest } from './ColumnManifest.js';
|
|
@@ -145,6 +147,10 @@ export class NativeColumnStore {
|
|
|
145
147
|
'Use brainy FileSystemStorage or another adapter implementing BinaryBlobStorage.');
|
|
146
148
|
}
|
|
147
149
|
this.storage = storage;
|
|
150
|
+
// ADR-004 §7: declare the column family (feature-detected ≥8.3.0) —
|
|
151
|
+
// the one family whose members are written via saveBinaryBlob, so the
|
|
152
|
+
// storage layer's delete-refusal actively bites here.
|
|
153
|
+
await declareDerivedFamilies(storage, COLUMN_FAMILIES);
|
|
148
154
|
// Discover fields by listing manifest files under the column-index prefix.
|
|
149
155
|
let paths;
|
|
150
156
|
try {
|
|
@@ -241,6 +247,43 @@ export class NativeColumnStore {
|
|
|
241
247
|
* would have been invisible.
|
|
242
248
|
* @param field - Field about to be served.
|
|
243
249
|
*/
|
|
250
|
+
/**
|
|
251
|
+
* ADR-004 §6 — the column store's invariant self-report. NEVER throws;
|
|
252
|
+
* bounded (set sizes + per-field segment counts, no IO).
|
|
253
|
+
*/
|
|
254
|
+
async validateInvariants() {
|
|
255
|
+
return composeInvariantReport('column', () => this.storage !== null && this.faultedFields.size === 0, [
|
|
256
|
+
() => ({
|
|
257
|
+
name: 'fields-servable',
|
|
258
|
+
holds: this.faultedFields.size === 0,
|
|
259
|
+
detail: this.faultedFields.size === 0
|
|
260
|
+
? `${this.fieldTypes.size} field(s), none faulted`
|
|
261
|
+
: `${this.faultedFields.size} field(s) UNAVAILABLE after faulted ` +
|
|
262
|
+
`loads (queries throw): ${[...this.faultedFields].join(', ')}`,
|
|
263
|
+
expected: this.fieldTypes.size,
|
|
264
|
+
actual: this.fieldTypes.size - this.faultedFields.size,
|
|
265
|
+
heal: 'rebuild',
|
|
266
|
+
}),
|
|
267
|
+
() => {
|
|
268
|
+
// manifest-residency: every field's manifest-listed segments are
|
|
269
|
+
// attached in the native engine (hasField is the cheap proxy for
|
|
270
|
+
// a field whose segments loaded; per-segment ids are not exposed
|
|
271
|
+
// by the native surface, so the count-level check is the honest
|
|
272
|
+
// bounded version — a faulted segment already trips
|
|
273
|
+
// fields-servable above).
|
|
274
|
+
const unattached = [...this.manifests.keys()].filter((field) => !this.faultedFields.has(field) && !this.native.hasField(field));
|
|
275
|
+
return {
|
|
276
|
+
name: 'manifest-residency',
|
|
277
|
+
holds: unattached.length === 0,
|
|
278
|
+
detail: unattached.length === 0
|
|
279
|
+
? `${this.manifests.size} field manifest(s), all attached`
|
|
280
|
+
: `${unattached.length} field(s) have persisted manifests but no ` +
|
|
281
|
+
`attached native state: ${unattached.join(', ')}`,
|
|
282
|
+
heal: 'rebuild',
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
244
287
|
assertFieldServable(field) {
|
|
245
288
|
if (this.faultedFields.has(field)) {
|
|
246
289
|
throw new Error(`NativeColumnStore: column index for field '${field}' is UNAVAILABLE — ` +
|
|
@@ -464,18 +507,25 @@ export class NativeColumnStore {
|
|
|
464
507
|
// Delete persisted column-index files (manifests + segment/deleted blobs)
|
|
465
508
|
// before dropping the in-memory manifests we need to locate them.
|
|
466
509
|
if (this.storage) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
510
|
+
// Owner-sanctioned retirement (ADR-004 §7): this reset legitimately
|
|
511
|
+
// deletes the family's own members (a rebuild reassigns entity ints,
|
|
512
|
+
// invalidating old segments), so the protection window opens and
|
|
513
|
+
// closes around exactly this loop.
|
|
514
|
+
const storage = this.storage;
|
|
515
|
+
await withFamilyRetirement(storage, COLUMN_FAMILIES[0], async () => {
|
|
516
|
+
for (const [field, manifest] of this.manifests) {
|
|
517
|
+
try {
|
|
518
|
+
await storage.deleteObjectFromPath(manifest.manifestPath());
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* manifest already gone */
|
|
522
|
+
}
|
|
523
|
+
for (const seg of manifest.getAllSegments()) {
|
|
524
|
+
await storage.deleteBinaryBlob(segmentBlobKey(field, seg.level, seg.id));
|
|
525
|
+
}
|
|
526
|
+
await storage.deleteBinaryBlob(deletedBlobKey(field));
|
|
476
527
|
}
|
|
477
|
-
|
|
478
|
-
}
|
|
528
|
+
});
|
|
479
529
|
}
|
|
480
530
|
// Drop all native state by replacing the engine, then clear TS-side state.
|
|
481
531
|
// Faults clear too: the rebuild that follows repopulates every field
|