@soulcraft/cor 3.0.18 → 3.0.19
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 +34 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +202 -17
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +11 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +81 -10
- package/dist/native/types.d.ts +34 -0
- package/dist/utils/NativeMetadataIndex.js +108 -5
- 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 +66 -0
- package/package.json +2 -2
|
@@ -86,6 +86,14 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
|
|
|
86
86
|
*/
|
|
87
87
|
private canonicalUnindexed;
|
|
88
88
|
private isRebuilding;
|
|
89
|
+
/** Membership mutated since the last persisted blob (write-side dirty bit). */
|
|
90
|
+
private membershipDirty;
|
|
91
|
+
/** Cached stamp content (minus committedAt) — identical payload = no I/O on idle ticks. */
|
|
92
|
+
private lastMembershipStampBody;
|
|
93
|
+
/** Byte length of the last persisted membership blob (stamp needs it without re-serializing). */
|
|
94
|
+
private lastMembershipBlobBytes;
|
|
95
|
+
/** How this boot obtained membership: restored (stamp), healed (walk), or fresh (no verbs). */
|
|
96
|
+
private membershipBootState;
|
|
89
97
|
private flushTimer?;
|
|
90
98
|
private rebuildStartTime;
|
|
91
99
|
private totalRelationshipsIndexed;
|
|
@@ -174,6 +182,32 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
|
|
|
174
182
|
* queryable edges report "not ready".
|
|
175
183
|
*/
|
|
176
184
|
private openGraphLsmEngine;
|
|
185
|
+
/**
|
|
186
|
+
* SHA-256 over the tree's durable SSTable identity (sorted `id:level`
|
|
187
|
+
* lines). The stamp records one per tree; a mismatch at open means the
|
|
188
|
+
* trees advanced (or tore) after the last membership persist, so the
|
|
189
|
+
* blob cannot be trusted and the walk heals. Content-relevant fields
|
|
190
|
+
* only — deliberately NOT the raw manifest JSON (timestamps and field
|
|
191
|
+
* ordering must not fail coherence).
|
|
192
|
+
*/
|
|
193
|
+
private treeFingerprint;
|
|
194
|
+
/**
|
|
195
|
+
* Persist the membership family: blob (only when membership mutated or
|
|
196
|
+
* `force`) then stamp LAST (§7 — the stamp commits the set). Identical
|
|
197
|
+
* stamp payloads are skipped entirely, so idle auto-flush ticks cost
|
|
198
|
+
* zero I/O. Failures log loudly and leave the previous stamp in place —
|
|
199
|
+
* a stale stamp heals at next open; a silent half-persist would not.
|
|
200
|
+
*/
|
|
201
|
+
private persistVerbMembership;
|
|
202
|
+
/**
|
|
203
|
+
* Restore membership from the stamped blob. Returns `true` only when the
|
|
204
|
+
* FULL coherence chain holds: stamp present → blob present with the exact
|
|
205
|
+
* stamped byte length → every tree fingerprint matches the durable state
|
|
206
|
+
* just loaded → the native parser accepts every framed section. Any break
|
|
207
|
+
* returns `false` (absent stamp silently — that is every pre-3.0.19
|
|
208
|
+
* brain; a broken chain with a warn) and the caller's walk heals.
|
|
209
|
+
*/
|
|
210
|
+
private loadVerbMembershipFromStorage;
|
|
177
211
|
private populateVerbIdSetFromStorage;
|
|
178
212
|
getNeighbors(id: bigint, optionsOrDirection?: {
|
|
179
213
|
direction?: 'in' | 'out' | 'both';
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* since it coordinates cross-subsystem caching and requires async storage access.
|
|
14
14
|
*/
|
|
15
15
|
import { dirname } from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
|
|
17
18
|
import { loadNativeModule } from '../native/index.js';
|
|
18
19
|
import { composeInvariantReport } from '../utils/invariantReport.js';
|
|
@@ -45,6 +46,18 @@ const VERB_FALLBACK_INDEX = TypeUtils.getVerbIndex(VerbType.RelatedTo) ?? 3;
|
|
|
45
46
|
* (`verb-uuid-to-int.mkv`, `verb-int-to-uuid.bin`, `verb-endpoints/`).
|
|
46
47
|
*/
|
|
47
48
|
const GRAPH_VERB_NS_PROBE_KEY = '_graph_verb_ns/x';
|
|
49
|
+
/**
|
|
50
|
+
* Persisted verb-membership family (3.0.19). The roster / per-type unions /
|
|
51
|
+
* per-(type, subtype) buckets / count families were RAM-only, costing every
|
|
52
|
+
* boot of every verb-bearing brain an O(all-canonical-verbs) walk and
|
|
53
|
+
* silently emptying subtype-filtered traversal after restarts. The blob is
|
|
54
|
+
* the serialized structures; the stamp ties the blob to the exact durable
|
|
55
|
+
* tree state (per-tree SSTable fingerprints) so a torn write is DETECTED at
|
|
56
|
+
* open and heals via the walk — §7 coherent-or-rebuild, same discipline as
|
|
57
|
+
* the vector family.
|
|
58
|
+
*/
|
|
59
|
+
const GRAPH_MEMBERSHIP_BLOB_KEY = 'graph-verb-membership';
|
|
60
|
+
const GRAPH_MEMBERSHIP_STAMP_KEY = 'graph-membership-stamp';
|
|
48
61
|
/**
|
|
49
62
|
* Resolve a verb-type string to its canonical `VerbTypeEnum` index,
|
|
50
63
|
* falling back to {@link VERB_FALLBACK_INDEX} (`VerbType.RelatedTo`)
|
|
@@ -91,6 +104,14 @@ export class GraphAdjacencyIndex {
|
|
|
91
104
|
*/
|
|
92
105
|
canonicalUnindexed = false;
|
|
93
106
|
isRebuilding = false;
|
|
107
|
+
/** Membership mutated since the last persisted blob (write-side dirty bit). */
|
|
108
|
+
membershipDirty = false;
|
|
109
|
+
/** Cached stamp content (minus committedAt) — identical payload = no I/O on idle ticks. */
|
|
110
|
+
lastMembershipStampBody = '';
|
|
111
|
+
/** Byte length of the last persisted membership blob (stamp needs it without re-serializing). */
|
|
112
|
+
lastMembershipBlobBytes = -1;
|
|
113
|
+
/** How this boot obtained membership: restored (stamp), healed (walk), or fresh (no verbs). */
|
|
114
|
+
membershipBootState = 'fresh';
|
|
94
115
|
flushTimer;
|
|
95
116
|
rebuildStartTime = 0;
|
|
96
117
|
totalRelationshipsIndexed = 0;
|
|
@@ -293,13 +314,126 @@ export class GraphAdjacencyIndex {
|
|
|
293
314
|
for (const treeName of treeNames) {
|
|
294
315
|
await this.loadTreeFromStorage(treeName);
|
|
295
316
|
}
|
|
317
|
+
// Persisted-membership restore (3.0.19): coherent stamp + blob loads the
|
|
318
|
+
// roster/buckets/counts in O(bitmap) — no canonical walk, subtype filters
|
|
319
|
+
// intact. `restored` also covers the legitimately-empty-roster case (all
|
|
320
|
+
// verbs removed) so the walk below can't re-fire on it every boot.
|
|
321
|
+
const restored = await this.loadVerbMembershipFromStorage();
|
|
296
322
|
// Defensive check: if LSM-trees have data but verbIdSet is empty, populate.
|
|
297
|
-
// This is the
|
|
323
|
+
// This is the membership HEAL path (pre-3.0.19 brains, torn/missing
|
|
324
|
+
// stamp) — it does NOT gate isReady().
|
|
298
325
|
const sourceSize = this.native.size();
|
|
299
|
-
if (sourceSize > 0 && this.native.verbIdCount() === 0) {
|
|
326
|
+
if (!restored && sourceSize > 0 && this.native.verbIdCount() === 0) {
|
|
300
327
|
prodLog.warn(`GraphAdjacencyIndex: LSM-trees have ${sourceSize} relationships but verbIdSet is empty. ` +
|
|
301
328
|
`Triggering auto-rebuild to restore consistency.`);
|
|
302
329
|
await this.populateVerbIdSetFromStorage();
|
|
330
|
+
this.membershipBootState = 'healed';
|
|
331
|
+
// Persist immediately so the NEXT boot restores instead of re-walking.
|
|
332
|
+
await this.persistVerbMembership(true);
|
|
333
|
+
}
|
|
334
|
+
else if (!restored && this.native.verbIdCount() > 0) {
|
|
335
|
+
// Verbs arrived without a stamp (e.g. mid-process rebuild) — treat as
|
|
336
|
+
// healed so the invariant reports honestly.
|
|
337
|
+
this.membershipBootState = 'healed';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* SHA-256 over the tree's durable SSTable identity (sorted `id:level`
|
|
342
|
+
* lines). The stamp records one per tree; a mismatch at open means the
|
|
343
|
+
* trees advanced (or tore) after the last membership persist, so the
|
|
344
|
+
* blob cannot be trusted and the walk heals. Content-relevant fields
|
|
345
|
+
* only — deliberately NOT the raw manifest JSON (timestamps and field
|
|
346
|
+
* ordering must not fail coherence).
|
|
347
|
+
*/
|
|
348
|
+
treeFingerprint(treeName) {
|
|
349
|
+
const ids = this.native.getManifestSstableIds(treeName);
|
|
350
|
+
const lines = ids
|
|
351
|
+
.map((id) => `${id}:${this.native.getManifestSstableLevel(treeName, id) ?? '?'}`)
|
|
352
|
+
.sort();
|
|
353
|
+
return createHash('sha256').update(lines.join('\n')).digest('hex');
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Persist the membership family: blob (only when membership mutated or
|
|
357
|
+
* `force`) then stamp LAST (§7 — the stamp commits the set). Identical
|
|
358
|
+
* stamp payloads are skipped entirely, so idle auto-flush ticks cost
|
|
359
|
+
* zero I/O. Failures log loudly and leave the previous stamp in place —
|
|
360
|
+
* a stale stamp heals at next open; a silent half-persist would not.
|
|
361
|
+
*/
|
|
362
|
+
async persistVerbMembership(force = false) {
|
|
363
|
+
if (!this.hasBinaryBlobs)
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
if (this.membershipDirty || force || this.lastMembershipBlobBytes < 0) {
|
|
367
|
+
const blob = this.native.serializeVerbMembership();
|
|
368
|
+
await this.storage.saveBinaryBlob(GRAPH_MEMBERSHIP_BLOB_KEY, Buffer.from(blob));
|
|
369
|
+
this.lastMembershipBlobBytes = blob.length;
|
|
370
|
+
this.membershipDirty = false;
|
|
371
|
+
}
|
|
372
|
+
const trees = {};
|
|
373
|
+
for (const treeName of this.native.treeNames()) {
|
|
374
|
+
trees[treeName] = this.treeFingerprint(treeName);
|
|
375
|
+
}
|
|
376
|
+
const body = JSON.stringify({ v: 1, blobBytes: this.lastMembershipBlobBytes, trees });
|
|
377
|
+
if (body === this.lastMembershipStampBody)
|
|
378
|
+
return;
|
|
379
|
+
const stamp = {
|
|
380
|
+
v: 1,
|
|
381
|
+
committedAt: Date.now(),
|
|
382
|
+
blobBytes: this.lastMembershipBlobBytes,
|
|
383
|
+
trees,
|
|
384
|
+
};
|
|
385
|
+
await this.storage.saveMetadata(GRAPH_MEMBERSHIP_STAMP_KEY, {
|
|
386
|
+
noun: 'thing',
|
|
387
|
+
data: stamp,
|
|
388
|
+
});
|
|
389
|
+
this.lastMembershipStampBody = body;
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
prodLog.error('GraphAdjacencyIndex: failed to persist verb membership — next open will heal via the canonical walk', error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Restore membership from the stamped blob. Returns `true` only when the
|
|
397
|
+
* FULL coherence chain holds: stamp present → blob present with the exact
|
|
398
|
+
* stamped byte length → every tree fingerprint matches the durable state
|
|
399
|
+
* just loaded → the native parser accepts every framed section. Any break
|
|
400
|
+
* returns `false` (absent stamp silently — that is every pre-3.0.19
|
|
401
|
+
* brain; a broken chain with a warn) and the caller's walk heals.
|
|
402
|
+
*/
|
|
403
|
+
async loadVerbMembershipFromStorage() {
|
|
404
|
+
if (!this.hasBinaryBlobs)
|
|
405
|
+
return false;
|
|
406
|
+
try {
|
|
407
|
+
const meta = await this.storage.getMetadata(GRAPH_MEMBERSHIP_STAMP_KEY);
|
|
408
|
+
const stamp = meta?.data;
|
|
409
|
+
if (!stamp || stamp.v !== 1)
|
|
410
|
+
return false;
|
|
411
|
+
const blob = await this.storage.loadBinaryBlob(GRAPH_MEMBERSHIP_BLOB_KEY);
|
|
412
|
+
if (!blob || blob.length !== stamp.blobBytes) {
|
|
413
|
+
prodLog.warn(`GraphAdjacencyIndex: membership blob ${blob ? `is ${blob.length} bytes but the stamp committed ${stamp.blobBytes}` : 'is MISSING under a present stamp'} — healing via the canonical walk`);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
for (const treeName of this.native.treeNames()) {
|
|
417
|
+
const now = this.treeFingerprint(treeName);
|
|
418
|
+
if (stamp.trees?.[treeName] !== now) {
|
|
419
|
+
prodLog.warn(`GraphAdjacencyIndex: membership stamp is stale for tree '${treeName}' (durable SSTables changed after the last persist) — healing via the canonical walk`);
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
this.native.loadVerbMembership(blob);
|
|
424
|
+
this.lastMembershipBlobBytes = blob.length;
|
|
425
|
+
this.lastMembershipStampBody = JSON.stringify({
|
|
426
|
+
v: 1,
|
|
427
|
+
blobBytes: stamp.blobBytes,
|
|
428
|
+
trees: stamp.trees,
|
|
429
|
+
});
|
|
430
|
+
this.membershipBootState = 'restored';
|
|
431
|
+
prodLog.info(`GraphAdjacencyIndex: verb membership restored from stamp (${this.native.verbIdCount()} live verbs, no canonical walk)`);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
prodLog.warn('GraphAdjacencyIndex: membership restore failed — healing via the canonical walk', error);
|
|
436
|
+
return false;
|
|
303
437
|
}
|
|
304
438
|
}
|
|
305
439
|
async populateVerbIdSetFromStorage() {
|
|
@@ -313,12 +447,16 @@ export class GraphAdjacencyIndex {
|
|
|
313
447
|
pagination: { limit: 10000, cursor }
|
|
314
448
|
});
|
|
315
449
|
for (const verb of result.items) {
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
450
|
+
// Full-fidelity tracking (3.0.19): the membership-only walk used
|
|
451
|
+
// to drop subtype state, silently emptying findConnectedSubtype
|
|
452
|
+
// after every restart. Canonical carries the subtype (brainy
|
|
453
|
+
// 7.30.0+) — a METADATA field on verbs, surfaced top-level on
|
|
454
|
+
// hydrated items; read both shapes. Unknown type strings fall
|
|
455
|
+
// back to RelatedTo — acceptable on the rebuild slow path
|
|
456
|
+
// because the membership bitmap converges either way.
|
|
457
|
+
const v = verb;
|
|
458
|
+
const subtype = v.subtype ?? v.metadata?.subtype ?? '';
|
|
459
|
+
this.native.trackVerbIdFull(verb.id, resolveVerbTypeIndex(verb.verb), subtype);
|
|
322
460
|
count++;
|
|
323
461
|
}
|
|
324
462
|
hasMore = result.hasMore;
|
|
@@ -437,6 +575,7 @@ export class GraphAdjacencyIndex {
|
|
|
437
575
|
// coalesce absent subtype to '' here.
|
|
438
576
|
const subtype = verb.subtype ?? '';
|
|
439
577
|
const result = this.native.addVerbWithEndpointsByIndex(verb.id, sourceInt, targetInt, verbTypeIndex, subtype, generation);
|
|
578
|
+
this.membershipDirty = true;
|
|
440
579
|
// Flush any trees that hit their threshold
|
|
441
580
|
const flushPromises = [];
|
|
442
581
|
if (result.needsFlushSource) {
|
|
@@ -465,8 +604,19 @@ export class GraphAdjacencyIndex {
|
|
|
465
604
|
await this.ensureInitialized();
|
|
466
605
|
// Load verb to get type + subtype info
|
|
467
606
|
const verb = await this.getVerbCached(verbId);
|
|
468
|
-
if (!verb)
|
|
607
|
+
if (!verb) {
|
|
608
|
+
// REMOVAL LAW (3.0.19): removal must never require reading the thing
|
|
609
|
+
// being removed. A ghost verb (canonical row unreadable) used to be
|
|
610
|
+
// silently SKIPPED here — its graph rows survived forever, the exact
|
|
611
|
+
// shape of brainy's replace-path defect one layer down. The id-only
|
|
612
|
+
// native path resolves type/subtype from cor-owned state instead.
|
|
613
|
+
const removed = this.native.removeVerbByIdOnly(verbId, generation);
|
|
614
|
+
if (removed) {
|
|
615
|
+
this.membershipDirty = true;
|
|
616
|
+
prodLog.warn(`GraphAdjacencyIndex: removed ghost verb ${verbId} via the id-only path (canonical row unreadable — removal law)`);
|
|
617
|
+
}
|
|
469
618
|
return;
|
|
619
|
+
}
|
|
470
620
|
const startTime = performance.now();
|
|
471
621
|
const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
|
|
472
622
|
// Native subtype param is `String` with '' = "no subtype" (the empty-string
|
|
@@ -475,6 +625,7 @@ export class GraphAdjacencyIndex {
|
|
|
475
625
|
// coalesce absent subtype to '' here.
|
|
476
626
|
const subtype = verb.subtype ?? '';
|
|
477
627
|
this.native.removeVerb(verbId, verbTypeIndex, subtype, generation);
|
|
628
|
+
this.membershipDirty = true;
|
|
478
629
|
const elapsed = performance.now() - startTime;
|
|
479
630
|
if (elapsed > 5.0) {
|
|
480
631
|
prodLog.warn(`GraphAdjacencyIndex: Slow removeVerb for ${verbId}: ${elapsed.toFixed(2)}ms`);
|
|
@@ -600,6 +751,19 @@ export class GraphAdjacencyIndex {
|
|
|
600
751
|
: 'no canonical-vs-durable coverage gap detected',
|
|
601
752
|
heal: 'rebuild',
|
|
602
753
|
}),
|
|
754
|
+
() => ({
|
|
755
|
+
name: 'membership-stamp',
|
|
756
|
+
// Every boot path lands in an honest state: restored (stamp
|
|
757
|
+
// coherent), healed (walk ran — first boot on 3.0.19, or a torn
|
|
758
|
+
// stamp), or fresh (no verbs anywhere). A verb-bearing graph
|
|
759
|
+
// whose membership is EMPTY while trees hold edges is the only
|
|
760
|
+
// failing shape — subtype filters would silently serve nothing.
|
|
761
|
+
holds: !(this.native.size() > 0 && this.native.verbIdCount() === 0),
|
|
762
|
+
detail: this.native.size() > 0 && this.native.verbIdCount() === 0
|
|
763
|
+
? 'trees hold edges but the live-verb roster is EMPTY — membership restore AND heal both failed'
|
|
764
|
+
: `membership ${this.membershipBootState} this boot (${this.native.verbIdCount()} live verbs)`,
|
|
765
|
+
heal: 'rebuild',
|
|
766
|
+
}),
|
|
603
767
|
]);
|
|
604
768
|
}
|
|
605
769
|
isReady() {
|
|
@@ -719,8 +883,18 @@ export class GraphAdjacencyIndex {
|
|
|
719
883
|
for (const verb of result.items) {
|
|
720
884
|
totalVerbs++;
|
|
721
885
|
const verbType = verb.verb ?? 'relatedTo';
|
|
886
|
+
const verbTypeIndex = resolveVerbTypeIndex(verbType);
|
|
887
|
+
// Native subtype param is `String` with '' = "no subtype" (the
|
|
888
|
+
// empty-string escape; graph_adjacency.rs guards on
|
|
889
|
+
// `!subtype.is_empty()`). JS null throws "Null into String" at the
|
|
890
|
+
// napi boundary, so coalesce absent subtype to ''. Subtype is a
|
|
891
|
+
// metadata field on verbs — read both hydration shapes.
|
|
892
|
+
const vv = verb;
|
|
893
|
+
const subtype = vv.subtype ?? vv.metadata?.subtype ?? '';
|
|
722
894
|
if (!reconstructAdjacency) {
|
|
723
|
-
|
|
895
|
+
// Full-fidelity membership (3.0.19): subtype rides the walk so
|
|
896
|
+
// filtered traversal survives the rebuild.
|
|
897
|
+
this.native.trackVerbIdFull(verb.id, verbTypeIndex, subtype);
|
|
724
898
|
continue;
|
|
725
899
|
}
|
|
726
900
|
const sourceId = verb.sourceId;
|
|
@@ -730,16 +904,10 @@ export class GraphAdjacencyIndex {
|
|
|
730
904
|
if (sourceInt === undefined || targetInt === undefined) {
|
|
731
905
|
// Endpoint not in the mapper — still record membership so the verb
|
|
732
906
|
// isn't lost; its adjacency can't be rebuilt without the int.
|
|
733
|
-
this.native.
|
|
907
|
+
this.native.trackVerbIdFull(verb.id, verbTypeIndex, subtype);
|
|
734
908
|
unresolvedEndpoints++;
|
|
735
909
|
continue;
|
|
736
910
|
}
|
|
737
|
-
const verbTypeIndex = resolveVerbTypeIndex(verbType);
|
|
738
|
-
// Native subtype param is `String` with '' = "no subtype" (the
|
|
739
|
-
// empty-string escape; graph_adjacency.rs guards on
|
|
740
|
-
// `!subtype.is_empty()`). JS null throws "Null into String" at the
|
|
741
|
-
// napi boundary, so coalesce absent subtype to ''.
|
|
742
|
-
const subtype = verb.subtype ?? '';
|
|
743
911
|
const addResult = this.native.addVerbWithEndpointsByIndex(verb.id, BigInt(sourceInt), BigInt(targetInt), verbTypeIndex, subtype, REBUILD_GENERATION);
|
|
744
912
|
edgesRebuilt++;
|
|
745
913
|
// Respect memtable flush thresholds mid-walk so RAM stays bounded.
|
|
@@ -759,6 +927,15 @@ export class GraphAdjacencyIndex {
|
|
|
759
927
|
cursor = result.nextCursor;
|
|
760
928
|
}
|
|
761
929
|
this.totalRelationshipsIndexed = totalVerbs;
|
|
930
|
+
// The walk rebuilt membership through the native handle directly (the
|
|
931
|
+
// wrapper's dirty bit never fired) — mark it so the persist below and
|
|
932
|
+
// any later flush write the fresh blob.
|
|
933
|
+
this.membershipDirty = true;
|
|
934
|
+
if (!reconstructAdjacency) {
|
|
935
|
+
// Membership-only rebuild writes no memtables, so no flush runs —
|
|
936
|
+
// persist the family directly or the walk repeats next boot.
|
|
937
|
+
await this.persistVerbMembership(true);
|
|
938
|
+
}
|
|
762
939
|
if (reconstructAdjacency) {
|
|
763
940
|
// Persist partially-filled memtables from the final batch.
|
|
764
941
|
await this.flush();
|
|
@@ -800,6 +977,9 @@ export class GraphAdjacencyIndex {
|
|
|
800
977
|
if (flushPromises.length > 0) {
|
|
801
978
|
await Promise.all(flushPromises);
|
|
802
979
|
}
|
|
980
|
+
// Membership family rides every flush cycle: blob only when membership
|
|
981
|
+
// mutated, stamp only when the payload changed — idle ticks cost nothing.
|
|
982
|
+
await this.persistVerbMembership();
|
|
803
983
|
const elapsed = Date.now() - startTime;
|
|
804
984
|
prodLog.debug(`GraphAdjacencyIndex: Flush completed in ${elapsed}ms`);
|
|
805
985
|
}
|
|
@@ -1031,6 +1211,11 @@ export class GraphAdjacencyIndex {
|
|
|
1031
1211
|
});
|
|
1032
1212
|
}));
|
|
1033
1213
|
});
|
|
1214
|
+
// Compaction changed the durable SSTable identity, so the
|
|
1215
|
+
// membership stamp's fingerprint for this tree is now stale —
|
|
1216
|
+
// refresh it (stamp-only unless membership also mutated). Without
|
|
1217
|
+
// this, the next open would needlessly heal via the walk.
|
|
1218
|
+
await this.persistVerbMembership();
|
|
1034
1219
|
const elapsed = Date.now() - startTime;
|
|
1035
1220
|
prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
|
|
1036
1221
|
if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
|
|
@@ -458,6 +458,17 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
|
|
|
458
458
|
getPersistMode(): 'immediate' | 'deferred';
|
|
459
459
|
/** Absolute path of a segment file (lives next to the base indexPath). */
|
|
460
460
|
private segPath;
|
|
461
|
+
/**
|
|
462
|
+
* Move poisoned L0 segments out of the live set into
|
|
463
|
+
* `<indexDir>/quarantine/` — preserved for forensics, NEVER deleted
|
|
464
|
+
* (accord's 545 poisoned segments were the evidence that convicted the
|
|
465
|
+
* June dim bug). Crash-safe order: the manifest stops referencing the
|
|
466
|
+
* segments FIRST (a crash after that leaves orphaned, de-referenced
|
|
467
|
+
* files — a harmless leak), then the files move. The dropped native
|
|
468
|
+
* handles close with their references; nothing reads a de-referenced
|
|
469
|
+
* segment.
|
|
470
|
+
*/
|
|
471
|
+
private quarantineSegments;
|
|
461
472
|
/** Membership across the base + every L0 segment (int → any slot). */
|
|
462
473
|
private hasIntAnywhere;
|
|
463
474
|
/**
|
|
@@ -75,8 +75,8 @@ import { declareDerivedFamilies, VECTOR_FAMILIES } from '../utils/derivedFamilie
|
|
|
75
75
|
import { statSync, readFileSync } from 'node:fs';
|
|
76
76
|
import { loadNativeModule } from '../native/index.js';
|
|
77
77
|
import { prodLog } from '@soulcraft/brainy/internals';
|
|
78
|
-
import { mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
79
|
-
import { dirname, join } from 'node:path';
|
|
78
|
+
import { mkdirSync, existsSync, rmSync, renameSync } from 'node:fs';
|
|
79
|
+
import { dirname, join, basename } from 'node:path';
|
|
80
80
|
import { autoModeForHeader, selectModeFromResourceManager, } from './AdaptiveDiskAnnModeSelector.js';
|
|
81
81
|
/**
|
|
82
82
|
* Node-count threshold above which `useMmapAdjacency: 'auto'`
|
|
@@ -1174,13 +1174,50 @@ export class NativeDiskAnnWrapper {
|
|
|
1174
1174
|
// into the new base. Their entries are read back through the existing
|
|
1175
1175
|
// readVectorChunk/slotIdsForSlots surface (each segment is bounded-small
|
|
1176
1176
|
// by construction, ≤ the delta budget, so this stays bounded RAM).
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1177
|
+
//
|
|
1178
|
+
// SEGMENT QUARANTINE (3.0.19, accord 2026-07-14): a segment whose
|
|
1179
|
+
// read-back throws (June-class dim poisoning — "Range … out of bounds")
|
|
1180
|
+
// used to abort the WHOLE fold, forever: 545 live segments accumulated,
|
|
1181
|
+
// the write brake engaged, and the board refused writes for 20 hours.
|
|
1182
|
+
// A poisoned segment is now dropped from the fold LOUDLY and its files
|
|
1183
|
+
// moved to <indexDir>/quarantine/ (preserved for forensics, never
|
|
1184
|
+
// deleted); the fold completes with the healthy segments and the same
|
|
1185
|
+
// pass restores the lost entries from canonical (hydrate below), so the
|
|
1186
|
+
// rebuilt base is COMPLETE — serving-truth holds without a second heal.
|
|
1187
|
+
const builtL0 = [];
|
|
1188
|
+
const l0Data = [];
|
|
1189
|
+
const poisonedL0 = [];
|
|
1190
|
+
for (const seg of this.l0Segments) {
|
|
1191
|
+
try {
|
|
1192
|
+
const ints = (seg.nodeCount > 0
|
|
1193
|
+
? seg.native.slotIdsForSlots(Array.from({ length: seg.nodeCount }, (_, i) => i))
|
|
1194
|
+
: []);
|
|
1195
|
+
const vecs = seg.native.readVectorChunk(0, seg.nodeCount);
|
|
1196
|
+
builtL0.push(seg);
|
|
1197
|
+
l0Data.push({ ints, vecs });
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
poisonedL0.push(seg);
|
|
1201
|
+
prodLog.error(`NativeDiskAnnWrapper: L0 segment ${seg.file} is UNREADABLE ` +
|
|
1202
|
+
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
1203
|
+
`QUARANTINED (${seg.nodeCount} entries; restored from canonical in this fold)`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (poisonedL0.length > 0) {
|
|
1207
|
+
await this.quarantineSegments(poisonedL0);
|
|
1208
|
+
if (typeof this.storage?.getNouns === 'function') {
|
|
1209
|
+
// Quarantine fold: the poisoned segments' entries exist ONLY in
|
|
1210
|
+
// canonical now — hydrate the delta BEFORE the dedup sets below are
|
|
1211
|
+
// computed, so newest-wins exclusion sees the full canonical set
|
|
1212
|
+
// (base slots excluded for delta ids, healthy-L0 rows deduped
|
|
1213
|
+
// against the delta — no duplicate ints, complete output).
|
|
1214
|
+
// O(canonical) reads, paid only on an actual corruption event —
|
|
1215
|
+
// the alternative (a base silently missing entities) violates
|
|
1216
|
+
// serving-truth. A scoped missing-ids-only backfill rides the M3
|
|
1217
|
+
// fact-log scan path.
|
|
1218
|
+
await this.hydrateDeltaFromStorage(this.config.dimensions);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1184
1221
|
// Tombstone ints (used to exclude entries from BOTH the base and the L0
|
|
1185
1222
|
// read-back). Ids never indexed have no int — nothing to exclude.
|
|
1186
1223
|
const tombIntKeys = new Set();
|
|
@@ -1256,11 +1293,14 @@ export class NativeDiskAnnWrapper {
|
|
|
1256
1293
|
let dim = this.config.dimensions ??
|
|
1257
1294
|
(this.native ? this.native.header().dim : undefined) ??
|
|
1258
1295
|
builtL0.find((s) => s.nodeCount > 0)?.native.header().dim;
|
|
1259
|
-
if (liveOldSlots.length === 0 &&
|
|
1296
|
+
if (liveOldSlots.length === 0 &&
|
|
1297
|
+
poisonedL0.length === 0 &&
|
|
1298
|
+
typeof this.storage?.getNouns === 'function') {
|
|
1260
1299
|
const probe = await this.storage.getNouns({ pagination: { limit: 1 } });
|
|
1261
1300
|
if (probe.totalCount === undefined || probe.totalCount > this.delta.size) {
|
|
1262
1301
|
// Cold/restore rebuild: hydrate also adopts the dim from canonical data
|
|
1263
1302
|
// when we still don't have it, and returns the width it used.
|
|
1303
|
+
// (A quarantine fold already hydrated ABOVE, before the dedup sets.)
|
|
1264
1304
|
dim = await this.hydrateDeltaFromStorage(dim);
|
|
1265
1305
|
}
|
|
1266
1306
|
}
|
|
@@ -1461,6 +1501,37 @@ export class NativeDiskAnnWrapper {
|
|
|
1461
1501
|
segPath(file) {
|
|
1462
1502
|
return `${dirname(this.config.indexPath)}/${file}`;
|
|
1463
1503
|
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Move poisoned L0 segments out of the live set into
|
|
1506
|
+
* `<indexDir>/quarantine/` — preserved for forensics, NEVER deleted
|
|
1507
|
+
* (accord's 545 poisoned segments were the evidence that convicted the
|
|
1508
|
+
* June dim bug). Crash-safe order: the manifest stops referencing the
|
|
1509
|
+
* segments FIRST (a crash after that leaves orphaned, de-referenced
|
|
1510
|
+
* files — a harmless leak), then the files move. The dropped native
|
|
1511
|
+
* handles close with their references; nothing reads a de-referenced
|
|
1512
|
+
* segment.
|
|
1513
|
+
*/
|
|
1514
|
+
async quarantineSegments(poisoned) {
|
|
1515
|
+
const drop = new Set(poisoned.map((s) => s.id));
|
|
1516
|
+
this.l0Segments = this.l0Segments.filter((s) => !drop.has(s.id));
|
|
1517
|
+
await this.saveSegmentManifest();
|
|
1518
|
+
const qDir = join(dirname(this.config.indexPath), 'quarantine');
|
|
1519
|
+
for (const seg of poisoned) {
|
|
1520
|
+
try {
|
|
1521
|
+
mkdirSync(qDir, { recursive: true });
|
|
1522
|
+
const src = this.segPath(seg.file);
|
|
1523
|
+
const family = [src, this.siblingPath(src, 'slotmap'), this.siblingPath(src, 'slotrev')];
|
|
1524
|
+
for (const p of family) {
|
|
1525
|
+
if (existsSync(p))
|
|
1526
|
+
renameSync(p, join(qDir, basename(p)));
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
prodLog.error(`NativeDiskAnnWrapper: quarantine move failed for ${seg.file} — ` +
|
|
1531
|
+
`segment stays de-referenced on disk (manifest no longer lists it)`, error);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1464
1535
|
/** Membership across the base + every L0 segment (int → any slot). */
|
|
1465
1536
|
hasIntAnywhere(int) {
|
|
1466
1537
|
if (this.native && this.native.hasInt(int))
|
package/dist/native/types.d.ts
CHANGED
|
@@ -751,6 +751,31 @@ export interface NativeGraphAdjacencyInstance {
|
|
|
751
751
|
* when LSM-trees are already loaded from persisted SSTables.
|
|
752
752
|
*/
|
|
753
753
|
trackVerbId(verbId: string, verbType: string): void;
|
|
754
|
+
/**
|
|
755
|
+
* Full-fidelity membership tracking for the boot heal walk (3.0.19):
|
|
756
|
+
* roster + per-type union + counts + per-(type, subtype) bucket. The
|
|
757
|
+
* membership-only walk dropped subtype state, silently emptying
|
|
758
|
+
* `findConnectedSubtype` after every restart.
|
|
759
|
+
*/
|
|
760
|
+
trackVerbIdFull(verbId: string, verbTypeIndex: number, subtype: string): void;
|
|
761
|
+
/**
|
|
762
|
+
* Serialize every RAM-resident membership structure (roster, per-type
|
|
763
|
+
* unions, per-(type, subtype) buckets, both count families) to one
|
|
764
|
+
* strictly-framed buffer for stamped persistence (3.0.19).
|
|
765
|
+
*/
|
|
766
|
+
serializeVerbMembership(): Buffer;
|
|
767
|
+
/**
|
|
768
|
+
* Restore membership from a `serializeVerbMembership` buffer. Throws on
|
|
769
|
+
* ANY malformation (magic/version/id-space/truncation/trailing bytes) —
|
|
770
|
+
* the caller falls back to the canonical heal walk. Idempotent overwrite.
|
|
771
|
+
*/
|
|
772
|
+
loadVerbMembership(buf: Buffer): void;
|
|
773
|
+
/**
|
|
774
|
+
* Removal-law removal (3.0.19): removes a verb resolving type/subtype
|
|
775
|
+
* from cor-owned state (endpoint store / membership buckets) — never
|
|
776
|
+
* reads canonical. Returns false when unknown/already dead (idempotent).
|
|
777
|
+
*/
|
|
778
|
+
removeVerbByIdOnly(verbId: string, generation: bigint): boolean;
|
|
754
779
|
/** Clear membership bitmap + relationship counts (rebuild prelude). */
|
|
755
780
|
clearVerbTracking(): void;
|
|
756
781
|
/**
|
|
@@ -976,6 +1001,15 @@ export interface NativeMetadataIndexInstance {
|
|
|
976
1001
|
* Never throws; empty index → `true`. Backs the wrapper's `probeConsistency`.
|
|
977
1002
|
*/
|
|
978
1003
|
probeConsistency(): boolean;
|
|
1004
|
+
/**
|
|
1005
|
+
* Suspect-surfacing probe (3.0.19): same bounded walk, but returns the
|
|
1006
|
+
* first suspicious `{field, uuid}` as JSON (null = clean) so the wrapper
|
|
1007
|
+
* can adjudicate it with one canonical read instead of guessing from the
|
|
1008
|
+
* sample MIX.
|
|
1009
|
+
*/
|
|
1010
|
+
probeConsistencySuspect(): string | null;
|
|
1011
|
+
/** Mark a field learned-multi-valued after canonical adjudication (3.0.19). */
|
|
1012
|
+
markFieldMultiValue(field: string): void;
|
|
979
1013
|
/**
|
|
980
1014
|
* Scan for the cross-bucket phantom signature (a query-visible int in >1
|
|
981
1015
|
* value bucket of a field) and return up to `maxCandidates` (0 = unlimited)
|
|
@@ -1371,24 +1371,33 @@ export class MetadataIndexManager {
|
|
|
1371
1371
|
async verifiedUnpostableAllowance() {
|
|
1372
1372
|
try {
|
|
1373
1373
|
const stamp = (await this.storage.getMetadata(UNPOSTABLE_STAMP_KEY));
|
|
1374
|
-
if (!stamp
|
|
1374
|
+
if (!stamp)
|
|
1375
1375
|
return 0;
|
|
1376
|
+
// Completion-gap portion (3.0.19): canonical rows a COMPLETED walk
|
|
1377
|
+
// could not even enumerate (no ids exist to re-verify) — count-only
|
|
1378
|
+
// by nature, refreshed on every completed rebuild. The detector's
|
|
1379
|
+
// "FULLY explained by" line reports it whenever it carries the floor.
|
|
1380
|
+
const gap = typeof stamp.completionGap === 'number' && stamp.completionGap > 0
|
|
1381
|
+
? stamp.completionGap
|
|
1382
|
+
: 0;
|
|
1383
|
+
if (typeof stamp.nounCount !== 'number' || stamp.nounCount <= 0)
|
|
1384
|
+
return gap;
|
|
1376
1385
|
if (Array.isArray(stamp.nounIds) && stamp.nounIds.length === stamp.nounCount) {
|
|
1377
1386
|
const getMeta = this.storage.getNounMetadata;
|
|
1378
1387
|
if (typeof getMeta !== 'function')
|
|
1379
|
-
return stamp.nounCount;
|
|
1388
|
+
return stamp.nounCount + gap;
|
|
1380
1389
|
let stillUnpostable = 0;
|
|
1381
1390
|
for (const id of stamp.nounIds) {
|
|
1382
1391
|
const md = await getMeta.call(this.storage, id).catch(() => undefined);
|
|
1383
1392
|
if (md == null)
|
|
1384
1393
|
stillUnpostable++;
|
|
1385
1394
|
}
|
|
1386
|
-
return stillUnpostable;
|
|
1395
|
+
return stillUnpostable + gap;
|
|
1387
1396
|
}
|
|
1388
1397
|
prodLog.warn(`[NativeMetadataIndex] unpostable stamp is count-only ` +
|
|
1389
1398
|
`(${stamp.nounCount} > id cap) — the posted-count floor carries its ` +
|
|
1390
1399
|
`staleness until the next completed rebuild refreshes it.`);
|
|
1391
|
-
return stamp.nounCount;
|
|
1400
|
+
return stamp.nounCount + gap;
|
|
1392
1401
|
}
|
|
1393
1402
|
catch {
|
|
1394
1403
|
return 0;
|
|
@@ -2847,7 +2856,53 @@ export class MetadataIndexManager {
|
|
|
2847
2856
|
await this.ensureFieldLoaded(field);
|
|
2848
2857
|
loaded++;
|
|
2849
2858
|
}
|
|
2850
|
-
|
|
2859
|
+
// Adjudicated probe (3.0.19): the native walk surfaces ONE suspect
|
|
2860
|
+
// {field, uuid}; a single canonical read decides it. ≥2 current
|
|
2861
|
+
// values = a mixed-length ARRAY field, not a phantom — learned,
|
|
2862
|
+
// persisted, and the probe re-runs (each pass retires one field
|
|
2863
|
+
// permanently, so this converges in ≤ field count; bounded harder
|
|
2864
|
+
// below). Anything else = a genuine phantom → false, exactly the old
|
|
2865
|
+
// detection strength. This replaces the MIX guess whose verdict
|
|
2866
|
+
// depended on which ints the sample happened to draw (accord's brain:
|
|
2867
|
+
// clean boots 1–2, failing boot 3, identical bytes).
|
|
2868
|
+
if (typeof this.native.probeConsistencySuspect !== 'function') {
|
|
2869
|
+
// Pre-3.0.19 binary: no adjudication surface — use the sampled MIX
|
|
2870
|
+
// verdict. WITHOUT this explicit fallback the call below throws into
|
|
2871
|
+
// the outer catch, which assumes healthy — i.e. a stale binary would
|
|
2872
|
+
// silently DISABLE phantom detection (the 3.0.12–3.0.15 class).
|
|
2873
|
+
return this.native.probeConsistency();
|
|
2874
|
+
}
|
|
2875
|
+
const MAX_ADJUDICATIONS = 8;
|
|
2876
|
+
for (let i = 0; i <= MAX_ADJUDICATIONS; i++) {
|
|
2877
|
+
const suspectJson = this.native.probeConsistencySuspect();
|
|
2878
|
+
if (!suspectJson)
|
|
2879
|
+
return true;
|
|
2880
|
+
if (i === MAX_ADJUDICATIONS)
|
|
2881
|
+
return false;
|
|
2882
|
+
const { field, uuid } = JSON.parse(suspectJson);
|
|
2883
|
+
let currentValues = [];
|
|
2884
|
+
try {
|
|
2885
|
+
const noun = await this.storage.getNoun(uuid);
|
|
2886
|
+
if (noun) {
|
|
2887
|
+
const raw = resolveEntityField(noun, field);
|
|
2888
|
+
if (raw !== undefined && raw !== null) {
|
|
2889
|
+
currentValues = Array.isArray(raw) ? raw : [raw];
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
catch {
|
|
2894
|
+
// Unreadable canonical = cannot exonerate — keep the phantom verdict.
|
|
2895
|
+
}
|
|
2896
|
+
if (currentValues.length < 2)
|
|
2897
|
+
return false;
|
|
2898
|
+
this.native.markFieldMultiValue(field);
|
|
2899
|
+
const json = this.native.saveFieldIndex(field);
|
|
2900
|
+
if (json) {
|
|
2901
|
+
await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, JSON.parse(json));
|
|
2902
|
+
}
|
|
2903
|
+
prodLog.info(`[probeConsistency] field '${field}' adjudicated multi-valued against canonical (${currentValues.length} current values) — learned + persisted, exempt from phantom checks`);
|
|
2904
|
+
}
|
|
2905
|
+
return false;
|
|
2851
2906
|
}
|
|
2852
2907
|
catch (error) {
|
|
2853
2908
|
// A probe must never throw into the read path — assume healthy.
|
|
@@ -2943,6 +2998,14 @@ export class MetadataIndexManager {
|
|
|
2943
2998
|
purged++;
|
|
2944
2999
|
repairedFields.add(field);
|
|
2945
3000
|
}
|
|
3001
|
+
// MULTI-VALUE LEARNING persistence (3.0.19): ≥2 current values
|
|
3002
|
+
// proves the field is array-shaped — the native side just learned
|
|
3003
|
+
// it (probe/detector exemption); persist the flag with the field
|
|
3004
|
+
// index or the next cold boot re-flags the same candidates
|
|
3005
|
+
// forever (accord's 166-loop, second mechanism).
|
|
3006
|
+
if (currentValues.length >= 2) {
|
|
3007
|
+
repairedFields.add(field);
|
|
3008
|
+
}
|
|
2946
3009
|
}
|
|
2947
3010
|
catch (error) {
|
|
2948
3011
|
prodLog.warn(`[purgeCrossBucketPhantoms] repair of ${uuid}.${field} failed: ${error}`);
|
|
@@ -3073,12 +3136,52 @@ export class MetadataIndexManager {
|
|
|
3073
3136
|
// detector uses the honest floor instead of re-flagging the same
|
|
3074
3137
|
// cohort forever. Refreshed on every completed rebuild; an empty
|
|
3075
3138
|
// set clears the stamp.
|
|
3139
|
+
//
|
|
3140
|
+
// COMPLETION GAP (3.0.19, memory's 52): canonical rows the walk
|
|
3141
|
+
// CANNOT enumerate — dangling counter rows with no readable record —
|
|
3142
|
+
// never appear in items, so the per-id stamp stayed EMPTY while the
|
|
3143
|
+
// detector's canonical count still included them: every detector
|
|
3144
|
+
// boot re-healed (~11 min on memory-vm) forever. A COMPLETED walk is
|
|
3145
|
+
// the strongest possible statement of "everything enumerable is
|
|
3146
|
+
// posted", so the residual canonical-minus-walked difference is
|
|
3147
|
+
// stamped as a count-only allowance (invisible rows have no ids).
|
|
3148
|
+
// Bounded staleness: refreshed on every completed walk; brainy's
|
|
3149
|
+
// counter recount (8.3.2 repairIndex) removes the gap permanently.
|
|
3150
|
+
const walked = nounResult.processed + nounResult.noMetadata;
|
|
3151
|
+
let completionGap = 0;
|
|
3152
|
+
let canonicalAtCompletion;
|
|
3153
|
+
try {
|
|
3154
|
+
const getNouns = this.storage.getNouns;
|
|
3155
|
+
if (typeof getNouns === 'function') {
|
|
3156
|
+
const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
|
|
3157
|
+
const canonical = probe?.totalCount;
|
|
3158
|
+
if (typeof canonical === 'number' &&
|
|
3159
|
+
Number.isFinite(canonical) &&
|
|
3160
|
+
canonical < 0xffff_ffff) {
|
|
3161
|
+
canonicalAtCompletion = canonical;
|
|
3162
|
+
completionGap = Math.max(0, canonical - walked);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
catch {
|
|
3167
|
+
// Gap probe is advisory — a failed canonical read stamps no gap
|
|
3168
|
+
// (may over-heal next boot, never under-detects).
|
|
3169
|
+
}
|
|
3076
3170
|
await this.storage.saveMetadata(UNPOSTABLE_STAMP_KEY, {
|
|
3077
3171
|
nounCount: nounResult.noMetadata,
|
|
3078
3172
|
nounIds: nounResult.noMetadataIds,
|
|
3079
3173
|
verbCount: verbResult.noMetadata,
|
|
3174
|
+
completionGap,
|
|
3175
|
+
canonicalAtCompletion,
|
|
3080
3176
|
verifiedAt: Date.now(),
|
|
3081
3177
|
});
|
|
3178
|
+
if (completionGap > 0) {
|
|
3179
|
+
opsLine(`rebuild: canonical reports ${canonicalAtCompletion} nouns but only ` +
|
|
3180
|
+
`${walked} were enumerable by a COMPLETED walk — the ${completionGap}-entity ` +
|
|
3181
|
+
`gap is stamped as a completion-gap allowance (unenumerable canonical ` +
|
|
3182
|
+
`rows, e.g. inflated counters; a brainy counter recount removes them ` +
|
|
3183
|
+
`permanently). Boots stop re-healing over this gap.`);
|
|
3184
|
+
}
|
|
3082
3185
|
opsLine(`Metadata index rebuild completed! Processed ${nounResult.processed} nouns and ` +
|
|
3083
3186
|
`${verbResult.processed} verbs` +
|
|
3084
3187
|
(nounResult.noMetadata > 0
|
package/dist/version.d.ts
CHANGED
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
* `check:brainy`-style drift between the two fails the release.
|
|
12
12
|
*/
|
|
13
13
|
/** The @soulcraft/cor version this build was cut from. */
|
|
14
|
-
export declare const COR_VERSION = "3.0.
|
|
14
|
+
export declare const COR_VERSION = "3.0.19";
|
|
15
15
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
Binary file
|
package/native/index.d.ts
CHANGED
|
@@ -722,6 +722,18 @@ export declare class NativeGraphAdjacencyIndex {
|
|
|
722
722
|
* solely for legacy-data remove paths.
|
|
723
723
|
*/
|
|
724
724
|
removeVerb(verbId: string, verbTypeIndex: number, subtype: string, generation: bigint): void
|
|
725
|
+
/**
|
|
726
|
+
* **Removal law (3.0.19)**: removal must never require reading the
|
|
727
|
+
* thing being removed. The JS remove path read the verb's CANONICAL
|
|
728
|
+
* row to learn (type, subtype) and silently SKIPPED removal when that
|
|
729
|
+
* row was unreadable — a ghost's graph rows survived forever (the
|
|
730
|
+
* third instance of the anti-pattern, this one cor's own). This
|
|
731
|
+
* variant resolves everything from cor-owned state: type from the
|
|
732
|
+
* endpoint store (or the per-type unions for membership-only verbs),
|
|
733
|
+
* subtype by scanning that type's buckets for the interned id.
|
|
734
|
+
* Returns `false` when the id is unknown or already dead (idempotent).
|
|
735
|
+
*/
|
|
736
|
+
removeVerbByIdOnly(verbId: string, generation: bigint): boolean
|
|
725
737
|
/**
|
|
726
738
|
* **Look up a verb's endpoints by verb-ID.** Returns
|
|
727
739
|
* `{sourceInt, targetInt}` (as u64 BigInts) when the verb was
|
|
@@ -999,6 +1011,43 @@ export declare class NativeGraphAdjacencyIndex {
|
|
|
999
1011
|
* counts converge to the same totals).
|
|
1000
1012
|
*/
|
|
1001
1013
|
trackVerbId(verbId: string, verbType: string): void
|
|
1014
|
+
/**
|
|
1015
|
+
* Membership-only tracking that ALSO restores the per-(type, subtype)
|
|
1016
|
+
* bucket and subtype count — the full-fidelity variant of
|
|
1017
|
+
* [`track_verb_id`](Self::track_verb_id) for the boot heal walk.
|
|
1018
|
+
* The membership-only walk used to drop subtype state entirely, so
|
|
1019
|
+
* `findConnectedSubtype` silently returned nothing after a restart
|
|
1020
|
+
* until a full adjacency rebuild happened to run (3.0.19 find).
|
|
1021
|
+
* Same empty-string escape as the add path: `subtype: ''` tracks
|
|
1022
|
+
* type-level membership only, never an empty-string bucket.
|
|
1023
|
+
*/
|
|
1024
|
+
trackVerbIdFull(verbId: string, verbTypeIndex: number, subtype: string): void
|
|
1025
|
+
/**
|
|
1026
|
+
* Serialize EVERY RAM-resident membership structure to one buffer so
|
|
1027
|
+
* the JS wrapper can persist it as a stamped family member (3.0.19:
|
|
1028
|
+
* the roster/buckets/counts were RAM-only, costing every boot an
|
|
1029
|
+
* O(all-canonical-verbs) walk and silently emptying subtype filters).
|
|
1030
|
+
* Format v1, little-endian, strictly length-framed:
|
|
1031
|
+
* magic 'CVMB' u32 | version u8 | idSpace u8 (0=U32, 1=U64) |
|
|
1032
|
+
* roster (u32 len + portable bytes) |
|
|
1033
|
+
* u8 nUnions { u8 typeIdx, u32 len, bytes } |
|
|
1034
|
+
* u32 nSubBuckets { u8 typeIdx, u16 subLen, utf8, u32 len, bytes } |
|
|
1035
|
+
* u8 nCounts { u8 typeIdx, u64 count } |
|
|
1036
|
+
* u32 nSubCounts { u8 typeIdx, u16 subLen, utf8, u64 count }
|
|
1037
|
+
* Subtype keys are emitted sorted so identical state yields identical
|
|
1038
|
+
* bytes (the stamp records the exact byte size).
|
|
1039
|
+
*/
|
|
1040
|
+
serializeVerbMembership(): Buffer
|
|
1041
|
+
/**
|
|
1042
|
+
* Restore every membership structure from a
|
|
1043
|
+
* [`serialize_verb_membership`](Self::serialize_verb_membership)
|
|
1044
|
+
* buffer. Strict total parse: ANY malformation (bad magic/version,
|
|
1045
|
+
* id-space mismatch with this index, truncation, trailing bytes,
|
|
1046
|
+
* out-of-range type index) throws so the caller falls back to the
|
|
1047
|
+
* canonical heal walk — a torn blob must never half-load silently.
|
|
1048
|
+
* Clears existing membership state first (idempotent overwrite).
|
|
1049
|
+
*/
|
|
1050
|
+
loadVerbMembership(buf: Buffer): void
|
|
1002
1051
|
/**
|
|
1003
1052
|
* Clear membership bitmap + relationship counts. Used before
|
|
1004
1053
|
* rebuild to ensure clean state. The verb-ID namespace's
|
|
@@ -1870,6 +1919,23 @@ export declare class NativeMetadataIndex {
|
|
|
1870
1919
|
* read path.
|
|
1871
1920
|
*/
|
|
1872
1921
|
probeConsistency(): boolean
|
|
1922
|
+
/**
|
|
1923
|
+
* The suspect-surfacing probe (3.0.19): same bounded walk as
|
|
1924
|
+
* `probeConsistency`, but instead of a bare verdict it returns the FIRST
|
|
1925
|
+
* suspicious `{field, uuid}` as JSON (None = clean). The TS wrapper
|
|
1926
|
+
* ADJUDICATES the suspect with one canonical read — ≥2 current values
|
|
1927
|
+
* proves the field is array-shaped (learned + persisted, probe re-runs),
|
|
1928
|
+
* anything else is a genuine phantom (probe fails). This replaces the
|
|
1929
|
+
* MIX guess whose verdict depended on WHICH ints the sample drew —
|
|
1930
|
+
* accord's brain passed boots 1–2 and failed boot 3 on identical bytes.
|
|
1931
|
+
*/
|
|
1932
|
+
probeConsistencySuspect(): string | null
|
|
1933
|
+
/**
|
|
1934
|
+
* Mark a field as learned-multi-valued (3.0.19). Called by the TS
|
|
1935
|
+
* wrapper after canonical adjudication proves an entity legitimately
|
|
1936
|
+
* holds ≥2 values for the field. The caller persists the field index.
|
|
1937
|
+
*/
|
|
1938
|
+
markFieldMultiValue(field: string): void
|
|
1873
1939
|
/**
|
|
1874
1940
|
* **Phantom triad — state-level scan (matrix 7.8b).** Find every
|
|
1875
1941
|
* query-visible int that sits in MORE THAN ONE value bucket of a field —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soulcraft/cor",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.19",
|
|
4
4
|
"description": "Native Rust acceleration for Brainy — SIMD distance, vector quantization, zero-copy mmap, native embeddings. Free tier for storage, Pro license for compute acceleration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@napi-rs/cli": "^3.0.0",
|
|
85
|
-
"@soulcraft/brainy": "8.3.
|
|
85
|
+
"@soulcraft/brainy": "8.3.2",
|
|
86
86
|
"@types/node": "^22.0.0",
|
|
87
87
|
"pg": "^8.21.0",
|
|
88
88
|
"tsx": "^4.21.0",
|