@soulcraft/cor 3.0.9 → 3.0.11

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.
@@ -127,6 +127,24 @@ const FLUSH_FAILURE_BACKPRESSURE_THRESHOLD = 4;
127
127
  * to this ceiling. A successful consolidation resets to zero.
128
128
  */
129
129
  const CONSOLIDATION_BACKOFF_MAX_MS = 5 * 60_000;
130
+ /**
131
+ * Live-L0-segment count that triggers a loud warning: consolidation folds at
132
+ * {@link L0_COMPACT_THRESHOLD}, so accumulating this many segments means it
133
+ * has been failing for many cycles. Every query fans out across all live
134
+ * segments plus the base — memory-vm's incident reached 162 segments and
135
+ * degraded reads to 25–40 s. The warn is the early alarm; the hard cap below
136
+ * is the brake.
137
+ */
138
+ const L0_SEGMENT_WARN_THRESHOLD = 32;
139
+ /**
140
+ * Live-L0-segment count at which `addItem` REFUSES writes loudly
141
+ * (CORTEX-CONSOLIDATION-SILENT-DROP hardening). Segments grow past the warn
142
+ * threshold only while consolidation persistently fails; each one adds query
143
+ * fan-out and RAM until reads collapse and the process OOMs. Same posture as
144
+ * the flush-failure brake: a loud bounded stop beats an unbounded quiet
145
+ * degradation. Clears itself the moment one consolidation folds the segments.
146
+ */
147
+ const L0_SEGMENT_HARD_CAP = 256;
130
148
  /**
131
149
  * Storage key of the vector segment manifest (flat key — slashed keys trip
132
150
  * the storage adapters' unknown-key warning; same lesson as the metadata
@@ -434,6 +452,18 @@ export class NativeDiskAnnWrapper {
434
452
  * `rebuild()` call, which folds the delta into the main index.
435
453
  */
436
454
  async addItem(item) {
455
+ // Segment brake (CORTEX-CONSOLIDATION-SILENT-DROP): segments accumulate
456
+ // past the warn threshold only while consolidation persistently fails —
457
+ // the delta keeps draining to NEW segments (flush works), so the flush-
458
+ // failure brake never engages, yet fan-out and RAM grow without bound
459
+ // (162 segments degraded memory-vm's reads to 25–40 s en route to OOM).
460
+ // Refuse loudly at the cap; one successful consolidation clears it.
461
+ if (this.l0Segments.length >= L0_SEGMENT_HARD_CAP) {
462
+ throw new Error(`NativeDiskAnnWrapper: ${this.l0Segments.length} live L0 segments — ` +
463
+ `consolidation is persistently failing; refusing writes to prevent ` +
464
+ `unbounded segment growth (read collapse / OOM). See the ` +
465
+ `consolidation-failure log lines for the underlying cause.`);
466
+ }
437
467
  // item.vector.length is the authoritative width for THIS write; fall back to
438
468
  // it when the brain is zero-config (config.dimensions undefined). Otherwise
439
469
  // bytesPerVector is NaN and every `NaN >= cap` backpressure/fold check is
@@ -1523,13 +1553,33 @@ export class NativeDiskAnnWrapper {
1523
1553
  }
1524
1554
  prodLog.info(`NativeDiskAnnWrapper: flushed ${builtDelta.size} delta entries to L0 segment ${file} ` +
1525
1555
  `(${this.l0Segments.length} live segments)`);
1556
+ // Integrity probe (CORTEX-CONSOLIDATION-SILENT-DROP hardening): one O(1)
1557
+ // stat per flush catches a base deleted out from under the live handle in
1558
+ // SECONDS instead of at the next consolidation — the strand ran for hours
1559
+ // undetected on memory-vm. Detection here; the heal is the consolidation
1560
+ // below (runRebuild's self-heal drops the stale handle and rebuilds from
1561
+ // segments + canonical).
1562
+ const baseMissing = this.native !== null &&
1563
+ this.config.indexPath !== undefined &&
1564
+ !existsSync(this.config.indexPath);
1565
+ if (baseMissing) {
1566
+ prodLog.error(`NativeDiskAnnWrapper: durable base index missing under a live handle ` +
1567
+ `(${this.config.indexPath}) — triggering self-heal consolidation`);
1568
+ }
1569
+ if (this.l0Segments.length >= L0_SEGMENT_WARN_THRESHOLD) {
1570
+ prodLog.error(`NativeDiskAnnWrapper: ${this.l0Segments.length} live L0 segments ` +
1571
+ `(consolidation folds at ${L0_COMPACT_THRESHOLD}) — consolidation is ` +
1572
+ `failing; query fan-out is degrading and the write brake engages at ` +
1573
+ `${L0_SEGMENT_HARD_CAP}`);
1574
+ }
1526
1575
  // Tiered maintenance: enough small segments → one BACKGROUND consolidation
1527
1576
  // (the async rebuild folds base + L0s + delta into a fresh base off-thread).
1528
- // Guarded by an exponential backoff: a persistently-failing consolidation
1529
- // must not re-fire on every ~6 s flush (memory-vm hammered it for hours) —
1530
- // it backs off up to CONSOLIDATION_BACKOFF_MAX_MS, and a success resets it
1531
- // (see the tail of runRebuild). CORTEX-CONSOLIDATION-SILENT-DROP.
1532
- if (this.l0Segments.length >= L0_COMPACT_THRESHOLD &&
1577
+ // A missing base skips the segment-count threshold — heal now, not three
1578
+ // flushes from now. Guarded by an exponential backoff: a persistently-
1579
+ // failing consolidation must not re-fire on every ~6 s flush (memory-vm
1580
+ // hammered it for hours) — it backs off up to CONSOLIDATION_BACKOFF_MAX_MS,
1581
+ // and a success resets it (see the tail of runRebuild).
1582
+ if ((baseMissing || this.l0Segments.length >= L0_COMPACT_THRESHOLD) &&
1533
1583
  !this.rebuildInFlight &&
1534
1584
  Date.now() >= this.consolidationCooldownUntil) {
1535
1585
  this.rebuild().catch((error) => {
@@ -103,6 +103,18 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
103
103
  * cold-open rebuild gate never skips over an index that has not loaded yet.
104
104
  */
105
105
  private readinessInitialized;
106
+ /**
107
+ * A derived-state strand was detected at cold open (CORTEX-CONSOLIDATION-
108
+ * SILENT-DROP, memory-vm 2026-07-12): postings lost while canonical is
109
+ * intact — e.g. memtable log shards deleted out from under the engine (15
110
+ * of 64 on the incident brain), whose histories silently vanish because a
111
+ * missing shard log is recreated empty on next touch. Set by
112
+ * {@link detectDerivedStrand} (archive-orphan census + the
113
+ * |canonical| == |posted| invariant); forces {@link isReady} to report
114
+ * `false` so brainy's cold-open gate runs the one rebuild that re-posts
115
+ * everything from canonical. Cleared by a completed {@link rebuild}.
116
+ */
117
+ private strandDetected;
106
118
  /**
107
119
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
108
120
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -518,6 +530,31 @@ export declare class MetadataIndexManager implements MetadataIndexProvider {
518
530
  * @returns `true` when a cold-open rebuild would be redundant.
519
531
  */
520
532
  isReady(): boolean;
533
+ /**
534
+ * Detect a derived-state STRAND at cold open: canonical is the source of
535
+ * truth, so any loss in the derived posting state — however it happened —
536
+ * surfaces as one of two signals checked here. Both are cheap (a directory
537
+ * listing + one bitmap-cardinality read + one canonical count probe), run
538
+ * once per open, never on a hot path. Advisory on storage adapters that
539
+ * can't answer (no blob paths / no exact counts): detection skips rather
540
+ * than false-positives. CORTEX-CONSOLIDATION-SILENT-DROP.
541
+ *
542
+ * 1. **Archive-orphan census.** The memtable log rotates
543
+ * `memtable-shard-NN.log` → `*.archive` siblings and always recreates
544
+ * the live file — so an archive WITHOUT its live log proves the live
545
+ * log was deleted (the deleter's signature on memory-vm: 15 of 64
546
+ * shards). A missing shard log silently replays nothing and is
547
+ * recreated empty on next touch; without this census the loss is
548
+ * invisible.
549
+ * 2. **The |canonical| == |posted| invariant.** Every live entity carries
550
+ * exactly one `noun` posting, so the engine's total posted-entity count
551
+ * must equal canonical's noun count. `posted < canonical` = stranded
552
+ * entities (the incident's ~35 unqueryable writes); `posted > canonical`
553
+ * = orphaned postings (egress-guarded at read time, reconciled by the
554
+ * same rebuild). Skipped at/above the u32 saturation point of
555
+ * `getTotalEntityCount` (a saturated count would make equality lie).
556
+ */
557
+ private detectDerivedStrand;
521
558
  /**
522
559
  * **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
523
560
  * brain's `_id_mapper/*` storage paths and inject it into the Rust
@@ -15,7 +15,7 @@
15
15
  * Buffer exchange pattern: TS loads data from storage, passes JSON to Rust.
16
16
  * Rust operates in-memory, returns serialized state for TS to persist.
17
17
  */
18
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { prodLog, getGlobalCache, FieldTypeInference, resolveEntityField } from '@soulcraft/brainy/internals';
21
21
  import { compareCodePoints } from './collation.js';
@@ -177,6 +177,18 @@ export class MetadataIndexManager {
177
177
  * cold-open rebuild gate never skips over an index that has not loaded yet.
178
178
  */
179
179
  readinessInitialized = false;
180
+ /**
181
+ * A derived-state strand was detected at cold open (CORTEX-CONSOLIDATION-
182
+ * SILENT-DROP, memory-vm 2026-07-12): postings lost while canonical is
183
+ * intact — e.g. memtable log shards deleted out from under the engine (15
184
+ * of 64 on the incident brain), whose histories silently vanish because a
185
+ * missing shard log is recreated empty on next touch. Set by
186
+ * {@link detectDerivedStrand} (archive-orphan census + the
187
+ * |canonical| == |posted| invariant); forces {@link isReady} to report
188
+ * `false` so brainy's cold-open gate runs the one rebuild that re-posts
189
+ * everything from canonical. Cleared by a completed {@link rebuild}.
190
+ */
191
+ strandDetected = false;
180
192
  /**
181
193
  * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
182
194
  * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
@@ -1101,6 +1113,13 @@ export class MetadataIndexManager {
1101
1113
  await this.warmCache();
1102
1114
  await this.lazyLoadCounts();
1103
1115
  this.syncTypeCountsToFixed();
1116
+ // Strand detection (CORTEX-CONSOLIDATION-SILENT-DROP): the LSM can load
1117
+ // "durable state" that is silently MISSING postings — deleted shard logs
1118
+ // replay nothing and are recreated empty on next touch, so the index
1119
+ // serves healthy-while-broken. Detect it here, before the readiness
1120
+ // contract reports ready, so brainy's gate heals via rebuild-from-
1121
+ // canonical instead of trusting a partial index.
1122
+ await this.detectDerivedStrand();
1104
1123
  // **#18 migration kick — LAST, after the index is fully constructed.** If the
1105
1124
  // epoch guard flagged a large stale brain (outcome 4), start the coordinated
1106
1125
  // blocking migration NOW: `start()` flips the lock (`isMigrating()` → true)
@@ -1145,8 +1164,110 @@ export class MetadataIndexManager {
1145
1164
  return false;
1146
1165
  if (this.knownFields.size > 0 && !this.lsmHasDurableState())
1147
1166
  return false;
1167
+ // A detected strand (postings lost while canonical is intact) means a
1168
+ // rebuild is NOT redundant — see {@link detectDerivedStrand}.
1169
+ if (this.strandDetected)
1170
+ return false;
1148
1171
  return true;
1149
1172
  }
1173
+ /**
1174
+ * Detect a derived-state STRAND at cold open: canonical is the source of
1175
+ * truth, so any loss in the derived posting state — however it happened —
1176
+ * surfaces as one of two signals checked here. Both are cheap (a directory
1177
+ * listing + one bitmap-cardinality read + one canonical count probe), run
1178
+ * once per open, never on a hot path. Advisory on storage adapters that
1179
+ * can't answer (no blob paths / no exact counts): detection skips rather
1180
+ * than false-positives. CORTEX-CONSOLIDATION-SILENT-DROP.
1181
+ *
1182
+ * 1. **Archive-orphan census.** The memtable log rotates
1183
+ * `memtable-shard-NN.log` → `*.archive` siblings and always recreates
1184
+ * the live file — so an archive WITHOUT its live log proves the live
1185
+ * log was deleted (the deleter's signature on memory-vm: 15 of 64
1186
+ * shards). A missing shard log silently replays nothing and is
1187
+ * recreated empty on next touch; without this census the loss is
1188
+ * invisible.
1189
+ * 2. **The |canonical| == |posted| invariant.** Every live entity carries
1190
+ * exactly one `noun` posting, so the engine's total posted-entity count
1191
+ * must equal canonical's noun count. `posted < canonical` = stranded
1192
+ * entities (the incident's ~35 unqueryable writes); `posted > canonical`
1193
+ * = orphaned postings (egress-guarded at read time, reconciled by the
1194
+ * same rebuild). Skipped at/above the u32 saturation point of
1195
+ * `getTotalEntityCount` (a saturated count would make equality lie).
1196
+ */
1197
+ async detectDerivedStrand() {
1198
+ if (!this.lsmEngine || !this.lsmMetaDir)
1199
+ return;
1200
+ // 1. Archive-orphan census over the memtable shard logs.
1201
+ try {
1202
+ const memtableDir = join(this.lsmMetaDir, 'memtable');
1203
+ if (existsSync(memtableDir)) {
1204
+ const files = readdirSync(memtableDir);
1205
+ const live = new Set(files.filter((f) => /^memtable-shard-\d{2}\.log$/.test(f)));
1206
+ const orphaned = [];
1207
+ for (const f of files) {
1208
+ const m = /^(memtable-shard-\d{2}\.log)\..+\.archive$/.exec(f);
1209
+ if (m && !live.has(m[1]) && !orphaned.includes(m[1])) {
1210
+ orphaned.push(m[1]);
1211
+ }
1212
+ }
1213
+ if (orphaned.length > 0) {
1214
+ this.strandDetected = true;
1215
+ prodLog.error(`[NativeMetadataIndex] STRAND: ${orphaned.length} memtable shard ` +
1216
+ `log(s) have archive rotations but the live log is MISSING ` +
1217
+ `(${orphaned.join(', ')}) — the live logs were deleted and their ` +
1218
+ `posting history is gone. Reporting not-ready so the cold-open ` +
1219
+ `gate rebuilds from canonical.`);
1220
+ }
1221
+ }
1222
+ }
1223
+ catch {
1224
+ // Census is advisory — an unreadable dir must not block open.
1225
+ }
1226
+ // 2. |canonical| == |posted| invariant.
1227
+ try {
1228
+ const getNouns = this.storage.getNouns;
1229
+ if (typeof getNouns === 'function') {
1230
+ const probe = await getNouns.call(this.storage, { pagination: { limit: 1 } });
1231
+ const canonical = probe?.totalCount;
1232
+ if (typeof canonical === 'number' &&
1233
+ Number.isFinite(canonical) &&
1234
+ canonical < 0xffff_ffff) {
1235
+ // Posted count = Σ per-type snapshot cardinalities over brainy's
1236
+ // CLOSED 42-noun vocabulary (wire-stable, validated at the storage
1237
+ // boundary — no type outside it can exist in canonical).
1238
+ // getEntityCountByType reads the LSM SNAPSHOT (memtable + SSTables),
1239
+ // so it is reopen-true; getTotalEntityCount is NOT (it counts the
1240
+ // in-process field registry, which is empty at cold open). O(42)
1241
+ // bitmap-cardinality reads, no id materialization — scale-safe.
1242
+ // Raw (unmapped) cardinality is acceptable here: at cold open there
1243
+ // is no mapper-eviction churn to inflate it.
1244
+ const { NounType } = await import('@soulcraft/brainy');
1245
+ let posted = 0;
1246
+ for (const t of Object.values(NounType)) {
1247
+ posted += this.native.getEntityCountByType(t);
1248
+ }
1249
+ // LOWER-BOUND invariant, not equality: the adapter's totalCount
1250
+ // EXCLUDES system-routed entities (e.g. the VFS root) while the
1251
+ // index posts them, so healthy brains sit at posted ≥ canonical.
1252
+ // posted < canonical therefore proves lost postings. A strand
1253
+ // smaller than the (1–2 entity) system skew is masked here and
1254
+ // falls to the archive census above; the exact invariant (a
1255
+ // persisted posted-count stamp) is CORTEX-BILLION-SCALE-SPINE work.
1256
+ if (posted < canonical) {
1257
+ this.strandDetected = true;
1258
+ prodLog.error(`[NativeMetadataIndex] STRAND: canonical holds ${canonical} ` +
1259
+ `entities but the index has postings for only ${posted} — ` +
1260
+ `${canonical - posted}+ entities are durable in canonical but ` +
1261
+ `UNQUERYABLE (lost postings). Reporting not-ready so the ` +
1262
+ `cold-open gate rebuilds from canonical.`);
1263
+ }
1264
+ }
1265
+ }
1266
+ }
1267
+ catch {
1268
+ // Invariant probe is advisory — a failed canonical read must not block open.
1269
+ }
1270
+ }
1150
1271
  // ==========================================================================
1151
1272
  // Storage I/O helpers
1152
1273
  // ==========================================================================
@@ -2469,6 +2590,10 @@ export class MetadataIndexManager {
2469
2590
  await this.flushRebuildDirty();
2470
2591
  await this.flush();
2471
2592
  prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
2593
+ // A completed rebuild re-posted every canonical entity into a freshly
2594
+ // re-opened engine (reopenLsmEngineFresh wiped the stale shard set) —
2595
+ // any cold-open strand verdict is now satisfied.
2596
+ this.strandDetected = false;
2472
2597
  }
2473
2598
  finally {
2474
2599
  this.isRebuilding = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulcraft/cor",
3
- "version": "3.0.9",
3
+ "version": "3.0.11",
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",