@soulcraft/cor 3.0.10 → 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.
|
@@ -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.
|
|
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",
|