@soulcraft/cor 3.0.12 → 3.0.14

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.
@@ -150,8 +150,12 @@ export declare class GraphAdjacencyIndex implements GraphIndexProvider {
150
150
  * rebuild is NOT redundant, and reporting ready serves empty adjacency.
151
151
  * Bounded O(1): one single-verb canonical page, only on the nothing-durable
152
152
  * path — after the gate-triggered rebuild persists the trees, later boots
153
- * latch edges-ready and never reach the probe. Truly edgeless brains (no
154
- * canonical verbs) stay ready, so the boot-rebuild/502 class stays dead.
153
+ * compute ready from the resident SSTables and never reach the probe.
154
+ * Truly edgeless brains (no canonical verbs) stay ready, so the
155
+ * boot-rebuild/502 class stays dead. The size() guard is honest under
156
+ * partial loads (3.0.13): the native count degrades to memtable-resident
157
+ * when the manifest's claim is not fully loaded, so a damaged durable set
158
+ * can no longer suppress this probe.
155
159
  */
156
160
  private probeCanonicalCoverage;
157
161
  /**
@@ -243,8 +243,12 @@ export class GraphAdjacencyIndex {
243
243
  * rebuild is NOT redundant, and reporting ready serves empty adjacency.
244
244
  * Bounded O(1): one single-verb canonical page, only on the nothing-durable
245
245
  * path — after the gate-triggered rebuild persists the trees, later boots
246
- * latch edges-ready and never reach the probe. Truly edgeless brains (no
247
- * canonical verbs) stay ready, so the boot-rebuild/502 class stays dead.
246
+ * compute ready from the resident SSTables and never reach the probe.
247
+ * Truly edgeless brains (no canonical verbs) stay ready, so the
248
+ * boot-rebuild/502 class stays dead. The size() guard is honest under
249
+ * partial loads (3.0.13): the native count degrades to memtable-resident
250
+ * when the manifest's claim is not fully loaded, so a damaged durable set
251
+ * can no longer suppress this probe.
248
252
  */
249
253
  async probeCanonicalCoverage() {
250
254
  if (this.native.isReady() || this.native.size() > 0)
@@ -566,14 +570,24 @@ export class GraphAdjacencyIndex {
566
570
  // dir): a rebuild is NOT redundant — see {@link probeCanonicalCoverage}.
567
571
  if (this.canonicalUnindexed)
568
572
  return false;
569
- // Cold-load has run but the edges-ready latch is unset. Ready ⇔ there is
570
- // nothing durable to load: no SSTables and empty memtables on either the
571
- // source or target tree. If any durable edges exist but didn't load, this
572
- // is false and brainy correctly rebuilds.
573
573
  const raw = this.native.getStats();
574
574
  const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
575
+ // 3.0.13: the native engine reports manifest-vs-resident truth. A
576
+ // manifest naming SSTables that are not ALL resident means the graph
577
+ // would serve a subset of its edges — never ready (brainy's gate
578
+ // rebuilds; the rebuild persists a truthful manifest and clears this).
579
+ // Absent on pre-3.0.13 binaries (undefined ≠ false → skip).
580
+ if (s?.loadComplete === false)
581
+ return false;
582
+ // Native reported not-ready with a complete (or absent) durable set.
583
+ // Ready ⇔ there is nothing durable to load: no SSTables and empty
584
+ // memtables across ALL FOUR trees (the verbs trees were structurally
585
+ // invisible here before 3.0.13 — a dead verbs-source with a live
586
+ // source tree slipped through as "empty").
575
587
  const durableEmpty = Number(s?.sourceSstableCount ?? 0) === 0 &&
576
588
  Number(s?.targetSstableCount ?? 0) === 0 &&
589
+ Number(s?.verbsSourceSstableCount ?? 0) === 0 &&
590
+ Number(s?.verbsTargetSstableCount ?? 0) === 0 &&
577
591
  Number(s?.sourceMemTableSize ?? 0) === 0 &&
578
592
  Number(s?.targetMemTableSize ?? 0) === 0;
579
593
  return durableEmpty;
@@ -461,6 +461,20 @@ export interface GraphAdjacencyStats {
461
461
  verbIdCountBig: bigint;
462
462
  /** BigInt relationship type count. */
463
463
  relationshipTypeCountBig: bigint;
464
+ /**
465
+ * Verbs-source tree SSTable count (3.0.13 — the readiness fallback was
466
+ * structurally blind to the verbs trees before these fields existed).
467
+ * Absent on pre-3.0.13 binaries.
468
+ */
469
+ verbsSourceSstableCount?: number;
470
+ /** Verbs-target tree SSTable count. Absent on pre-3.0.13 binaries. */
471
+ verbsTargetSstableCount?: number;
472
+ /**
473
+ * Whether every manifest-named SSTable across all four trees is actually
474
+ * resident. `false` = the graph would serve a subset of its durable edges
475
+ * and reports not-ready. Absent on pre-3.0.13 binaries.
476
+ */
477
+ loadComplete?: boolean;
464
478
  }
465
479
  /**
466
480
  * Result from flushing a tree's MemTable to binary .sst format
@@ -86,6 +86,18 @@ export declare class NativeColumnStore implements ColumnStoreProvider {
86
86
  * TS copy is what gets persisted to `DELETED.bin` and re-applied on init.
87
87
  */
88
88
  private readonly deletedEntities;
89
+ /**
90
+ * Fields whose persisted state could not be FULLY loaded because a read
91
+ * FAULTED (threw) — as opposed to a blob being cleanly absent. A faulted
92
+ * field's column index may be silently partial (missing segments) or
93
+ * resurrect deleted rows (tombstones unapplied), so serving queries from
94
+ * it would return wrong results with no error. Queries against a faulted
95
+ * field THROW instead ({@link assertFieldServable}) — loud errors, never
96
+ * quiet losses. Cleared by {@link reset} (a rebuild repopulates cleanly)
97
+ * and {@link close}. Ready for brainy's loadBinaryBlob fault-vs-absent
98
+ * tightening (EIO throws instead of returning null).
99
+ */
100
+ private readonly faultedFields;
89
101
  /**
90
102
  * Create a native column store. Call {@link init} before queries that need
91
103
  * storage (loading persisted segments). Writes work immediately — the Rust
@@ -122,6 +134,15 @@ export declare class NativeColumnStore implements ColumnStoreProvider {
122
134
  * @param entityIntId - The entity's u32 id.
123
135
  */
124
136
  removeEntity(entityIntId: number): void;
137
+ /**
138
+ * @description Refuse to serve a field whose persisted column state
139
+ * faulted at load — a partial segment set or unapplied tombstones would
140
+ * return WRONG results silently. Throwing names the field and the heal
141
+ * path; the caller's error surfaces where a silent empty/partial result
142
+ * would have been invisible.
143
+ * @param field - Field about to be served.
144
+ */
145
+ private assertFieldServable;
125
146
  /**
126
147
  * @description Point filter: entity int ids whose `field` equals `value`.
127
148
  * Routes to the numeric/float/string Rust filter by the field's value type.
@@ -105,6 +105,18 @@ export class NativeColumnStore {
105
105
  * TS copy is what gets persisted to `DELETED.bin` and re-applied on init.
106
106
  */
107
107
  deletedEntities = new Map();
108
+ /**
109
+ * Fields whose persisted state could not be FULLY loaded because a read
110
+ * FAULTED (threw) — as opposed to a blob being cleanly absent. A faulted
111
+ * field's column index may be silently partial (missing segments) or
112
+ * resurrect deleted rows (tombstones unapplied), so serving queries from
113
+ * it would return wrong results with no error. Queries against a faulted
114
+ * field THROW instead ({@link assertFieldServable}) — loud errors, never
115
+ * quiet losses. Cleared by {@link reset} (a rebuild repopulates cleanly)
116
+ * and {@link close}. Ready for brainy's loadBinaryBlob fault-vs-absent
117
+ * tightening (EIO throws instead of returning null).
118
+ */
119
+ faultedFields = new Set();
108
120
  /**
109
121
  * Create a native column store. Call {@link init} before queries that need
110
122
  * storage (loading persisted segments). Writes work immediately — the Rust
@@ -164,8 +176,13 @@ export class NativeColumnStore {
164
176
  await this.loadDeletedBitmap(field);
165
177
  }
166
178
  catch (error) {
167
- prodLog.warn(`NativeColumnStore: failed to load column field '${field}' skipping it; ` +
168
- `other fields are unaffected`, error);
179
+ // The field's manifest/segments faulted wholesalemark it faulted
180
+ // so queries THROW instead of silently returning empty (the
181
+ // pre-3.0.13 behavior: fieldTypes never set → filter() returned an
182
+ // empty bitmap with no error).
183
+ this.faultedFields.add(field);
184
+ prodLog.error(`NativeColumnStore: failed to load column field '${field}' — marked ` +
185
+ `unavailable (queries will throw); other fields are unaffected`, error);
169
186
  }
170
187
  }
171
188
  }
@@ -216,6 +233,22 @@ export class NativeColumnStore {
216
233
  deleted.add(entityIntId);
217
234
  }
218
235
  }
236
+ /**
237
+ * @description Refuse to serve a field whose persisted column state
238
+ * faulted at load — a partial segment set or unapplied tombstones would
239
+ * return WRONG results silently. Throwing names the field and the heal
240
+ * path; the caller's error surfaces where a silent empty/partial result
241
+ * would have been invisible.
242
+ * @param field - Field about to be served.
243
+ */
244
+ assertFieldServable(field) {
245
+ if (this.faultedFields.has(field)) {
246
+ throw new Error(`NativeColumnStore: column index for field '${field}' is UNAVAILABLE — ` +
247
+ `a persisted segment or tombstone read FAULTED at load (not merely ` +
248
+ `absent), so serving it would silently return partial/wrong results. ` +
249
+ `Heal: restore storage health and restart, or rebuild the metadata index.`);
250
+ }
251
+ }
219
252
  /**
220
253
  * @description Point filter: entity int ids whose `field` equals `value`.
221
254
  * Routes to the numeric/float/string Rust filter by the field's value type.
@@ -224,6 +257,7 @@ export class NativeColumnStore {
224
257
  * @returns Roaring bitmap of matching entity int ids.
225
258
  */
226
259
  async filter(field, value) {
260
+ this.assertFieldServable(field);
227
261
  const type = this.fieldTypes.get(field);
228
262
  if (type === undefined)
229
263
  return new RoaringBitmap32();
@@ -283,6 +317,7 @@ export class NativeColumnStore {
283
317
  * @returns Roaring bitmap of matching entity int ids.
284
318
  */
285
319
  async rangeQuery(field, min, max) {
320
+ this.assertFieldServable(field);
286
321
  const type = this.fieldTypes.get(field);
287
322
  if (type === undefined)
288
323
  return new RoaringBitmap32();
@@ -323,6 +358,7 @@ export class NativeColumnStore {
323
358
  * @returns Sorted array of entity int ids.
324
359
  */
325
360
  async sortTopK(field, order, k) {
361
+ this.assertFieldServable(field);
326
362
  if (k <= 0)
327
363
  return [];
328
364
  // Cor 3.0 Piece E: napi returns BigInt[]. The wrapper narrows
@@ -342,6 +378,7 @@ export class NativeColumnStore {
342
378
  * @returns Sorted array of entity int ids, all present in `filterBitmap`.
343
379
  */
344
380
  async filteredSortTopK(filterBitmap, field, order, k) {
381
+ this.assertFieldServable(field);
345
382
  if (k <= 0 || filterBitmap.size === 0)
346
383
  return [];
347
384
  // Intersecting only the global top-K could drop matches when the filter is
@@ -371,6 +408,7 @@ export class NativeColumnStore {
371
408
  * @returns Distinct values stringified the way brainy renders them.
372
409
  */
373
410
  async getFilterValues(field) {
411
+ this.assertFieldServable(field);
374
412
  if (!this.native.hasField(field))
375
413
  return [];
376
414
  return this.native.distinctValues(field);
@@ -408,6 +446,7 @@ export class NativeColumnStore {
408
446
  this.manifests.clear();
409
447
  this.fieldTypes.clear();
410
448
  this.deletedEntities.clear();
449
+ this.faultedFields.clear();
411
450
  this.storage = null;
412
451
  }
413
452
  /**
@@ -439,11 +478,14 @@ export class NativeColumnStore {
439
478
  }
440
479
  }
441
480
  // Drop all native state by replacing the engine, then clear TS-side state.
481
+ // Faults clear too: the rebuild that follows repopulates every field
482
+ // cleanly from canonical, so the refusal-to-serve verdict is spent.
442
483
  const bindings = loadNativeModule();
443
484
  this.native = new bindings.NativeColumnStore();
444
485
  this.manifests.clear();
445
486
  this.fieldTypes.clear();
446
487
  this.deletedEntities.clear();
488
+ this.faultedFields.clear();
447
489
  }
448
490
  // =========================================================================
449
491
  // Internal helpers
@@ -596,9 +638,33 @@ export class NativeColumnStore {
596
638
  prodLog.debug(`NativeColumnStore: mmap load failed for ${key}, trying buffer`, error);
597
639
  }
598
640
  }
599
- const buf = fallbackBuffer ?? (await this.storage.loadBinaryBlob(key));
641
+ // Fault-vs-absent discrimination (spine law; ready for brainy's
642
+ // loadBinaryBlob tightening where an IO fault THROWS instead of
643
+ // returning null): a FAULTED read means the segment may exist but be
644
+ // unreadable — serving the field without it would silently return
645
+ // partial results, so the field is marked faulted (queries throw) and
646
+ // the remaining fields/segments continue loading. A clean null stays
647
+ // an absent segment (warn + skip, unchanged semantics).
648
+ let buf;
649
+ try {
650
+ buf = fallbackBuffer ?? (await this.storage.loadBinaryBlob(key));
651
+ }
652
+ catch (error) {
653
+ this.faultedFields.add(field);
654
+ prodLog.error(`NativeColumnStore: segment read FAULTED for ${key} — field ` +
655
+ `'${field}' marked unavailable (queries will throw, not return ` +
656
+ `partial results):`, error);
657
+ return;
658
+ }
600
659
  if (buf) {
601
- this.native.loadSegmentFromBuffer(field, String(segId), buf);
660
+ try {
661
+ this.native.loadSegmentFromBuffer(field, String(segId), buf);
662
+ }
663
+ catch (error) {
664
+ this.faultedFields.add(field);
665
+ prodLog.error(`NativeColumnStore: segment attach FAULTED for ${key} (corrupt ` +
666
+ `bytes?) — field '${field}' marked unavailable:`, error);
667
+ }
602
668
  return;
603
669
  }
604
670
  // No raw blob for this segment. A 2.x→3.0 upgrade does NOT read legacy
@@ -615,7 +681,21 @@ export class NativeColumnStore {
615
681
  async loadDeletedBitmap(field) {
616
682
  if (!this.storage)
617
683
  return;
618
- const buf = await this.storage.loadBinaryBlob(deletedBlobKey(field));
684
+ // Fault-vs-absent: a FAULTED tombstone read leaves deleted rows
685
+ // resurrected in every query — worse than a missing segment, because
686
+ // the results are wrong rather than merely partial. Mark the field
687
+ // faulted (queries throw). A clean null = no tombstones, unchanged.
688
+ let buf;
689
+ try {
690
+ buf = await this.storage.loadBinaryBlob(deletedBlobKey(field));
691
+ }
692
+ catch (error) {
693
+ this.faultedFields.add(field);
694
+ prodLog.error(`NativeColumnStore: tombstone bitmap read FAULTED for field ` +
695
+ `'${field}' — deleted rows would resurrect; field marked ` +
696
+ `unavailable (queries will throw):`, error);
697
+ return;
698
+ }
619
699
  if (!buf)
620
700
  return;
621
701
  const bitmap = RoaringBitmap32.deserialize(buf, 'portable');
package/native/index.d.ts CHANGED
@@ -1169,14 +1169,25 @@ export declare class NativeGraphAdjacencyIndex {
1169
1169
  * empty (a fresh brain with nothing persisted) and brainy should build
1170
1170
  * it.
1171
1171
  *
1172
- * **Edges-loaded semantics — never membership.** Returns `true` iff the
1173
- * graph actually holds edges through a durable or live path: the
1174
- * cold-load latch (set when an SSTable loads from binary/file, a manifest
1175
- * names ≥1 SSTable, or a memtable flushes to a persisted SSTable), or a
1176
- * currently non-empty memtable on any of the four trees. The verb-id
1177
- * membership bitmap whose population is the deferred O(N) fast-follow
1178
- * is deliberately NOT consulted, so a slow membership walk can never make
1179
- * a graph with queryable edges report "not ready".
1172
+ * **Edges-loaded semantics — never membership, never a latch.**
1173
+ * COMPUTED from actual residency (3.0.13; previously a boolean latched
1174
+ * TRUE the moment a manifest merely NAMED an SSTable, before any file
1175
+ * opened a total load failure served ready forever):
1176
+ *
1177
+ * 1. If ANY tree's manifest names an SSTable that is not resident,
1178
+ * the graph is NOT ready it would serve a silent subset of its
1179
+ * edges (e.g. verbs-source dead while getNeighbors works). This
1180
+ * clause dominates: live memtable writes cannot mask a partial
1181
+ * durable load.
1182
+ * 2. Otherwise ready iff durable SSTables exist (all resident by
1183
+ * clause 1) or any memtable holds live edges. A fresh edgeless
1184
+ * brain reports false — the TS wrapper's edgeless-ready +
1185
+ * canonical-coverage logic owns that verdict.
1186
+ *
1187
+ * The verb-id membership bitmap — whose population is the deferred
1188
+ * O(N) fast-follow — is deliberately NOT consulted, so a slow
1189
+ * membership walk can never make a graph with queryable edges report
1190
+ * "not ready".
1180
1191
  */
1181
1192
  isReady(): boolean
1182
1193
  /**
@@ -2752,6 +2763,19 @@ export interface GraphAdjacencyStats {
2752
2763
  verbIdCountBig: bigint
2753
2764
  /** Number of relationship types as a `BigInt`. */
2754
2765
  relationshipTypeCountBig: bigint
2766
+ /**
2767
+ * Verbs-source tree SSTable count (3.0.13 — the readiness fallback
2768
+ * was structurally blind to the verbs trees before these fields).
2769
+ */
2770
+ verbsSourceSstableCount: number
2771
+ /** Verbs-target tree SSTable count. */
2772
+ verbsTargetSstableCount: number
2773
+ /**
2774
+ * Whether every manifest-named SSTable across all four trees is
2775
+ * actually resident. False means the graph is serving (at most) a
2776
+ * subset of its durable edges and `isReady()` reports not-ready.
2777
+ */
2778
+ loadComplete: boolean
2755
2779
  }
2756
2780
 
2757
2781
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulcraft/cor",
3
- "version": "3.0.12",
3
+ "version": "3.0.14",
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.2.5",
85
+ "@soulcraft/brainy": "8.2.6",
86
86
  "@types/node": "^22.0.0",
87
87
  "pg": "^8.21.0",
88
88
  "tsx": "^4.21.0",