@soulcraft/cor 3.0.4 → 3.0.5

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.
@@ -210,6 +210,14 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
210
210
  /** Segment-manifest cold-load memo (see {@link ensureSegments}). */
211
211
  private segmentsLoaded;
212
212
  private segmentsLoading;
213
+ /**
214
+ * True when a durable base index was present on disk but failed to load
215
+ * (corrupt file, or a missing slotmap sidecar). Distinct from a fresh brain
216
+ * (no file at all): drives {@link isReady} to report NOT-ready so brainy's
217
+ * cold-open gate recovers via `rebuild()` instead of skipping an unusable
218
+ * index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
219
+ */
220
+ private durableBaseLoadFailed;
213
221
  /** Serializes manifest writes: the storage adapter has no per-key write
214
222
  * ordering, so two in-flight saves (a flush racing a consolidation) could
215
223
  * physically land out of order and persist a STALE segment list — losing a
@@ -325,6 +333,24 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
325
333
  * @returns `true` when the brain must not be read from or written to.
326
334
  */
327
335
  isMigrating(): boolean;
336
+ /**
337
+ * @description **Readiness contract (brainy ≥ 8.0.12).** Honest durability
338
+ * signal for brainy's per-leg cold-open rebuild gate: `true` ⇔ the persisted
339
+ * index is loaded (or there is none to load) and a rebuild from canonical
340
+ * would be redundant. Brainy defers to this INSTEAD of the `size()===0`
341
+ * heuristic — a disk-native index legitimately reports zero resident nodes
342
+ * while fully durable, which is exactly why `size()` is the wrong gate.
343
+ *
344
+ * Returns `false` only when a durable base index was present but FAILED to
345
+ * load ({@link durableBaseLoadFailed}) — never claim ready over a failed load,
346
+ * or the gate would serve silent-empty results instead of recovering via
347
+ * `rebuild()`. A fresh brain (no durable index) is ready: loading nothing
348
+ * succeeds and a rebuild is a no-op. Partially-skipped L0 segments do NOT make
349
+ * it not-ready — their entries remain in canonical and heal via consolidation
350
+ * (see {@link loadSegmentManifest}).
351
+ * @returns `true` when a cold-open rebuild would be redundant.
352
+ */
353
+ isReady(): boolean;
328
354
  /**
329
355
  * @description **#18 migration lock — structured progress** Brainy relays into
330
356
  * `getIndexStatus().migration`. `null` unless a migration is in flight.
@@ -392,10 +418,18 @@ export declare class NativeDiskAnnWrapper implements VectorIndexProvider {
392
418
  /** Membership across the base + every L0 segment (int → any slot). */
393
419
  private hasIntAnywhere;
394
420
  /**
395
- * @description Provider init brainy feature-detects and awaits it. Cold-loads
396
- * the segment manifest so a reopened brain serves its L0 segments from the
397
- * first query (the wrapper's own async paths also call {@link ensureSegments},
398
- * so a caller that skips init() still gets the segments lazily).
421
+ * @description **Readiness contract (brainy 8.0.12).** Provider init — brainy
422
+ * feature-detects and awaits it once during brain open, after the metadata
423
+ * provider's `init()` (id-mapper hydrated) and BEFORE its cold-open rebuild
424
+ * gate. The base index is mmap-attached in the constructor ({@link
425
+ * tryOpenExisting}); this eager cold-load attaches the L0 **segment** set too,
426
+ * so `size()`/{@link isReady} reflect the FULL persisted index at the gate
427
+ * rather than the base alone. Without it the async segment cold-load defers
428
+ * past the gate, `size()` reads low/zero, and brainy rebuilds from canonical on
429
+ * every open (the 48s-per-boot class). An mmap attach — O(pages touched), never
430
+ * an O(N) rebuild — so it stays cheap at billion scale. (The wrapper's own
431
+ * async paths also call {@link ensureSegments}, so a caller that skips init()
432
+ * still gets the segments lazily; only the pre-gate timing needs init().)
399
433
  */
400
434
  init(): Promise<void>;
401
435
  /** Memoized segment-manifest cold-load. */
@@ -312,6 +312,14 @@ export class NativeDiskAnnWrapper {
312
312
  /** Segment-manifest cold-load memo (see {@link ensureSegments}). */
313
313
  segmentsLoaded = false;
314
314
  segmentsLoading = null;
315
+ /**
316
+ * True when a durable base index was present on disk but failed to load
317
+ * (corrupt file, or a missing slotmap sidecar). Distinct from a fresh brain
318
+ * (no file at all): drives {@link isReady} to report NOT-ready so brainy's
319
+ * cold-open gate recovers via `rebuild()` instead of skipping an unusable
320
+ * index. Set in {@link tryOpenExisting}; cleared by a successful rebuild.
321
+ */
322
+ durableBaseLoadFailed = false;
315
323
  /** Serializes manifest writes: the storage adapter has no per-key write
316
324
  * ordering, so two in-flight saves (a flush racing a consolidation) could
317
325
  * physically land out of order and persist a STALE segment list — losing a
@@ -715,6 +723,31 @@ export class NativeDiskAnnWrapper {
715
723
  isMigrating() {
716
724
  return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
717
725
  }
726
+ /**
727
+ * @description **Readiness contract (brainy ≥ 8.0.12).** Honest durability
728
+ * signal for brainy's per-leg cold-open rebuild gate: `true` ⇔ the persisted
729
+ * index is loaded (or there is none to load) and a rebuild from canonical
730
+ * would be redundant. Brainy defers to this INSTEAD of the `size()===0`
731
+ * heuristic — a disk-native index legitimately reports zero resident nodes
732
+ * while fully durable, which is exactly why `size()` is the wrong gate.
733
+ *
734
+ * Returns `false` only when a durable base index was present but FAILED to
735
+ * load ({@link durableBaseLoadFailed}) — never claim ready over a failed load,
736
+ * or the gate would serve silent-empty results instead of recovering via
737
+ * `rebuild()`. A fresh brain (no durable index) is ready: loading nothing
738
+ * succeeds and a rebuild is a no-op. Partially-skipped L0 segments do NOT make
739
+ * it not-ready — their entries remain in canonical and heal via consolidation
740
+ * (see {@link loadSegmentManifest}).
741
+ * @returns `true` when a cold-open rebuild would be redundant.
742
+ */
743
+ isReady() {
744
+ if (this.durableBaseLoadFailed)
745
+ return false;
746
+ // init() (or any prior async op) has attached the segment set. Before that,
747
+ // report not-ready so the gate never skips over an unloaded index; brainy's
748
+ // contract calls isReady() only after init(), where this is true.
749
+ return this.segmentsLoaded;
750
+ }
718
751
  /**
719
752
  * @description **#18 migration lock — structured progress** Brainy relays into
720
753
  * `getIndexStatus().migration`. `null` unless a migration is in flight.
@@ -1056,6 +1089,9 @@ export class NativeDiskAnnWrapper {
1056
1089
  // the #72 Phase A+B win. slot ↔ uuid resolution flows through the
1057
1090
  // mmap slotmaps + the canonical mapper on every query.
1058
1091
  this.native = newNative;
1092
+ // A completed rebuild regenerates the base index + its slotmap sidecar, so
1093
+ // any prior failed-durable-load state is now resolved — the index is ready.
1094
+ this.durableBaseLoadFailed = false;
1059
1095
  // Reconcile the LIVE delta/tombstones against the fold snapshot: only
1060
1096
  // entries the fold actually baked in are cleared. Adds/updates/removes
1061
1097
  // that landed during the async build stay behind for the next fold (an
@@ -1094,7 +1130,32 @@ export class NativeDiskAnnWrapper {
1094
1130
  * size for parity with HNSW's flush contract.
1095
1131
  */
1096
1132
  async flush() {
1097
- return this.delta.size;
1133
+ // **Durable close (readiness contract).** Persist the in-memory delta to a
1134
+ // durable L0 segment. brainy calls flush() on shutdown/close (never per
1135
+ // write — verified against its flushOnShutdown + close paths), so a brain
1136
+ // that added fewer than the soft auto-fold threshold ({@link
1137
+ // DELTA_SOFT_FOLD_BYTES}) still lands ALL its writes durably on a clean
1138
+ // close. WITHOUT this, that sub-threshold delta lived only in canonical
1139
+ // storage and the vector index rebuilt it from canonical on the NEXT open —
1140
+ // a cold-open rebuild on every restart (the 48s-per-boot class; a small
1141
+ // brain never folds, so it paid it forever). After this flush the durable
1142
+ // base + segments reflect every persisted entity, so {@link isReady}
1143
+ // reports true at brainy's cold-open gate and the rebuild is skipped.
1144
+ //
1145
+ // Drain any in-flight fold first (it may already be persisting this delta),
1146
+ // then persist whatever remains. flushDeltaToL0 no-ops on an empty delta and
1147
+ // coalesces with a concurrent flush; a transient base/L0 overlap is
1148
+ // deduplicated at search time by the per-hit authority check. Bounded cost:
1149
+ // repeated small closes make small L0s that consolidation merges at
1150
+ // L0_COMPACT_THRESHOLD.
1151
+ const pending = this.delta.size;
1152
+ if (this.flushInFlight)
1153
+ await this.flushInFlight;
1154
+ if (this.rebuildInFlight)
1155
+ await this.rebuildInFlight;
1156
+ if (this.delta.size > 0)
1157
+ await this.flushDeltaToL0();
1158
+ return pending;
1098
1159
  }
1099
1160
  getPersistMode() {
1100
1161
  return this.persistMode;
@@ -1114,10 +1175,18 @@ export class NativeDiskAnnWrapper {
1114
1175
  return false;
1115
1176
  }
1116
1177
  /**
1117
- * @description Provider init brainy feature-detects and awaits it. Cold-loads
1118
- * the segment manifest so a reopened brain serves its L0 segments from the
1119
- * first query (the wrapper's own async paths also call {@link ensureSegments},
1120
- * so a caller that skips init() still gets the segments lazily).
1178
+ * @description **Readiness contract (brainy 8.0.12).** Provider init — brainy
1179
+ * feature-detects and awaits it once during brain open, after the metadata
1180
+ * provider's `init()` (id-mapper hydrated) and BEFORE its cold-open rebuild
1181
+ * gate. The base index is mmap-attached in the constructor ({@link
1182
+ * tryOpenExisting}); this eager cold-load attaches the L0 **segment** set too,
1183
+ * so `size()`/{@link isReady} reflect the FULL persisted index at the gate
1184
+ * rather than the base alone. Without it the async segment cold-load defers
1185
+ * past the gate, `size()` reads low/zero, and brainy rebuilds from canonical on
1186
+ * every open (the 48s-per-boot class). An mmap attach — O(pages touched), never
1187
+ * an O(N) rebuild — so it stays cheap at billion scale. (The wrapper's own
1188
+ * async paths also call {@link ensureSegments}, so a caller that skips init()
1189
+ * still gets the segments lazily; only the pre-gate timing needs init().)
1121
1190
  */
1122
1191
  async init() {
1123
1192
  await this.ensureSegments();
@@ -1228,7 +1297,17 @@ export class NativeDiskAnnWrapper {
1228
1297
  const builtDelta = new Map(this.delta);
1229
1298
  if (builtDelta.size === 0)
1230
1299
  return;
1231
- const dim = this.config.dimensions;
1300
+ // Resolve the vector dimension robustly. `this.config.dimensions` is the
1301
+ // declared dim, but brainy's zero-config vector factory does not always pass
1302
+ // it (the wrapper marks it "required", yet a provider constructed without it
1303
+ // must still flush on close). Fall back to the attached base index's header,
1304
+ // then to the delta's own vectors — every vector in a brain shares one dim
1305
+ // (brainy enforces the embedding model's). Without this fallback, flush()
1306
+ // on a zero-config brain threw `≠ index dim undefined` on every close.
1307
+ const firstVec = builtDelta.values().next().value;
1308
+ const dim = this.config.dimensions ??
1309
+ (this.native ? this.native.header().dim : undefined) ??
1310
+ firstVec.length;
1232
1311
  const mapper = this.mapper();
1233
1312
  const deltaBuf = new Float32Array(builtDelta.size * dim);
1234
1313
  const slotIdsArr = new BigUint64Array(builtDelta.size);
@@ -1320,15 +1399,26 @@ export class NativeDiskAnnWrapper {
1320
1399
  // .slots.json parse). No mapper is needed here; slot↔uuid
1321
1400
  // resolution happens lazily at query/rebuild time.
1322
1401
  if (!this.native.slotmapReady()) {
1402
+ // The base index file is present but its slotmap sidecar is missing —
1403
+ // the index exists on disk but can't map hits to entity ints. It must
1404
+ // be regenerated by rebuild(); until then this is a FAILED durable load,
1405
+ // not a fresh/empty brain. isReady() reports it so brainy's gate
1406
+ // recovers via rebuild instead of skipping over an unusable index.
1323
1407
  this.native = null;
1408
+ this.durableBaseLoadFailed = true;
1324
1409
  }
1325
1410
  else {
1326
1411
  this.removeLegacySlotsSidecar();
1327
1412
  }
1328
1413
  }
1329
1414
  catch {
1330
- // No existing file index stays empty until first rebuild().
1415
+ // openExisting threw. Distinguish a fresh brain (no file normal, index
1416
+ // stays empty until the first rebuild) from a corrupt/unreadable durable
1417
+ // index that IS on disk (a failed load that needs rebuild recovery).
1331
1418
  this.native = null;
1419
+ const path = this.config.indexPath;
1420
+ if (path !== undefined && existsSync(path))
1421
+ this.durableBaseLoadFailed = true;
1332
1422
  }
1333
1423
  }
1334
1424
  /**
@@ -97,6 +97,12 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
97
97
  private config;
98
98
  private native;
99
99
  private isRebuilding;
100
+ /**
101
+ * True once {@link init} has finished loading the durable derived state
102
+ * (registry + LSM cold-load + column store). Gates {@link isReady} so brainy's
103
+ * cold-open rebuild gate never skips over an index that has not loaded yet.
104
+ */
105
+ private readinessInitialized;
100
106
  /**
101
107
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
102
108
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -497,6 +503,21 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
497
503
  */
498
504
  getIdMapper(): EntityIdMapperLike;
499
505
  init(): Promise<void>;
506
+ /**
507
+ * @description **Readiness contract (brainy ≥ 8.0.12).** Honest durability
508
+ * signal for brainy's per-leg cold-open rebuild gate: `true` ⇔ this metadata
509
+ * index is loaded and a rebuild from canonical would be redundant. Brainy
510
+ * defers to this INSTEAD of the `getStats().totalEntries === 0` heuristic.
511
+ *
512
+ * Returns `false` when: `init()` has not completed; a migration is in flight
513
+ * (the gate skips a migrating leg, and the migration itself rebuilds); or the
514
+ * field registry lists fields but the LSM engine recovered NO durable state —
515
+ * the derived index is genuinely missing and a rebuild must run (never let the
516
+ * gate skip a legitimate recovery). Otherwise (durable LSM loaded, or a
517
+ * genuinely empty brain) a rebuild is redundant and this returns `true`.
518
+ * @returns `true` when a cold-open rebuild would be redundant.
519
+ */
520
+ isReady(): boolean;
500
521
  /**
501
522
  * **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
502
523
  * brain's `_id_mapper/*` storage paths and inject it into the Rust
@@ -171,6 +171,12 @@ export class MetadataIndexManager {
171
171
  config;
172
172
  native;
173
173
  isRebuilding = false;
174
+ /**
175
+ * True once {@link init} has finished loading the durable derived state
176
+ * (registry + LSM cold-load + column store). Gates {@link isReady} so brainy's
177
+ * cold-open rebuild gate never skips over an index that has not loaded yet.
178
+ */
179
+ readinessInitialized = false;
174
180
  /**
175
181
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
176
182
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -1108,11 +1114,39 @@ export class MetadataIndexManager {
1108
1114
  // `.catch` only prevents an unhandled rejection on the start() promise when a
1109
1115
  // failed migration surfaces (it is logged loudly and keeps the brain locked). No coordinator
1110
1116
  // wired (raw-napi test / non-migrating tier) → no-op.
1117
+ // Readiness contract (brainy ≥ 8.0.12): everything durable has now been
1118
+ // loaded (registry + LSM cold-load + column store) or determined absent.
1119
+ // Set BEFORE the migration kick — during a migration isReady() reports
1120
+ // not-ready via isMigrating(), and brainy's gate skips a migrating leg anyway.
1121
+ this.readinessInitialized = true;
1111
1122
  if (this.epochMigrationPending) {
1112
1123
  this.epochMigrationPending = false;
1113
1124
  this.migrationCoordinatorGetter?.()?.start().catch(() => { });
1114
1125
  }
1115
1126
  }
1127
+ /**
1128
+ * @description **Readiness contract (brainy ≥ 8.0.12).** Honest durability
1129
+ * signal for brainy's per-leg cold-open rebuild gate: `true` ⇔ this metadata
1130
+ * index is loaded and a rebuild from canonical would be redundant. Brainy
1131
+ * defers to this INSTEAD of the `getStats().totalEntries === 0` heuristic.
1132
+ *
1133
+ * Returns `false` when: `init()` has not completed; a migration is in flight
1134
+ * (the gate skips a migrating leg, and the migration itself rebuilds); or the
1135
+ * field registry lists fields but the LSM engine recovered NO durable state —
1136
+ * the derived index is genuinely missing and a rebuild must run (never let the
1137
+ * gate skip a legitimate recovery). Otherwise (durable LSM loaded, or a
1138
+ * genuinely empty brain) a rebuild is redundant and this returns `true`.
1139
+ * @returns `true` when a cold-open rebuild would be redundant.
1140
+ */
1141
+ isReady() {
1142
+ if (!this.readinessInitialized)
1143
+ return false;
1144
+ if (this.isMigrating())
1145
+ return false;
1146
+ if (this.knownFields.size > 0 && !this.lsmHasDurableState())
1147
+ return false;
1148
+ return true;
1149
+ }
1116
1150
  // ==========================================================================
1117
1151
  // Storage I/O helpers
1118
1152
  // ==========================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulcraft/cor",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "description": "Native Rust acceleration for Brainy \u2014 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.0.11",
85
+ "@soulcraft/brainy": "8.0.12",
86
86
  "@types/node": "^22.0.0",
87
87
  "pg": "^8.21.0",
88
88
  "tsx": "^4.21.0",