@soulcraft/cor 3.0.0-rc.1

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.
Files changed (87) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +328 -0
  3. package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
  4. package/dist/aggregation/NativeAggregationEngine.js +186 -0
  5. package/dist/cli.d.ts +12 -0
  6. package/dist/cli.js +236 -0
  7. package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
  8. package/dist/graph/GraphAccelerationAdapter.js +326 -0
  9. package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
  10. package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
  11. package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
  12. package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
  13. package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
  14. package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
  15. package/dist/index.d.ts +28 -0
  16. package/dist/index.js +29 -0
  17. package/dist/legacyLayoutGuard.d.ts +93 -0
  18. package/dist/legacyLayoutGuard.js +237 -0
  19. package/dist/license/constants.d.ts +30 -0
  20. package/dist/license/constants.js +30 -0
  21. package/dist/license/onlineValidator.d.ts +43 -0
  22. package/dist/license/onlineValidator.js +225 -0
  23. package/dist/license/types.d.ts +39 -0
  24. package/dist/license/types.js +5 -0
  25. package/dist/license.d.ts +64 -0
  26. package/dist/license.js +165 -0
  27. package/dist/migration/MigrationCoordinator.d.ts +171 -0
  28. package/dist/migration/MigrationCoordinator.js +249 -0
  29. package/dist/migration/indexEpochGuard.d.ts +163 -0
  30. package/dist/migration/indexEpochGuard.js +202 -0
  31. package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
  32. package/dist/native/NativeEmbeddingEngine.js +318 -0
  33. package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
  34. package/dist/native/NativeRoaringBitmap32.js +221 -0
  35. package/dist/native/ffi.d.ts +20 -0
  36. package/dist/native/ffi.js +48 -0
  37. package/dist/native/idMapperTestSupport.d.ts +67 -0
  38. package/dist/native/idMapperTestSupport.js +112 -0
  39. package/dist/native/index.d.ts +49 -0
  40. package/dist/native/index.js +87 -0
  41. package/dist/native/napi.d.ts +21 -0
  42. package/dist/native/napi.js +88 -0
  43. package/dist/native/types.d.ts +1298 -0
  44. package/dist/native/types.js +16 -0
  45. package/dist/plugin.d.ts +50 -0
  46. package/dist/plugin.js +388 -0
  47. package/dist/providerContracts.d.ts +277 -0
  48. package/dist/providerContracts.js +38 -0
  49. package/dist/resource/OsMemoryProbe.d.ts +175 -0
  50. package/dist/resource/OsMemoryProbe.js +206 -0
  51. package/dist/resource/ResourceManager.d.ts +491 -0
  52. package/dist/resource/ResourceManager.js +960 -0
  53. package/dist/utils/ColumnManifest.d.ts +97 -0
  54. package/dist/utils/ColumnManifest.js +129 -0
  55. package/dist/utils/NativeColumnStore.d.ts +284 -0
  56. package/dist/utils/NativeColumnStore.js +685 -0
  57. package/dist/utils/NativeMetadataIndex.d.ts +882 -0
  58. package/dist/utils/NativeMetadataIndex.js +2631 -0
  59. package/dist/utils/NativeUnifiedCache.d.ts +87 -0
  60. package/dist/utils/NativeUnifiedCache.js +273 -0
  61. package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
  62. package/dist/utils/binaryIdMapperFactory.js +94 -0
  63. package/dist/utils/collation.d.ts +30 -0
  64. package/dist/utils/collation.js +40 -0
  65. package/dist/utils/columnStoreTypes.d.ts +161 -0
  66. package/dist/utils/columnStoreTypes.js +29 -0
  67. package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
  68. package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
  69. package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
  70. package/dist/utils/nativeEntityIdMapper.js +170 -0
  71. package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
  72. package/docs/ADR-003-semantic-time-travel.md +100 -0
  73. package/docs/aggregation.md +251 -0
  74. package/docs/comparison.md +162 -0
  75. package/docs/deployment-limits.md +87 -0
  76. package/docs/diskann.md +184 -0
  77. package/docs/migration-3.0.md +396 -0
  78. package/docs/performance-budget.md +274 -0
  79. package/docs/performance.md +117 -0
  80. package/docs/scaling.md +439 -0
  81. package/docs/snapshot-safety.md +246 -0
  82. package/docs/u64-id-space.md +214 -0
  83. package/docs/verification-report.md +670 -0
  84. package/native/brainy-native.node +0 -0
  85. package/native/index.d.ts +3916 -0
  86. package/package.json +92 -0
  87. package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
@@ -0,0 +1,2631 @@
1
+ /**
2
+ * NativeMetadataIndex — TypeScript wrapper around the Rust NativeMetadataIndex.
3
+ *
4
+ * Implements the same public API as the old MetadataIndexManager (3,721 lines),
5
+ * delegating core operations (query, mutation, normalization) to Rust.
6
+ *
7
+ * Architecture:
8
+ * - Rust owns: posting-list algebra, filter evaluation, value normalization,
9
+ * field extraction, text search, entity ID mapping, and the LSM engine
10
+ * (the sole metadata storage engine — SSTables + manifest)
11
+ * - TS owns: async storage I/O, LSM SSTable/manifest persistence, lazy
12
+ * value-enumeration field-index loading, rebuild orchestration, flush,
13
+ * getSortedIdsForFilter (loads sort values from storage)
14
+ *
15
+ * Buffer exchange pattern: TS loads data from storage, passes JSON to Rust.
16
+ * Rust operates in-memory, returns serialized state for TS to persist.
17
+ */
18
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { prodLog, getGlobalCache, FieldTypeInference, resolveEntityField } from '@soulcraft/brainy/internals';
21
+ import { compareCodePoints } from './collation.js';
22
+ /**
23
+ * Fields whose values are stored in the sparse index as BUCKETED values
24
+ * (rounded to a coarser granularity to keep the index compact). Sorting
25
+ * on these fields must bypass the index and read the actual value from
26
+ * entity storage to avoid precision loss.
27
+ *
28
+ * Mirrors BUCKETED_INDEX_FIELDS in brainy's src/utils/metadataIndex.ts —
29
+ * keep in lockstep when Brainy adds bucketed fields.
30
+ */
31
+ const BUCKETED_INDEX_FIELDS = new Set([
32
+ 'createdAt',
33
+ 'updatedAt'
34
+ ]);
35
+ import { TypeUtils, NOUN_TYPE_COUNT, VERB_TYPE_COUNT } from '@soulcraft/brainy/types/graphTypes';
36
+ import { loadNativeModule } from '../native/index.js';
37
+ import { openOrCreateBinaryIdMapper } from './binaryIdMapperFactory.js';
38
+ import { NativeColumnStore } from './NativeColumnStore.js';
39
+ import { ResourceManager } from '../resource/ResourceManager.js';
40
+ import { COR_EXPECTED_INDEX_EPOCH, IndexMigrationRequiredError, detectBrainSize, isEpochStale, readOnDiskBrainFormat, resolveAutoMigrate, resolveLargeBrainThreshold, } from '../migration/indexEpochGuard.js';
41
+ /**
42
+ * Process-wide counter for unique `MetadataIndexManager` subscriber
43
+ * names. Each LSM-enabled instance reserves a fresh id so the
44
+ * `ResourceManager.registerMemoryBudgetSubscriber` slot doesn't collide
45
+ * across instances in the same process (e.g., multi-tenant deployments
46
+ * with one brain per workspace).
47
+ */
48
+ let lsmSubscriberIdCounter = 0;
49
+ function nextLsmSubscriberId() {
50
+ lsmSubscriberIdCounter += 1;
51
+ return `cor:metadata-lsm:${lsmSubscriberIdCounter}`;
52
+ }
53
+ /**
54
+ * Manifest format version this build writes and accepts. Mirrors the Rust
55
+ * `LSM_MANIFEST_VERSION` constant (native/src/metadata_index/lsm/format.rs).
56
+ * The wrapper stamps every manifest it writes with this value and refuses
57
+ * to cold-load a manifest carrying a different one — a forward-incompatible
58
+ * manifest must fail loud, never be half-read into a partial SSTable set.
59
+ */
60
+ const LSM_MANIFEST_VERSION = 2;
61
+ /**
62
+ * Storage key (flat `__metadata_*` system key, same family as
63
+ * `__metadata_field_registry__`) under which the LSM manifest JSON is
64
+ * persisted via `storage.saveMetadata` / `storage.getMetadata`.
65
+ *
66
+ * @remarks The original durability plan named this `_metadata/manifest.json`,
67
+ * but a slashed, non-UUID key routes through brainy's storage adapter as an
68
+ * "Unknown key format" system resource (noisy + fragile). The established
69
+ * clean pattern for index-level metadata is the flat `__metadata_*` key, so
70
+ * the manifest rides the exact same atomic `writeCanonicalObject` path the
71
+ * field registry already depends on.
72
+ */
73
+ const LSM_MANIFEST_KEY = '__metadata_lsm_manifest__';
74
+ /**
75
+ * **LSM durability (step 5).** Lower clamp (32 MiB) for the bounded-memtable
76
+ * size-flush trigger. Even when the dynamic 'metadata' budget is tiny, the
77
+ * trigger never fires below this — sub-32 MiB SSTables would amplify
78
+ * read-merge fan-out (every query unions across more on-disk files) without a
79
+ * matching memory win.
80
+ */
81
+ const LSM_FLUSH_THRESHOLD_MIN_BYTES = 32 * 1024 * 1024;
82
+ /**
83
+ * **LSM durability (step 5).** Upper clamp (256 MiB) for the bounded-memtable
84
+ * size-flush trigger. Caps a single flush's resident peak (and the resulting
85
+ * SSTable's build cost) even when the dynamic 'metadata' budget is many GiB,
86
+ * keeping flush latency bounded at billion-entity scale.
87
+ */
88
+ const LSM_FLUSH_THRESHOLD_MAX_BYTES = 256 * 1024 * 1024;
89
+ /**
90
+ * **LSM durability (step 5).** Resolve the memtable size-flush threshold from
91
+ * the engine's current admission ceiling (`allowedBytes`): ~50% of the
92
+ * ceiling, clamped to `[32 MiB, 256 MiB]`. Draining at half the ceiling
93
+ * leaves the upper half as headroom so a burst of writes between the trigger
94
+ * firing and the flush completing cannot reach the admission gate and wedge
95
+ * the writer — the gate stays a backstop, never the steady-state limiter.
96
+ *
97
+ * @param allowedBytes - The engine's current admission ceiling in bytes.
98
+ * @returns The clamped memtable-drain threshold in bytes.
99
+ */
100
+ function computeLsmFlushThreshold(allowedBytes) {
101
+ const half = Math.floor(Math.max(0, allowedBytes) / 2);
102
+ return Math.min(LSM_FLUSH_THRESHOLD_MAX_BYTES, Math.max(LSM_FLUSH_THRESHOLD_MIN_BYTES, half));
103
+ }
104
+ /**
105
+ * Build a fresh, empty manifest ready to receive its first SSTable.
106
+ *
107
+ * @returns A version-stamped manifest with no SSTables and a zero durable epoch.
108
+ */
109
+ function newEmptyLsmManifest() {
110
+ return { version: LSM_MANIFEST_VERSION, sstables: [], last_durable_epoch: 0 };
111
+ }
112
+ /**
113
+ * Allocate the next SSTable id: one past the highest id currently in the
114
+ * manifest. SSTables are append-only in this durability slice (compaction —
115
+ * which removes entries — is a later slice), so `max + 1` is strictly
116
+ * monotonic and can never collide with a live entry. It is also crash-safe:
117
+ * if a flush writes the `.sst` but crashes before persisting the manifest,
118
+ * the orphaned id is simply re-allocated on the next flush (the records
119
+ * replay from the still-uncommitted memtable log), overwriting a file no
120
+ * manifest ever referenced.
121
+ *
122
+ * @param manifest - The in-memory manifest whose ids bound the allocation.
123
+ * @returns The next monotonic SSTable id (≥ 1).
124
+ */
125
+ function nextSstableId(manifest) {
126
+ let max = 0;
127
+ for (const e of manifest.sstables) {
128
+ if (e.id > max)
129
+ max = e.id;
130
+ }
131
+ return max + 1;
132
+ }
133
+ /**
134
+ * Build the binary-blob storage key for an SSTable. Both the write
135
+ * (`saveBinaryBlob`) and the mmap-open (`getBinaryBlobPath` →
136
+ * `registerSstablePath`) go through this single helper, so the logical key is
137
+ * internally consistent regardless of any extension the adapter appends.
138
+ *
139
+ * @param id - The SSTable's monotonic on-disk id.
140
+ * @returns Key like `_metadata/sstables/1.sst`.
141
+ */
142
+ function lsmSstableKey(id) {
143
+ return `_metadata/sstables/${id}.sst`;
144
+ }
145
+ /**
146
+ * MetadataIndexManager — native Rust implementation with TS storage bridge.
147
+ *
148
+ * Drop-in replacement for the old pure-TS MetadataIndexManager.
149
+ * All bitmap AND/OR/NOT operations execute in Rust without crossing FFI.
150
+ *
151
+ * Implements {@link MetadataIndexProvider} so a compile error fires the moment
152
+ * this wrapper drops or drifts a member brainy calls via `this.metadataIndex.*`.
153
+ */
154
+ /**
155
+ * **Piece 11 closeout — init-time gate.** Thrown by
156
+ * {@link MetadataIndexManager.init} when the brain carries cortex 2.x legacy
157
+ * JSON metadata state on disk. cor 3.0 uses the LSM engine exclusively (the
158
+ * legacy JSON chunk engine has been removed), so an un-migrated brain must be
159
+ * migrated rather than silently switched. Catchable for graceful fallback
160
+ * paths in callers that prefer a one-shot warning over a hard refusal; the
161
+ * default behaviour (uncaught) is the right one for production deployments —
162
+ * silent degradation to an empty LSM engine would mask the on-disk JSON state
163
+ * and surface as a mysteriously-empty brain at first query.
164
+ */
165
+ export class MetadataIndexLegacyJsonStateDetected extends Error {
166
+ name = 'MetadataIndexLegacyJsonStateDetected';
167
+ }
168
+ export class MetadataIndexManager {
169
+ migrationCoordinatorGetter;
170
+ storage;
171
+ config;
172
+ native;
173
+ isRebuilding = false;
174
+ /**
175
+ * **#18** — set true by {@link guardIndexEpoch} outcome 4 (large stale brain,
176
+ * auto-migrate enabled) and read once at the END of {@link init}, which kicks the
177
+ * coordinated migration only AFTER the index is fully constructed. Flagging here
178
+ * (not kicking inline) avoids racing the migration's destructive rebuild
179
+ * (`reopenLsmEngineFresh` + `columnStore.reset`) against the remainder of the same
180
+ * init() (`columnStore.init` / `warmCache` / `lazyLoadCounts`) on shared state.
181
+ */
182
+ epochMigrationPending = false;
183
+ lastFlushTime = Date.now();
184
+ autoFlushThreshold = 10;
185
+ dirtyFields = new Set();
186
+ // Lazy field loading: tracks which fields have been loaded into Rust
187
+ loadedFields = new Set();
188
+ // Tracks which field indexes are known (from field registry)
189
+ knownFields = new Set();
190
+ /**
191
+ * **Piece 6 generation counter — reader-side cache.** Records the
192
+ * generation observed at the most recent `invalidateIfStale` call.
193
+ * Mutations on the native side bump `core.generation` via
194
+ * `bump_generation`; a subsequent reader's `invalidateIfStale` call
195
+ * sees a delta and clears `loadedFields`, forcing the next query to
196
+ * re-load from the current state instead of trusting the lazy-load
197
+ * snapshot.
198
+ *
199
+ * Default `-1n` means "never observed"; the first call will see any
200
+ * non-zero current generation and trigger an initial invalidate.
201
+ * (We don't invalidate on the very first call regardless because
202
+ * `loadedFields` is empty anyway.)
203
+ */
204
+ lastSeenGeneration = -1n;
205
+ /**
206
+ * **Piece 15 — per-field generation cache.** Keyed by field name,
207
+ * value is the per-field generation last observed via
208
+ * `native.fieldGeneration(field)`. Updated on every
209
+ * {@link invalidateFieldIfStale} call.
210
+ *
211
+ * Selective invalidation contract: when a probe of field X's
212
+ * counter advances, ONLY X is removed from `loadedFields` — every
213
+ * other loaded field stays valid. This is the load-bearing
214
+ * difference vs the index-wide {@link lastSeenGeneration} +
215
+ * {@link invalidateIfStale} path, which clears the entire
216
+ * `loadedFields` Set on any mutation.
217
+ *
218
+ * Cold-boot: an empty Map. The first probe of any field records
219
+ * its current native counter without invalidating (matches the
220
+ * `lastSeenGeneration: -1n` semantic — never-observed implies
221
+ * loadedFields is empty anyway).
222
+ */
223
+ lastSeenFieldGenerations = new Map();
224
+ // TS-only analytics (not in Rust)
225
+ fieldStats = new Map();
226
+ typeFieldAffinity = new Map();
227
+ totalEntitiesByType = new Map();
228
+ entityCountsByTypeFixed = new Uint32Array(NOUN_TYPE_COUNT);
229
+ verbCountsByTypeFixed = new Uint32Array(VERB_TYPE_COUNT);
230
+ // Unified cache for coordinated memory management
231
+ unifiedCache;
232
+ // Field Type Inference
233
+ fieldTypeInference;
234
+ /**
235
+ * **The native LSM engine (cor 3.0 — the sole metadata engine).** Opened
236
+ * in {@link init} (persistent + mmap-backed on filesystem storage, or a
237
+ * non-persistent in-RAM engine on non-filesystem adapters) and attached to
238
+ * `this.native` via `setLsmEngine` so the metadata-index dispatch routes
239
+ * through the engine's memtable + admission control. `null` only between
240
+ * construction and {@link openLsmEngine} during `init()`.
241
+ */
242
+ lsmEngine = null;
243
+ /**
244
+ * **LSM durability (steps 3-4).** In-memory mirror of the on-disk LSM
245
+ * manifest. `null` in JSON mode and on the non-persistent fallback engine
246
+ * (where there is no SSTable surface). Set by {@link openLsmEngine} at cold
247
+ * open (parsed from {@link LSM_MANIFEST_KEY}, or a fresh empty manifest) and
248
+ * grown by {@link flushLsm} as each memtable drain produces an SSTable.
249
+ */
250
+ lsmManifest = null;
251
+ /**
252
+ * **LSM durability (steps 3-4).** The resolved on-disk metadata directory
253
+ * (`getBinaryBlobPath('_metadata')`) backing the persistent engine's
254
+ * memtable logs. `null` in JSON mode and on the non-persistent fallback.
255
+ * Its presence is the load-bearing signal that {@link flushLsm} should drain
256
+ * the memtable to a durable SSTable.
257
+ */
258
+ lsmMetaDir = null;
259
+ /**
260
+ * **LSM durability (step 5) — bounded-memtable flush trigger.** The memtable
261
+ * resident-byte ceiling at which {@link addToIndex} proactively drains the
262
+ * memtable to a durable SSTable via {@link flushLsm}, so the memtable never
263
+ * climbs to the admission ceiling in steady state (which would wedge every
264
+ * writer — the failure mode the persistent-open switch would otherwise
265
+ * introduce). Computed by {@link computeLsmFlushThreshold} as ~50% of the
266
+ * engine's dynamic `allowedBytes`, clamped to `[32 MiB, 256 MiB]`, and
267
+ * RECOMPUTED inside the ResourceManager subscriber callback
268
+ * ({@link attachLsmBudgetSubscriber}) so it tracks the live per-category
269
+ * budget. Seeded to the floor until the first budget callback fires.
270
+ */
271
+ lsmFlushThresholdBytes = LSM_FLUSH_THRESHOLD_MIN_BYTES;
272
+ /**
273
+ * Deregistration hook returned by
274
+ * {@link ResourceManager.registerMemoryBudgetSubscriber}. Called from
275
+ * {@link close} so the rebalance loop stops firing the engine's
276
+ * `setAllowedBytes` after this index has shut down. `null` in JSON
277
+ * mode (no subscriber registered).
278
+ */
279
+ unsubscribeFromBudget = null;
280
+ /**
281
+ * Unique subscriber name used for both the
282
+ * `ResourceManager.registerMemoryBudgetSubscriber` call and the
283
+ * corresponding `reportNativeMemory` callback that drives the
284
+ * dynamic per-category rebalance. Each MetadataIndexManager instance
285
+ * gets a fresh name from {@link nextLsmSubscriberId} so multiple
286
+ * brains in one process don't collide on the same subscriber slot.
287
+ */
288
+ lsmSubscriberName = null;
289
+ /**
290
+ * Native column store — per-field sorted columns backing brainy's unfiltered
291
+ * `find({ orderBy })` path. Brainy calls `metadataIndex.columnStore.sortTopK(...)`
292
+ * directly, so this MUST be present (and is why it is public). Cor's metadata
293
+ * index does its own native filtering; the column store's jobs here are populate
294
+ * (on `addToIndex`), persist (on `flush`), and sort (`sortTopK`).
295
+ */
296
+ columnStore;
297
+ /**
298
+ * Lazily built id-mapper façade over the native UUID ↔ int methods, returned by
299
+ * {@link getIdMapper}. Cached so brainy's orderBy path reuses one object.
300
+ */
301
+ idMapperFacade = null;
302
+ /**
303
+ * **#72 Phase C.** The native mmap `BinaryIdMapper` injected into the
304
+ * Rust metadata core as its canonical UUID ↔ entity-int allocator,
305
+ * replacing the resident JSON id-maps (~56 GB @1B + the ~13M-UUID JSON
306
+ * cliff). Built in {@link init} from the brain's `_id_mapper/*` paths and
307
+ * handed to the core via `native.setIdMapper`. Kept here so {@link flush}
308
+ * can msync it and a full {@link rebuild} can truncate + re-inject it.
309
+ * `null` until init wires it (or on non-filesystem storage, where the
310
+ * mapper cannot engage — the metadata index then has no allocator and
311
+ * the brain must run unaccelerated).
312
+ */
313
+ idMapperNative = null;
314
+ /**
315
+ * Set by `getIdMapper().rebuild()` so the immediately-following
316
+ * `rebuild()` (brainy `restore()` runs metadataIndex.rebuild() in a
317
+ * `Promise.all` right after the mapper hook) is a no-op: re-clearing the
318
+ * entity-id mapper there would race the concurrent `graphIndex.rebuild()`
319
+ * that resolves verb endpoints through this same mapper. The hook already
320
+ * did the full, identical rebuild, so the second one is skipped and the
321
+ * mapper stays stable for the graph pass. One-shot: consumed by the next
322
+ * `rebuild()`. (#68)
323
+ */
324
+ skipRebuildOnce = false;
325
+ constructor(storage, config = {},
326
+ /**
327
+ * Lazy resolver to the shared {@link MigrationCoordinator} (the plugin creates
328
+ * one per brain and hands the same getter to all three derived-index providers).
329
+ * Undefined for a raw-napi construction (tests) or a tier that never migrates —
330
+ * then {@link isMigrating} is always `false` and the epoch guard's large-brain
331
+ * outcome falls through to Brainy's own path.
332
+ */
333
+ migrationCoordinatorGetter) {
334
+ this.migrationCoordinatorGetter = migrationCoordinatorGetter;
335
+ this.storage = storage;
336
+ this.config = {
337
+ maxIndexSize: config.maxIndexSize ?? 10000,
338
+ rebuildThreshold: config.rebuildThreshold ?? 0.1,
339
+ autoOptimize: config.autoOptimize ?? true,
340
+ indexedFields: config.indexedFields ?? [],
341
+ excludeFields: config.excludeFields ?? [
342
+ 'embedding', 'vector', 'embeddings', 'vectors',
343
+ 'content', 'data', 'originalData', '_data',
344
+ 'id'
345
+ ],
346
+ };
347
+ // Build Rust config JSON
348
+ const nativeConfig = {};
349
+ if (this.config.excludeFields.length > 0) {
350
+ nativeConfig.excludeFields = this.config.excludeFields;
351
+ }
352
+ if (this.config.indexedFields.length > 0) {
353
+ nativeConfig.indexedFields = this.config.indexedFields;
354
+ }
355
+ const bindings = loadNativeModule();
356
+ // Cor 3.0 zero-config: pass 'u64' explicitly so the
357
+ // wrapper's LSM engine (also constructed with idSpace='u64'
358
+ // below) and the metadata index core agree. The raw napi
359
+ // constructor still defaults to u32 for back-compat with
360
+ // direct napi callers + internal tests.
361
+ this.native = new bindings.NativeMetadataIndex(Object.keys(nativeConfig).length > 0 ? JSON.stringify(nativeConfig) : null, 'u64');
362
+ this.unifiedCache = getGlobalCache();
363
+ this.fieldTypeInference = new FieldTypeInference(storage);
364
+ this.columnStore = new NativeColumnStore();
365
+ // LSM durability (steps 3-4): engine construction MOVED to init().
366
+ // The persistent engine is opened via `NativeLsmEngine.openWithDir`,
367
+ // which must be seeded from the on-disk manifest (an async storage
368
+ // read) so its epoch counter outranks every already-flushed SSTable.
369
+ // The constructor therefore does no LSM work — `init()` opens the
370
+ // engine (cold-loading any persisted SSTables) and registers the
371
+ // ResourceManager subscriber once the engine exists.
372
+ }
373
+ /**
374
+ * **LSM durability (steps 3-4) — the async open.** Construct the native LSM
375
+ * engine, attach it to the metadata index, register the ResourceManager
376
+ * budget subscriber, and cold-load every persisted SSTable into the
377
+ * engine's live read set. Called once from {@link init} (after
378
+ * {@link wireIdMapper}) — the LSM engine is the sole metadata engine.
379
+ *
380
+ * ## Persistent vs fallback
381
+ *
382
+ * When the storage is filesystem-backed (exposes `getBinaryBlobPath`), the
383
+ * engine is opened via `NativeLsmEngine.openWithDir(metaDir, manifestJson,
384
+ * …)`: its memtable is backed by mmap'd per-shard append logs and its epoch
385
+ * counter is seeded from the persisted manifest so new writes outrank every
386
+ * already-flushed SSTable. Non-filesystem storage falls back to a
387
+ * non-persistent in-RAM engine (`new NativeLsmEngine`) — reads/writes work
388
+ * for the session but there is no SSTable durability (and {@link flushLsm}
389
+ * becomes a no-op).
390
+ *
391
+ * ## Cold-load = no rebuild
392
+ *
393
+ * Cold start reads the manifest, seeds the epoch via `openWithDir`, then
394
+ * calls `registerSstablePath` once per SSTable — the entire restore. The
395
+ * O(N) {@link rebuild} path is NOT taken: the SSTables ARE the durable
396
+ * posting lists, served from mmap.
397
+ *
398
+ * @throws If a persisted SSTable's path cannot be resolved.
399
+ */
400
+ async openLsmEngine() {
401
+ if (this.lsmEngine !== null)
402
+ return; // already attached
403
+ const bindings = loadNativeModule();
404
+ const blobStorage = this.storage;
405
+ // Resolve the metadata directory for the persistent engine, mirroring
406
+ // wireIdMapper's filesystem guard. `getBinaryBlobPath` returns null on
407
+ // non-local adapters → fall back to the in-RAM engine.
408
+ const metaDir = typeof blobStorage.getBinaryBlobPath === 'function'
409
+ ? blobStorage.getBinaryBlobPath('_metadata')
410
+ : null;
411
+ if (metaDir === null) {
412
+ // Fallback: non-persistent in-RAM engine (the legacy `new` path).
413
+ // U64 IdSpace matches the cor 3.0 production default + the metadata
414
+ // index core constructed above.
415
+ this.lsmEngine = new bindings.NativeLsmEngine({ idSpace: 'u64' });
416
+ this.lsmManifest = null;
417
+ this.lsmMetaDir = null;
418
+ }
419
+ else {
420
+ // Persistent cold-load: parse the manifest (null on a fresh brain),
421
+ // seed the engine's epoch from it via openWithDir, then register every
422
+ // persisted SSTable below. NO rebuild.
423
+ const persisted = await this.loadLsmManifest();
424
+ const manifestJson = persisted !== null ? JSON.stringify(persisted) : null;
425
+ this.lsmEngine = bindings.NativeLsmEngine.openWithDir(metaDir, manifestJson, {
426
+ idSpace: 'u64',
427
+ });
428
+ this.lsmManifest = persisted ?? newEmptyLsmManifest();
429
+ this.lsmMetaDir = metaDir;
430
+ }
431
+ this.native.setLsmEngine(this.lsmEngine);
432
+ // Register the budget subscriber NOW that the engine exists, so its
433
+ // synchronous initial callback gives the engine a budgeted allowedBytes
434
+ // before the first write (closing the u64::MAX unwired-admission window).
435
+ this.attachLsmBudgetSubscriber();
436
+ // Cold-load SSTable registration (persistent engine only). The ordering
437
+ // invariant in flushLsm guarantees every entry the manifest references
438
+ // was made durable BEFORE the manifest — so each path exists here. Call
439
+ // getBinaryBlobPath as a METHOD on the storage object (not via an extracted
440
+ // reference) so its `this` binding survives — the adapter resolves the path
441
+ // through `this.blobPath(...)`.
442
+ if (this.lsmManifest !== null && metaDir !== null) {
443
+ const fsStorage = this.storage;
444
+ const engine = this.lsmEngine;
445
+ for (const entry of this.lsmManifest.sstables) {
446
+ const key = lsmSstableKey(entry.id);
447
+ const path = fsStorage.getBinaryBlobPath(key);
448
+ if (path === null) {
449
+ throw new Error(`openLsmEngine: getBinaryBlobPath returned null for persisted SSTable '${key}'`);
450
+ }
451
+ engine.registerSstablePath(path);
452
+ }
453
+ }
454
+ }
455
+ /**
456
+ * **LSM durability (steps 3-4) — the sync part.** Register the
457
+ * ResourceManager budget subscriber that keeps the engine's admission
458
+ * ceiling in sync with the dynamic 'metadata' budget. Split out of the
459
+ * async {@link openLsmEngine} so the registration (and its synchronous
460
+ * initial callback) is an isolated, side-effect-free-on-failure step that
461
+ * runs immediately after the engine is attached.
462
+ *
463
+ * Idempotent: a second call (e.g. a re-init) is a no-op while a subscriber
464
+ * is live. The unique subscriber name keeps multiple brains in one process
465
+ * from colliding on the same ResourceManager slot.
466
+ *
467
+ * The callback path is load-bearing for the dynamic rebalance: every
468
+ * `notifySubscribers` cycle (rebalance + pressure tick) hits
469
+ * `this.lsmEngine.setAllowedBytes(BigInt(allowed))`, applied by the native
470
+ * engine on its very next admission check.
471
+ */
472
+ attachLsmBudgetSubscriber() {
473
+ if (this.unsubscribeFromBudget !== null)
474
+ return; // already subscribed
475
+ const rm = ResourceManager.getInstance();
476
+ this.lsmSubscriberName = nextLsmSubscriberId();
477
+ this.unsubscribeFromBudget = rm.registerMemoryBudgetSubscriber(this.lsmSubscriberName, 'metadata', (allowedBytes) => {
478
+ // Native side wants u64 BigInt. Negative or NaN values from a
479
+ // pathological budget calculation would round to 0n via the
480
+ // Math.max guard — admission would block every write, which
481
+ // is the SAFE failure (no over-budget writes) rather than the
482
+ // unsafe alternative (unwired u64::MAX → no admission gate).
483
+ const clamped = Math.max(0, Math.floor(allowedBytes));
484
+ this.lsmEngine?.setAllowedBytes(BigInt(clamped));
485
+ // **Step 5.** Track the dynamic budget: the memtable size-flush
486
+ // trigger ({@link addToIndex}) drains at ~50% of this admission
487
+ // ceiling (clamped [32 MiB, 256 MiB]), so the memtable empties
488
+ // well before admission would block a writer. Recomputed in the
489
+ // SAME callback the engine's ceiling is updated from so the two
490
+ // never drift apart across a rebalance.
491
+ this.lsmFlushThresholdBytes = computeLsmFlushThreshold(clamped);
492
+ });
493
+ }
494
+ /**
495
+ * **LSM durability (steps 3-4) — cold-load read.** Read and validate the
496
+ * persisted LSM manifest from storage. Returns `null` when no manifest
497
+ * exists yet (a fresh brain). Validates the format version + shape and
498
+ * throws on a mismatch rather than registering a partial SSTable set (which
499
+ * would silently drop rows) — a forward-incompatible or torn manifest must
500
+ * fail loud.
501
+ *
502
+ * @returns The parsed manifest, or `null` if absent.
503
+ * @throws If the manifest is present but malformed or carries an
504
+ * unsupported {@link LsmManifest.version}.
505
+ */
506
+ async loadLsmManifest() {
507
+ let raw;
508
+ try {
509
+ raw = await this.storage.getMetadata(LSM_MANIFEST_KEY);
510
+ }
511
+ catch {
512
+ // Storage read error during the probe is treated as "no manifest" — a
513
+ // transient hiccup should not block engine open; a genuinely-present
514
+ // manifest is re-read on the next open.
515
+ return null;
516
+ }
517
+ if (raw === null || raw === undefined)
518
+ return null;
519
+ const m = raw;
520
+ if (typeof m.version !== 'number' || !Array.isArray(m.sstables)) {
521
+ throw new Error(`loadLsmManifest: malformed manifest at '${LSM_MANIFEST_KEY}' ` +
522
+ `(expected { version, sstables[], last_durable_epoch })`);
523
+ }
524
+ if (m.version !== LSM_MANIFEST_VERSION) {
525
+ throw new Error(`loadLsmManifest: manifest version ${m.version}, this build supports ` +
526
+ `${LSM_MANIFEST_VERSION}`);
527
+ }
528
+ return {
529
+ version: m.version,
530
+ sstables: m.sstables,
531
+ last_durable_epoch: typeof m.last_durable_epoch === 'number' ? m.last_durable_epoch : 0,
532
+ };
533
+ }
534
+ /**
535
+ * **LSM durability (step 6).** Whether the LSM engine recovered durable
536
+ * on-disk state at cold open — used by {@link init} to demote the
537
+ * field-registry-missing rebuild to disaster-recovery-only. Durable state
538
+ * is either ≥1 SSTable in the cold-loaded manifest OR a non-empty memtable
539
+ * replayed from the per-shard logs (records inserted but not yet flushed
540
+ * before the last shutdown/crash). When present, the posting lists are
541
+ * served from the engine independent of the JSON field registry, so a
542
+ * rebuild would be a redundant O(N) re-scan.
543
+ *
544
+ * @returns `true` when the engine holds durable posting-list state.
545
+ */
546
+ lsmHasDurableState() {
547
+ const engine = this.lsmEngine;
548
+ if (engine === null)
549
+ return false;
550
+ if (this.lsmManifest !== null && this.lsmManifest.sstables.length >= 1) {
551
+ return true;
552
+ }
553
+ if (Number(engine.memtableBytes()) > 0)
554
+ return true;
555
+ if (engine.memtableEntryCount() > 0n)
556
+ return true;
557
+ return false;
558
+ }
559
+ /**
560
+ * **LSM durability (step 6) — restore/rebuild re-open.** Re-open the LSM
561
+ * engine fresh against the (wiped + recreated) storage tree and re-attach
562
+ * it to the native core. Called from {@link rebuild} in LSM mode.
563
+ *
564
+ * ## Why a full re-open, not just a `mkdir`
565
+ *
566
+ * A rebuild commonly follows brainy's `restore()`, which WIPES and replaces
567
+ * the storage tree under the still-live engine. But `native.clear()` (the
568
+ * in-memory reset rebuild runs) deliberately does NOT touch the attached
569
+ * engine — the engine owns its own memtable + copy-on-write live-SSTable
570
+ * set, neither of which `clear()` can see. So WITHOUT this re-open the
571
+ * engine would keep:
572
+ * - **stale live-SSTable readers** — mmap handles on the pre-restore
573
+ * `.sst` files. On Linux an mmap survives the file's `unlink`, so those
574
+ * readers keep serving PRE-restore rows; a query after rebuild would
575
+ * merge the restored data WITH ghosts of the wiped entities.
576
+ * - **a stale in-memory manifest** ({@link lsmManifest}) still listing the
577
+ * deleted SSTables — the next cold open would try to `registerSstablePath`
578
+ * files that no longer exist and fail loud.
579
+ * - **a stale memtable** holding pre-restore writes not yet flushed.
580
+ *
581
+ * This method makes the engine adopt the new tree: it deletes the stale
582
+ * on-disk SSTables + memtable logs, drops the stale manifest, constructs a
583
+ * FRESH `openWithDir` engine (empty live set, empty memtable, epoch reset
584
+ * from the clean logs), and re-attaches it via `setLsmEngine` (which
585
+ * overwrites the core's engine Arc — verified in the Rust binding). The
586
+ * ResourceManager subscriber callback dereferences `this.lsmEngine`
587
+ * dynamically, so the fresh engine inherits budget tracking on the next
588
+ * tick; meanwhile the re-applied admission ceiling below keeps it bounded,
589
+ * and the step-5 size-flush trigger drains it during the rebuild's
590
+ * re-index walk.
591
+ *
592
+ * A no-op on the non-persistent fallback engine (no `lsmMetaDir`), where
593
+ * there is no SSTable surface to go stale.
594
+ */
595
+ async reopenLsmEngineFresh() {
596
+ if (this.lsmMetaDir === null)
597
+ return;
598
+ const metaDir = this.lsmMetaDir;
599
+ const bindings = loadNativeModule();
600
+ const blobStorage = this.storage;
601
+ // (1) Delete the stale on-disk SSTables of the pre-restore tree. The live
602
+ // engine's readers mmap the `.sst` files (surviving unlink), so they must
603
+ // be dropped together with the engine below; deleting the files also stops
604
+ // the next cold open from registering a ghost SSTable. The SSTables live
605
+ // under the blob root (resolved via the SAME `getBinaryBlobPath` key
606
+ // mapping flushLsm uses) — which is a SIBLING of `metaDir`, NOT a child of
607
+ // it (e.g. FileSystemStorage maps `_metadata` → `…/_metadata.bin` for the
608
+ // memtable logs but `_metadata/sstables/<id>.sst` → `…/_metadata/sstables/
609
+ // <id>.sst.bin`). Wiping the whole sstables directory reaps the
610
+ // manifest-listed SSTables AND any orphan from an interrupted flush.
611
+ if (typeof blobStorage.getBinaryBlobPath === 'function') {
612
+ const probe = blobStorage.getBinaryBlobPath(lsmSstableKey(0));
613
+ if (probe !== null) {
614
+ rmSync(dirname(probe), { recursive: true, force: true });
615
+ }
616
+ }
617
+ rmSync(join(metaDir, 'memtable'), { recursive: true, force: true });
618
+ // (2) Drop the stale manifest (it still references the deleted SSTables).
619
+ try {
620
+ await this.storage.saveMetadata(LSM_MANIFEST_KEY, null);
621
+ }
622
+ catch {
623
+ // Best-effort: a fresh-restore tree may already lack the manifest.
624
+ }
625
+ // (3) Recreate the memtable directory the fresh engine's lazy per-shard
626
+ // logs append into; the native append path fails LOUD on a missing dir.
627
+ mkdirSync(join(metaDir, 'memtable'), { recursive: true });
628
+ // (4) Construct a FRESH persistent engine (null manifest → epoch seeded
629
+ // from the now-clean logs) and re-attach. This drops the old engine's
630
+ // stale live-SSTable readers + memtable; `setLsmEngine` replaces the
631
+ // core's engine reference.
632
+ const engine = bindings.NativeLsmEngine.openWithDir(metaDir, null, {
633
+ idSpace: 'u64',
634
+ });
635
+ this.lsmEngine = engine;
636
+ this.lsmManifest = newEmptyLsmManifest();
637
+ this.native.setLsmEngine(engine);
638
+ // Re-apply the current admission ceiling immediately (the subscriber
639
+ // callback only refreshes it on the next rebalance/pressure tick). The
640
+ // fresh engine opens with `allowedBytes = u64::MAX`; seeding it now keeps
641
+ // the rebuild's writes inside the same budget the old engine had.
642
+ if (this.lsmSubscriberName !== null) {
643
+ const allowed = ResourceManager.getInstance().getCategoryAllowedBytes('metadata');
644
+ const clamped = Math.max(0, Math.floor(allowed));
645
+ engine.setAllowedBytes(BigInt(clamped));
646
+ this.lsmFlushThresholdBytes = computeLsmFlushThreshold(clamped);
647
+ }
648
+ }
649
+ /**
650
+ * **LSM durability (steps 3-4) — the flush/close drain.** Drain the LSM
651
+ * engine's memtable to a durable SSTable and splice it into the manifest.
652
+ * Called from {@link flush} (and therefore {@link close}, which flushes).
653
+ * No-op when there is no memtable content to flush, and on the
654
+ * non-persistent fallback engine (no manifest / no metaDir).
655
+ *
656
+ * ## Ordering invariant (load-bearing — a reviewer MUST preserve it)
657
+ *
658
+ * The four steps run in this exact order, and `commitFlushThrough` is NEVER
659
+ * called before the `.sst` bytes AND the manifest referencing them are
660
+ * durable:
661
+ *
662
+ * 1. write the `.sst` bytes (`saveBinaryBlob`, atomic tmp+rename)
663
+ * 2. write the manifest that references the new SSTable (`saveMetadata`)
664
+ * 3. register the SSTable into the engine's live read set
665
+ * (`registerSstablePath` — mmap + SHA-256 verify)
666
+ * 4. ONLY NOW retire the memtable log below the flushed epoch
667
+ * (`commitFlushThrough`)
668
+ *
669
+ * A crash between any two steps is safe: the just-flushed records still live
670
+ * in the uncommitted memtable log, so the next `openWithDir` replays them
671
+ * and merge-by-epoch idempotently collapses the transient double presence. A
672
+ * crash before step 2 leaves an orphaned `.sst` no manifest references — its
673
+ * id is harmlessly re-used on the next flush. Calling `commitFlushThrough`
674
+ * early would drop the log copy while the only on-disk copy might not yet be
675
+ * durable — unrecoverable data loss. Hence step 4 is strictly last.
676
+ */
677
+ async flushLsm() {
678
+ const engine = this.lsmEngine;
679
+ const manifest = this.lsmManifest;
680
+ // Only the persistent (filesystem-backed) engine has an SSTable surface.
681
+ if (engine === null || manifest === null || this.lsmMetaDir === null)
682
+ return;
683
+ // Defensive: skip the drain if the persistent memtable directory has been
684
+ // removed out from under us. The realistic trigger is teardown, not
685
+ // production: brainy registers a global process-exit `flushOnShutdown`
686
+ // hook that calls `metadataIndex.flush()` on EVERY still-registered brain,
687
+ // and a test commonly deletes its temp storage before that hook fires. The
688
+ // native memtable-log rotation in `commitFlushThrough` fails LOUD on a
689
+ // missing directory ("persistence guarantee broken — terminate"), aborting
690
+ // the process. There is nothing to persist when the directory is gone (the
691
+ // memtable logs lived there too), so a no-op is the correct, safe outcome.
692
+ // In production the directory never vanishes, so this guard always passes.
693
+ if (!existsSync(join(this.lsmMetaDir, 'memtable')))
694
+ return;
695
+ const result = engine.flush();
696
+ if (result == null)
697
+ return; // memtable empty — nothing to persist
698
+ const blobStorage = this.storage;
699
+ // Monotonic, manifest-persisted id (see nextSstableId for the crash-safety
700
+ // argument).
701
+ const id = nextSstableId(manifest);
702
+ const key = lsmSstableKey(id);
703
+ // --- Ordering invariant (see JSDoc) ---
704
+ // (1) .sst bytes durable.
705
+ await blobStorage.saveBinaryBlob(key, result.sstable);
706
+ // (2) manifest (references the .sst) durable. Stamp the native flush's
707
+ // placeholder-id entry with the allocated id, keep the (level, id) sort
708
+ // invariant, and advance the durable-epoch high-water mark.
709
+ const entry = JSON.parse(result.manifestEntryJson);
710
+ entry.id = id;
711
+ manifest.sstables.push(entry);
712
+ manifest.sstables.sort((a, b) => a.level - b.level || a.id - b.id);
713
+ manifest.last_durable_epoch = Number(result.lastDurableEpoch);
714
+ await this.storage.saveMetadata(LSM_MANIFEST_KEY, manifest);
715
+ // (3) register into the engine's live read set (mmap + verify).
716
+ const path = blobStorage.getBinaryBlobPath(key);
717
+ if (path === null) {
718
+ throw new Error(`flushLsm: getBinaryBlobPath returned null for '${key}'`);
719
+ }
720
+ engine.registerSstablePath(path);
721
+ // (4) Retire the memtable log below the flushed epoch. The `.sst` bytes
722
+ // AND the manifest referencing them are already durable here, so a failure
723
+ // at THIS step is non-fatal per the ordering invariant above: the records
724
+ // simply stay in the memtable log and replay on the next `openWithDir`,
725
+ // where merge-by-epoch idempotently collapses them against the SSTable
726
+ // copy (no double counting, no loss — the data lives safely in two places).
727
+ // The one realistic trigger is a `rebuild()` following brainy's `restore()`,
728
+ // which wipes + recreates the storage tree under the still-live engine; the
729
+ // native shard-log rotation can then fail to rename a log even though the
730
+ // flushed data is safely in the new SSTable. Warn + continue rather than
731
+ // abort the rebuild — the next clean flush retires the log.
732
+ try {
733
+ engine.commitFlushThrough(result.lastDurableEpoch);
734
+ }
735
+ catch (error) {
736
+ prodLog.warn(`flushLsm: commitFlushThrough deferred for SSTable ${id} (it + the ` +
737
+ `manifest are already durable; the memtable log replays + collapses ` +
738
+ `by epoch on the next open):`, error);
739
+ }
740
+ }
741
+ /**
742
+ * **Piece 11 closeout.** Detect cortex 2.x legacy JSON metadata
743
+ * state on storage. The canonical marker is the
744
+ * `__metadata_field_registry__` key the JSON path writes on
745
+ * every flush. If present, the brain was built before the LSM
746
+ * engine became the default and refuses to silently switch.
747
+ *
748
+ * On detection: throw an init-time error directing the operator to run
749
+ * the migration script (the cor 3.0 LSM engine is the only engine — the
750
+ * legacy JSON chunk path has been removed). The error message is the
751
+ * migration runbook.
752
+ *
753
+ * No legacy state present → the brain is either fresh (no prior
754
+ * cor install) or already on the LSM path → proceed normally
755
+ * with the LSM engine.
756
+ *
757
+ * @throws {@link MetadataIndexLegacyJsonStateDetected} when cortex 2.x
758
+ * JSON metadata state is detected on disk.
759
+ */
760
+ async detectLegacyJsonStateAndGate() {
761
+ let registry;
762
+ try {
763
+ registry = await this.storage.getMetadata('__metadata_field_registry__');
764
+ }
765
+ catch {
766
+ // Storage errors during the probe are treated as "no legacy
767
+ // state" — we can't read the file, so we can't be sure it's
768
+ // there, and the default behaviour (use LSM) is safer than
769
+ // erroring out on every transient storage hiccup.
770
+ return;
771
+ }
772
+ if (registry === null || registry === undefined) {
773
+ // Fresh brain — no field registry — proceed with the LSM engine.
774
+ return;
775
+ }
776
+ // A reopened cor 3.0 LSM brain ALSO carries `__metadata_field_registry__`
777
+ // (the value-enumeration registry is shared — it is NOT a cortex 2.x
778
+ // marker by itself). The 2.x-specific marker is a per-field sparse index
779
+ // (`__sparse_index__<field>`), which the LSM path never writes — the
780
+ // metadata-storage analog of {@link assertNoLegacyLayout}'s chunk-file
781
+ // check. Probe the registered fields for one; only genuine 2.x chunk
782
+ // state trips the gate, so a legitimate LSM reopen passes through.
783
+ const fields = Array.isArray(registry.fields)
784
+ ? registry.fields.filter((f) => typeof f === 'string')
785
+ : [];
786
+ let hasLegacySparseIndex = false;
787
+ for (const field of fields) {
788
+ let sparse;
789
+ try {
790
+ sparse = await this.storage.getMetadata(`__sparse_index__${field}`);
791
+ }
792
+ catch {
793
+ continue;
794
+ }
795
+ if (sparse !== null && sparse !== undefined) {
796
+ hasLegacySparseIndex = true;
797
+ break;
798
+ }
799
+ }
800
+ if (!hasLegacySparseIndex) {
801
+ // Registry present but no 2.x sparse indices → an LSM-native brain.
802
+ return;
803
+ }
804
+ // Genuine cortex 2.x JSON chunk state. Refuse to proceed silently: the
805
+ // cor 3.0 LSM engine would shadow this on-disk state and surface as a
806
+ // mysteriously-empty brain on first query. The legacy JSON chunk engine
807
+ // has been removed, so the only path forward is migration.
808
+ throw new MetadataIndexLegacyJsonStateDetected(`[NativeMetadataIndex] cortex 2.x JSON metadata state detected ` +
809
+ `(__metadata_field_registry__ + __sparse_index__ chunk markers). ` +
810
+ `cor 3.0 uses the LSM metadata engine exclusively (the legacy JSON ` +
811
+ `chunk engine has been removed), which would shadow this on-disk ` +
812
+ `state and surface as a mysteriously-empty brain on first query.\n\n` +
813
+ `Migrate the brain to the LSM engine before reopening it:\n` +
814
+ ` - Run the migration script: ` +
815
+ `scripts/migrate-cortex-2x-to-3x.mjs <brain-path>\n` +
816
+ ` (it snapshots the brain via Brainy 7.x, wipes the legacy JSON ` +
817
+ `state, and restores via Brainy 8.0 + cor 3.0, producing the ` +
818
+ `LSM-native on-disk layout).`);
819
+ }
820
+ /**
821
+ * **#18 read-side epoch guard.** Detect 7.x → 8.0 derived-index epoch drift
822
+ * on open and route it to the right outcome. Called from {@link init} AFTER
823
+ * {@link detectLegacyJsonStateAndGate} (the cortex-2.x JSON gate) and
824
+ * {@link openLsmEngine} (the LSM cold-open), and BEFORE the column-store init
825
+ * and any rebuild branch — i.e. while reads are still un-served.
826
+ *
827
+ * The on-disk version-handshake marker (`_system/brain-format.json`) carries a
828
+ * shared, lockstep-bumped `indexEpoch`. cor reads it directly off storage (it
829
+ * cannot reach `brain.formatInfo()`, and Brainy's marker module is not
830
+ * exported) and compares against {@link COR_EXPECTED_INDEX_EPOCH}.
831
+ *
832
+ * Four fully-wired outcomes — no dangling flag:
833
+ *
834
+ * 1. **Epoch CURRENT (the common case)** — a single tiny marker read + an
835
+ * integer compare, then return. NO size probe, NO further work: the
836
+ * warm-restart path stays a cheap no-op.
837
+ * 2. **Stale + SMALL** (`< {@link resolveLargeBrainThreshold}`) — NO-OP. Brainy's
838
+ * `rebuildIndexesIfNeeded` already drives the rebuild from the canonical
839
+ * records for sub-threshold brains (its `AUTO_REBUILD_THRESHOLD` matches
840
+ * ours) and re-stamps the marker after the rebuild verifies. cor must NOT
841
+ * throw here; it simply lets Brainy's path run.
842
+ * 3. **Stale + LARGE + auto-migrate disabled** (`COR_AUTO_MIGRATE=false`) —
843
+ * THROW {@link IndexMigrationRequiredError}, loud and pre-serve. A service
844
+ * that refuses to start is recoverable; serving on indexes that predate
845
+ * this build would surface stale/empty results with no signal.
846
+ * 4. **Stale + LARGE + auto-migrate enabled (default)** — NO-OP / fall through.
847
+ * Brainy's existing large-brain path defers to a lazy first-query rebuild
848
+ * (force=true, BLOCKING inline). That blocking rebuild is correct but not
849
+ * yet "optimized": the non-blocking background build-new → verify → swap
850
+ * that removes the first-query freeze is the **rc.8 increment** (it needs
851
+ * Brainy's deference hook + a cor-invokable stamp method, neither shipped on
852
+ * rc.7). We deliberately do NOT set an `epochMigrationPending` flag here:
853
+ * nothing on rc.7 reads it (the MigrationCoordinator is the rc.8 unit), and
854
+ * an unread flag would be unwired dead state.
855
+ *
856
+ * @throws {@link IndexMigrationRequiredError} on outcome 3 only.
857
+ */
858
+ async guardIndexEpoch() {
859
+ const onDisk = await readOnDiskBrainFormat(this.storage);
860
+ if (!isEpochStale(onDisk)) {
861
+ // Outcome 1: epoch current — zero-overhead no-op (no size probe).
862
+ return;
863
+ }
864
+ // Epoch is stale (drifted or absent/corrupt marker). Size the brain to
865
+ // decide who drives the rebuild.
866
+ const size = await detectBrainSize(this.storage);
867
+ const isLargeBrain = size >= resolveLargeBrainThreshold();
868
+ if (!isLargeBrain) {
869
+ // Outcome 2: small brain — Brainy's rebuildIndexesIfNeeded drives the
870
+ // rebuild from canonical and re-stamps. Nothing for cor to do.
871
+ return;
872
+ }
873
+ if (!resolveAutoMigrate()) {
874
+ // Outcome 3: large brain, auto-migrate disabled — refuse to serve.
875
+ throw new IndexMigrationRequiredError(`[NativeMetadataIndex] derived-index epoch drift detected on a large ` +
876
+ `brain (${size.toLocaleString()} entities ≥ migration threshold) and ` +
877
+ `COR_AUTO_MIGRATE is disabled. The on-disk derived indexes (DiskANN ` +
878
+ `vectors, LSM metadata postings, graph adjacency) predate this build ` +
879
+ `(on-disk indexEpoch ${onDisk?.indexEpoch ?? 'absent'} ≠ expected ` +
880
+ `${COR_EXPECTED_INDEX_EPOCH}) and would serve stale/empty results.\n\n` +
881
+ `Re-enable automatic migration (rebuild from the canonical records on ` +
882
+ `open) by unsetting COR_AUTO_MIGRATE, or set COR_AUTO_MIGRATE=true.`);
883
+ }
884
+ // Outcome 4: large brain, auto-migrate enabled — the #18 coordinated blocking
885
+ // migration will run, but we do NOT kick it here. guardIndexEpoch() runs in the
886
+ // MIDDLE of init() (before columnStore.init / warmCache / lazyLoadCounts), and
887
+ // the migration's first step (metadata.rebuild → reopenLsmEngineFresh +
888
+ // columnStore.reset, both destructive) would run CONCURRENTLY with — and race,
889
+ // unsynchronized — the remainder of THIS init(). Flag it instead; init() kicks
890
+ // the coordinator at its very END, so the rebuild starts against a fully
891
+ // settled index. The flag is read (init()'s tail) — not dead state.
892
+ this.epochMigrationPending = true;
893
+ }
894
+ /**
895
+ * @description **#18 migration lock — feature-detected by Brainy.** `true` while
896
+ * this brain is undergoing (or has failed) the coordinated 7.x → 8.0 rebuild, so
897
+ * Brainy holds every public read and write behind the lock. Delegates to the
898
+ * shared {@link MigrationCoordinator}; `false` when no coordinator is wired.
899
+ * @returns `true` when the brain must not be read from or written to.
900
+ */
901
+ isMigrating() {
902
+ return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
903
+ }
904
+ /**
905
+ * @description **#18 migration lock — structured progress** Brainy relays into
906
+ * `getIndexStatus().migration`. `null` unless a migration is in flight.
907
+ * @returns The current {@link MigrationStatus}, or `null`.
908
+ */
909
+ migrationStatus() {
910
+ return this.migrationCoordinatorGetter?.()?.migrationStatus() ?? null;
911
+ }
912
+ /**
913
+ * **#18 in-place migration runner (stub).** Reads the un-migrated metadata
914
+ * state from storage and replays it through the LSM-attached mutation path,
915
+ * leaving the brain on the cor 3.0 LSM-native on-disk layout.
916
+ *
917
+ * **NOT YET IMPLEMENTED — GA path is the rc.8 increment.** The read-side
918
+ * epoch guard (#18, {@link guardIndexEpoch}) ships now and makes the SMALL
919
+ * 7.x → 8.0 brain GA on rc.7 (Brainy drives the rebuild from canonical). The
920
+ * LARGE-brain non-blocking background build-new → verify → swap that this
921
+ * runner will drive needs Brainy's deference hook + a cor-invokable stamp
922
+ * method, which land together in the rc.8 increment; until then a large stale
923
+ * brain falls through to Brainy's blocking inline rebuild on first query.
924
+ *
925
+ * The wrapper for this method exists so callers have a stable reference point
926
+ * in the API surface; today it throws with a pointer to the supported
927
+ * back-compat path. Until the in-place runner lands, migrate via the
928
+ * standalone script `scripts/migrate-cortex-2x-to-3x.mjs`, which snapshots the
929
+ * brain via Brainy 7.x, wipes the legacy state, and restores via Brainy 8.0 +
930
+ * cor 3.0 (producing the LSM-native on-disk layout).
931
+ *
932
+ * @throws Always — the in-place runner is the rc.8 increment.
933
+ */
934
+ async migrateToLsm() {
935
+ throw new Error(`[NativeMetadataIndex.migrateToLsm] not yet implemented — the in-place ` +
936
+ `runner is the #18 rc.8 increment (needs Brainy's deference hook + ` +
937
+ `stamp method). Until then, migrate via the standalone script: ` +
938
+ `scripts/migrate-cortex-2x-to-3x.mjs <brain-path>.`);
939
+ }
940
+ /**
941
+ * Get the shared entity id-mapper façade. Brainy's unfiltered `orderBy` path
942
+ * calls `metadataIndex.getIdMapper().getUuid(intId)` to turn the column store's
943
+ * sorted int ids back into UUIDs for batchGet. The façade delegates to the
944
+ * native index's O(1) UUID ↔ int methods — the canonical mapping that
945
+ * `addToIndex` assigns and the column store is populated against.
946
+ *
947
+ * @returns An object exposing the brainy `EntityIdMapper` methods callers use.
948
+ */
949
+ getIdMapper() {
950
+ if (this.idMapperFacade)
951
+ return this.idMapperFacade;
952
+ const native = this.native;
953
+ this.idMapperFacade = {
954
+ getUuid: (intId) => native.intToUuid(intId) ?? undefined,
955
+ getInt: (uuid) => native.uuidToInt(uuid) ?? undefined,
956
+ getOrAssign: (uuid) => {
957
+ // The native index assigns ids inside addToIndex. If a caller resolves an
958
+ // id before the entity is indexed, surface the existing mapping; an
959
+ // unmapped UUID has no int id yet and is reported as such rather than
960
+ // fabricating one (which would desync the canonical native mapper).
961
+ const existing = native.uuidToInt(uuid);
962
+ if (existing !== null)
963
+ return existing;
964
+ throw new Error(`EntityIdMapper.getOrAssign: '${uuid}' has no native id yet — ids are ` +
965
+ `assigned by addToIndex. Index the entity before resolving its int id.`);
966
+ },
967
+ intsIterableToUuids: (ints) => native.intsToUuids([...ints]),
968
+ // Brainy 8.0 `restore()` calls this BEFORE the metadata/vector/graph
969
+ // `Promise.all` so the entity-id mapper reflects the restored snapshot's
970
+ // assignments before `graphIndex.rebuild()` resolves verb endpoints through
971
+ // it. The metadata rebuild IS cor's canonical UUID→int assignment pass, so
972
+ // we run the full rebuild here, then mark the immediately-following
973
+ // `rebuild()` (the one in brainy's Promise.all) to skip — otherwise it would
974
+ // re-clear the mapper and race the concurrent graph rebuild (#68).
975
+ rebuild: async () => {
976
+ this.skipRebuildOnce = false; // ensure the call below actually rebuilds
977
+ await this.rebuild();
978
+ this.skipRebuildOnce = true; // brainy's following Promise.all rebuild() is a no-op
979
+ }
980
+ };
981
+ return this.idMapperFacade;
982
+ }
983
+ // ==========================================================================
984
+ // Initialization
985
+ // ==========================================================================
986
+ async init() {
987
+ // **Piece 11 closeout.** Detect cortex 2.x legacy JSON metadata state on
988
+ // disk. The cor 3.0 LSM engine is the only metadata engine, so an
989
+ // un-migrated 2.x brain would have its on-disk JSON state shadowed and
990
+ // surface as a mysteriously-empty brain on first query. Throw a clear
991
+ // migration error instead so the operator runs the migration script.
992
+ await this.detectLegacyJsonStateAndGate();
993
+ // Load field registry to discover persisted indices
994
+ await this.loadFieldRegistry();
995
+ // #72 Phase C: build the shared mmap BinaryIdMapper from the brain's
996
+ // `_id_mapper/*` paths and inject it as the core's canonical
997
+ // UUID ↔ entity-int allocator. Must run before columnStore.init (which
998
+ // resolves ids through getIdMapper()) and before any mutation.
999
+ this.wireIdMapper();
1000
+ // LSM durability (steps 3-4): open the native LSM engine here (not in the
1001
+ // constructor) so it can be seeded from the on-disk manifest and cold-load
1002
+ // every persisted SSTable into its live read set — the entire restore,
1003
+ // with NO O(N) rebuild. Runs after wireIdMapper (the engine's posting
1004
+ // lists reference the mapper's entity ints) and before any read/mutation.
1005
+ await this.openLsmEngine();
1006
+ // **#18 read-side epoch guard.** AFTER the cortex-2.x legacy gate and the
1007
+ // LSM cold-open, classify the brain's derived-index epoch against this
1008
+ // build's expectation. The common case (current epoch) is a single tiny
1009
+ // marker read + an integer compare, then return — no size probe, no
1010
+ // overhead on the warm-restart path. Only an epoch drift (a 7.x→8.0 upgrade,
1011
+ // or an absent/corrupt marker) does any further work. See
1012
+ // {@link guardIndexEpoch}.
1013
+ await this.guardIndexEpoch();
1014
+ // Initialize the column store with the shared storage + id mapper façade.
1015
+ // Discovers any persisted `_column_index/` segments and re-applies tombstones.
1016
+ // Done before the rebuild branch below so that a rebuild repopulates a
1017
+ // freshly-initialized column store via addToIndex.
1018
+ await this.columnStore.init(this.storage, this.getIdMapper());
1019
+ const hasFields = this.knownFields.size > 0;
1020
+ if (!hasFields) {
1021
+ // Don't trust "empty" — field registry may be missing due to interrupted flush.
1022
+ // Probe storage for actual entities before concluding the workspace is empty.
1023
+ try {
1024
+ const probe = await this.storage.getNouns({ pagination: { limit: 1, offset: 0 } });
1025
+ const hasEntities = (probe.totalCount ?? 0) > 0 || probe.items.length > 0;
1026
+ if (hasEntities) {
1027
+ // **LSM durability (step 6) — rebuild demoted to disaster-recovery.**
1028
+ // A missing field registry no longer implies the index is lost: in
1029
+ // LSM mode the posting lists live in the registered SSTables + the
1030
+ // replayed memtable log, wholly independent of the JSON field
1031
+ // registry. If `openLsmEngine` recovered durable state (≥1 manifest
1032
+ // SSTable OR a non-empty replayed memtable), reads serve from there
1033
+ // and an O(N) rebuild would be a redundant re-scan of the entire
1034
+ // corpus. Skip it. `rebuild()` stays reachable only for GENUINE
1035
+ // corruption — no durable LSM state AND entities on storage — which
1036
+ // falls through to the rebuild below.
1037
+ if (this.lsmHasDurableState()) {
1038
+ prodLog.info('[NativeMetadataIndex] Field registry missing but the LSM engine ' +
1039
+ 'recovered durable state (SSTables/memtable) — serving from it, ' +
1040
+ 'NO rebuild.');
1041
+ return;
1042
+ }
1043
+ console.warn(`[NativeMetadataIndex] Field registry missing but ${probe.totalCount ?? 'unknown'} entities exist — rebuilding index`);
1044
+ await this.rebuild();
1045
+ return;
1046
+ }
1047
+ }
1048
+ catch {
1049
+ // Storage probe failed — genuinely empty
1050
+ }
1051
+ return;
1052
+ }
1053
+ await this.warmCache();
1054
+ await this.lazyLoadCounts();
1055
+ this.syncTypeCountsToFixed();
1056
+ // **#18 migration kick — LAST, after the index is fully constructed.** If the
1057
+ // epoch guard flagged a large stale brain (outcome 4), start the coordinated
1058
+ // blocking migration NOW: `start()` flips the lock (`isMigrating()` → true)
1059
+ // SYNCHRONOUSLY before this init() returns (so Brainy sees it the instant init
1060
+ // completes and holds reads+writes behind it), then runs the in-place
1061
+ // rebuild-from-canonical of all three derived indexes asynchronously — the LOCK,
1062
+ // not this call, serializes access, so init() is not blocked. Kicking HERE (not
1063
+ // from guardIndexEpoch mid-init) is what keeps the migration's destructive
1064
+ // rebuild from racing the rest of init(). Fire-and-forget: brainy polls
1065
+ // isMigrating() at 250 ms (rc.9 dropped the whenMigrationComplete hook), so the
1066
+ // `.catch` only prevents an unhandled rejection on the start() promise when a
1067
+ // failed migration surfaces (it is logged loudly and keeps the brain locked). No coordinator
1068
+ // wired (raw-napi test / non-migrating tier) → no-op.
1069
+ if (this.epochMigrationPending) {
1070
+ this.epochMigrationPending = false;
1071
+ this.migrationCoordinatorGetter?.()?.start().catch(() => { });
1072
+ }
1073
+ }
1074
+ // ==========================================================================
1075
+ // Storage I/O helpers
1076
+ // ==========================================================================
1077
+ /**
1078
+ * **#72 Phase C.** Build the shared mmap `BinaryIdMapper` from the
1079
+ * brain's `_id_mapper/*` storage paths and inject it into the Rust
1080
+ * core as the canonical UUID ↔ entity-int allocator. This replaces the
1081
+ * legacy `loadEntityIdMapper`/`saveEntityIdMapper` JSON path, whose two
1082
+ * resident HashMaps cost ~56 GB at 1 B entities and whose serialize
1083
+ * threw at the V8 ~512 MB / ~13 M-UUID cliff.
1084
+ *
1085
+ * @throws If storage is not filesystem-backed (no `getBinaryBlobPath`)
1086
+ * — cor's metadata index requires local paths; brainy runs
1087
+ * unaccelerated on cloud adapters.
1088
+ */
1089
+ wireIdMapper() {
1090
+ // The native core is always constructed in u64 mode (the cor 3.0
1091
+ // billion-scale default); setIdMapper rejects a width mismatch.
1092
+ const handle = openOrCreateBinaryIdMapper(this.storage, {
1093
+ idSpace: 'u64',
1094
+ });
1095
+ this.idMapperNative = handle.mapper;
1096
+ this.native.setIdMapper(handle.mapper);
1097
+ }
1098
+ async loadFieldRegistry() {
1099
+ try {
1100
+ const registry = await this.storage.getMetadata('__metadata_field_registry__');
1101
+ if (!registry?.fields || !Array.isArray(registry.fields)) {
1102
+ prodLog.debug('No field registry found - will build on first flush');
1103
+ return;
1104
+ }
1105
+ // Load into Rust
1106
+ this.native.loadFieldRegistry(JSON.stringify(registry));
1107
+ // Track known fields
1108
+ for (const field of registry.fields) {
1109
+ if (typeof field === 'string' && field.length > 0) {
1110
+ this.knownFields.add(field);
1111
+ }
1112
+ }
1113
+ prodLog.info(`Loaded field registry: ${registry.fields.length} persisted fields discovered\n` +
1114
+ ` Fields: ${registry.fields.slice(0, 5).join(', ')}${registry.fields.length > 5 ? '...' : ''}`);
1115
+ }
1116
+ catch (error) {
1117
+ prodLog.debug('Could not load field registry:', error);
1118
+ }
1119
+ }
1120
+ async saveFieldRegistry() {
1121
+ if (this.knownFields.size === 0)
1122
+ return;
1123
+ try {
1124
+ const json = this.native.saveFieldRegistry();
1125
+ const data = JSON.parse(json);
1126
+ await this.storage.saveMetadata('__metadata_field_registry__', data);
1127
+ }
1128
+ catch (error) {
1129
+ prodLog.warn('Failed to save field registry:', error);
1130
+ }
1131
+ }
1132
+ /**
1133
+ * **Piece 6 generation counter — invalidate stale lazy-load state.**
1134
+ * Checks the native generation counter against our last-seen value.
1135
+ * If a mutation has bumped the counter since we last looked,
1136
+ * `loadedFields` is cleared so every subsequent `ensureFieldLoaded`
1137
+ * re-pulls the current value-enumeration field index from storage.
1138
+ *
1139
+ * Called at the start of every read entry point. The check itself
1140
+ * is one atomic load + one BigInt compare — sub-microsecond.
1141
+ *
1142
+ * Without this hook, after a mutation on the native side the
1143
+ * cached `loadedFields` set would prevent re-reading the updated
1144
+ * value enumeration, surfacing as stale-result bugs of the same shape
1145
+ * as R5 (but for the lazy-load path rather than the filter path).
1146
+ */
1147
+ invalidateIfStale() {
1148
+ // **Piece 15 nuclear path.** Pre-Piece-15 this method gated the
1149
+ // clear on `currentGeneration()` having changed since the last
1150
+ // observation; that gate was load-bearing when EVERY read entry
1151
+ // point called this method (the counter delta was the canary
1152
+ // for "some mutation happened"). Post-Piece-15, the read entry
1153
+ // points call {@link invalidateFieldIfStale} instead — which
1154
+ // never updates `lastSeenGeneration`, so the old delta check
1155
+ // would never trigger from the on-path callers. The method
1156
+ // survives as an unconditional reset for cross-cutting state
1157
+ // transitions (rebuilds, debug callers, future code paths
1158
+ // that need a clean slate) — the gate is gone because the
1159
+ // method's purpose is now "wipe everything", full stop.
1160
+ this.loadedFields.clear();
1161
+ this.lastSeenFieldGenerations.clear();
1162
+ this.lastSeenGeneration = this.native.currentGeneration();
1163
+ }
1164
+ /**
1165
+ * **Piece 15 — per-field selective invalidation.** Reads the
1166
+ * field's per-field generation from the native side and compares
1167
+ * against {@link lastSeenFieldGenerations}. If the field's counter
1168
+ * advanced since we last observed it, removes ONLY that field
1169
+ * from {@link loadedFields} — every other loaded field stays
1170
+ * valid. Then records the current value so the next call sees a
1171
+ * no-op until another mutation touches this field.
1172
+ *
1173
+ * Called at the start of every read entry point that knows which
1174
+ * fields it needs. The check is one FFI hop (native atomic load)
1175
+ * + one Map get/set — sub-microsecond per field. For multi-field
1176
+ * filters (`ensureFieldsLoaded`) the wrapper iterates this method
1177
+ * once per field instead of doing the old index-wide check.
1178
+ *
1179
+ * Cold-boot semantic: first probe records the current counter
1180
+ * without invalidating (matches the old `lastSeenGeneration ===
1181
+ * -1n` initial-call behavior). A counter advance from 0 → 1 on
1182
+ * the FIRST observation is treated as "first sighting", NOT
1183
+ * "stale" — `loadedFields` doesn't have this field yet anyway, so
1184
+ * there's nothing to invalidate.
1185
+ */
1186
+ invalidateFieldIfStale(field) {
1187
+ const current = this.native.fieldGeneration(field);
1188
+ const lastSeen = this.lastSeenFieldGenerations.get(field);
1189
+ if (lastSeen !== undefined && current !== lastSeen) {
1190
+ this.loadedFields.delete(field);
1191
+ }
1192
+ this.lastSeenFieldGenerations.set(field, current);
1193
+ }
1194
+ /**
1195
+ * Ensure a field's persisted value-enumeration index is loaded into the
1196
+ * native core (the LSM read paths enumerate candidate values from it).
1197
+ * Lazy + idempotent — guarded by {@link loadedFields}.
1198
+ */
1199
+ async ensureFieldLoaded(field) {
1200
+ // Piece 15: selective per-field invalidation. A mutation on
1201
+ // ANOTHER field no longer drops this field's cached load —
1202
+ // brainy's lazy-load survives the cross-field churn.
1203
+ this.invalidateFieldIfStale(field);
1204
+ if (this.loadedFields.has(field))
1205
+ return;
1206
+ await this.loadPersistedFieldIndex(field);
1207
+ this.loadedFields.add(field);
1208
+ }
1209
+ /**
1210
+ * **LSM durability (steps 3-4) — cold-load value enumeration.** Restore a
1211
+ * field's persisted value-enumeration index
1212
+ * (`__metadata_field_index__field_<field>`) into the native core. In LSM
1213
+ * mode the read paths for field-wide `exists` / `missing` / `ne` and range
1214
+ * (`between` / `gt` / `lt`) queries enumerate a field's distinct values from
1215
+ * `core.field_indexes`. That map is built incrementally by `addToIndex`
1216
+ * within a session but is EMPTY after a cold reopen — the registered
1217
+ * SSTables hold the posting lists, but without the value enumeration the
1218
+ * read paths have no value keys to look those lists up by. Loading this JSON
1219
+ * (persisted by `persistMutationResult` / `flush`) closes that gap so the
1220
+ * cold-loaded SSTables serve every filter shape with no rebuild.
1221
+ *
1222
+ * No-op for a field with no persisted index (a brand-new field this
1223
+ * session) — `addToIndex` then populates `core.field_indexes` incrementally.
1224
+ * Only the first access loads it (the caller guards via `loadedFields`), so
1225
+ * an in-session field index built by `addToIndex` is never overwritten.
1226
+ *
1227
+ * @param field - The field whose value-enumeration index to restore.
1228
+ */
1229
+ async loadPersistedFieldIndex(field) {
1230
+ try {
1231
+ const data = await this.storage.getMetadata(`__metadata_field_index__field_${field}`);
1232
+ if (data) {
1233
+ this.native.loadFieldIndex(field, JSON.stringify(data));
1234
+ }
1235
+ }
1236
+ catch (error) {
1237
+ prodLog.debug(`Failed to load field index '${field}':`, error);
1238
+ }
1239
+ }
1240
+ /**
1241
+ * Ensure multiple fields are loaded (parallel).
1242
+ *
1243
+ * **Piece 15.** Per-field stale checks instead of one
1244
+ * index-wide check. The wrapper now iterates {@link invalidateFieldIfStale}
1245
+ * once per requested field — selectively dropping ONLY the
1246
+ * fields whose counters advanced since we last looked. Cross-
1247
+ * field churn (a mutation on `noun` while we're reading `status`)
1248
+ * no longer invalidates `status`'s lazy-load. Cost: N FFI hops
1249
+ * (each a single atomic Acquire load) instead of 1 — typically
1250
+ * 3-5 hops on a brainy filter query, negligible vs the storage
1251
+ * I/O the wrapper does next.
1252
+ */
1253
+ async ensureFieldsLoaded(fields) {
1254
+ for (const f of fields) {
1255
+ this.invalidateFieldIfStale(f);
1256
+ }
1257
+ const unloaded = fields.filter(f => !this.loadedFields.has(f));
1258
+ if (unloaded.length === 0)
1259
+ return;
1260
+ // Pass a flag to skip the redundant per-field check.
1261
+ await Promise.all(unloaded.map(f => this.ensureFieldLoadedNoCheck(f)));
1262
+ }
1263
+ /**
1264
+ * Internal variant of `ensureFieldLoaded` that skips the generation
1265
+ * check (used by `ensureFieldsLoaded` which already did one batched
1266
+ * check at the top).
1267
+ */
1268
+ async ensureFieldLoadedNoCheck(field) {
1269
+ if (this.loadedFields.has(field))
1270
+ return;
1271
+ await this.loadPersistedFieldIndex(field);
1272
+ this.loadedFields.add(field);
1273
+ }
1274
+ /**
1275
+ * Extract filter field names for lazy loading.
1276
+ */
1277
+ extractFilterFields(filter) {
1278
+ if (!filter || typeof filter !== 'object')
1279
+ return [];
1280
+ const fields = [];
1281
+ if (filter.allOf && Array.isArray(filter.allOf)) {
1282
+ for (const sub of filter.allOf) {
1283
+ fields.push(...this.extractFilterFields(sub));
1284
+ }
1285
+ }
1286
+ if (filter.anyOf && Array.isArray(filter.anyOf)) {
1287
+ for (const sub of filter.anyOf) {
1288
+ fields.push(...this.extractFilterFields(sub));
1289
+ }
1290
+ }
1291
+ for (const key of Object.keys(filter)) {
1292
+ if (key !== 'allOf' && key !== 'anyOf' && key !== 'not') {
1293
+ fields.push(key);
1294
+ }
1295
+ }
1296
+ return [...new Set(fields)];
1297
+ }
1298
+ // ==========================================================================
1299
+ // Dirty data persistence
1300
+ // ==========================================================================
1301
+ /**
1302
+ * Persist the dirty value-enumeration field indexes + field registry from a
1303
+ * MutationResult. Posting-list durability is owned by the LSM engine
1304
+ * ({@link flushLsm}), so this only writes the JSON value-enumeration surface
1305
+ * the LSM read paths cold-load.
1306
+ */
1307
+ async persistMutationResult(result) {
1308
+ const promises = [];
1309
+ // Save dirty field indexes
1310
+ for (const field of result.dirtyFieldIndexes) {
1311
+ const json = this.native.saveFieldIndex(field);
1312
+ if (json) {
1313
+ const indexId = `__metadata_field_index__field_${field}`;
1314
+ promises.push(this.storage.saveMetadata(indexId, JSON.parse(json)));
1315
+ }
1316
+ }
1317
+ // Track new fields
1318
+ for (const field of result.newFields) {
1319
+ this.knownFields.add(field);
1320
+ this.dirtyFields.add(field);
1321
+ }
1322
+ if (result.dirtyFieldRegistry) {
1323
+ promises.push(this.saveFieldRegistry());
1324
+ }
1325
+ // #72 Phase C: the entity-id mapper is the mmap BinaryIdMapper now —
1326
+ // its writes are durable in the mapped files (msync'd at flush), so
1327
+ // there is no per-mutation JSON blob to persist here.
1328
+ await Promise.all(promises);
1329
+ }
1330
+ // ==========================================================================
1331
+ // Cache warming
1332
+ // ==========================================================================
1333
+ async warmCache() {
1334
+ const commonFields = ['noun', 'type', 'service', 'createdAt'];
1335
+ await Promise.all(commonFields.map(async (field) => {
1336
+ try {
1337
+ await this.ensureFieldLoaded(field);
1338
+ }
1339
+ catch {
1340
+ prodLog.debug(`Cache warming: field '${field}' not yet indexed`);
1341
+ }
1342
+ }));
1343
+ await this.warmCacheForTopTypes(3);
1344
+ }
1345
+ async warmCacheForTopTypes(topN = 3) {
1346
+ const topTypes = this.getTopNounTypes(topN);
1347
+ if (topTypes.length === 0)
1348
+ return;
1349
+ for (const type of topTypes) {
1350
+ const typeFields = this.typeFieldAffinity.get(type);
1351
+ if (!typeFields)
1352
+ continue;
1353
+ const topFields = Array.from(typeFields.entries())
1354
+ .sort((a, b) => b[1] - a[1])
1355
+ .slice(0, 5)
1356
+ .map(([field]) => field);
1357
+ if (topFields.length === 0)
1358
+ continue;
1359
+ await Promise.all(topFields.map(async (field) => {
1360
+ try {
1361
+ await this.ensureFieldLoaded(field);
1362
+ }
1363
+ catch {
1364
+ // Silently ignore
1365
+ }
1366
+ }));
1367
+ }
1368
+ }
1369
+ // ==========================================================================
1370
+ // Lazy load counts
1371
+ // ==========================================================================
1372
+ async lazyLoadCounts() {
1373
+ try {
1374
+ this.totalEntitiesByType.clear();
1375
+ this.entityCountsByTypeFixed.fill(0);
1376
+ this.verbCountsByTypeFixed.fill(0);
1377
+ // Ensure 'noun' field is loaded into Rust
1378
+ await this.ensureFieldLoaded('noun');
1379
+ // Use Rust to get type counts via the noun field. Count natively
1380
+ // (#78 pt2) — getIdsCount returns the mapped-id count without
1381
+ // materializing one UUID string per entity. At 10B this is the
1382
+ // difference between O(1) per type and allocating billions of strings
1383
+ // just to read `.length`.
1384
+ const nounValues = this.native.getFilterValues('noun');
1385
+ for (const typeName of nounValues) {
1386
+ const count = this.native.getIdsCount('noun', JSON.stringify(typeName));
1387
+ if (count > 0) {
1388
+ this.totalEntitiesByType.set(typeName, count);
1389
+ }
1390
+ }
1391
+ prodLog.debug(`Loaded type counts: ${this.totalEntitiesByType.size} types`);
1392
+ }
1393
+ catch (error) {
1394
+ prodLog.debug('Could not load type counts:', error);
1395
+ }
1396
+ }
1397
+ syncTypeCountsToFixed() {
1398
+ for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
1399
+ const type = TypeUtils.getNounFromIndex(i);
1400
+ const count = this.totalEntitiesByType.get(type) || 0;
1401
+ this.entityCountsByTypeFixed[i] = count;
1402
+ }
1403
+ for (let i = 0; i < VERB_TYPE_COUNT; i++) {
1404
+ const type = TypeUtils.getVerbFromIndex(i);
1405
+ const count = this.totalEntitiesByType.get(type) || 0;
1406
+ this.verbCountsByTypeFixed[i] = count;
1407
+ }
1408
+ }
1409
+ syncTypeCountsFromFixed() {
1410
+ for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
1411
+ const count = this.entityCountsByTypeFixed[i];
1412
+ if (count > 0) {
1413
+ const type = TypeUtils.getNounFromIndex(i);
1414
+ this.totalEntitiesByType.set(type, count);
1415
+ }
1416
+ }
1417
+ for (let i = 0; i < VERB_TYPE_COUNT; i++) {
1418
+ const count = this.verbCountsByTypeFixed[i];
1419
+ if (count > 0) {
1420
+ const type = TypeUtils.getVerbFromIndex(i);
1421
+ this.totalEntitiesByType.set(type, count);
1422
+ }
1423
+ }
1424
+ }
1425
+ // ==========================================================================
1426
+ // Core query methods
1427
+ // ==========================================================================
1428
+ /**
1429
+ * Serialize a query value to JSON for the native String param. `JSON.stringify`
1430
+ * returns the JS value `undefined` (NOT a string) for `undefined`/functions —
1431
+ * which the napi String param rejects ("Failed to convert Null/Undefined into
1432
+ * String"). Coalesce to the JSON literal `'null'` so the Rust side parses it to
1433
+ * `serde_json::Value::Null`, normalizes, and returns the no-match result (0/[]) —
1434
+ * bug-for-bug compatible with brainy's "no match → empty". (#79 cluster B.)
1435
+ */
1436
+ serializeValue(value) {
1437
+ const s = JSON.stringify(value);
1438
+ return s === undefined ? 'null' : s;
1439
+ }
1440
+ async getIds(field, value) {
1441
+ await this.ensureFieldLoaded(field);
1442
+ return this.native.getIds(field, this.serializeValue(value));
1443
+ }
1444
+ /**
1445
+ * Resolve a `where` filter to its matching entity ids.
1446
+ *
1447
+ * @param filter - Brainy field-operator filter (eq / allOf / anyOf / ne /
1448
+ * range / exists / …).
1449
+ * @param opts - OPTIONAL page bound (#75). When `{ limit }` is supplied the
1450
+ * native producer early-stops and returns only the `[offset, offset+limit)`
1451
+ * PREFIX of the sorted match set — it short-circuits the posting-list →
1452
+ * UUID conversion at `offset+limit` instead of materializing the full list
1453
+ * and slicing, which removes the O(N) FFI marshal for a broad-filter
1454
+ * single-page `find()` at billion scale. Brainy calls this with
1455
+ * `{ limit: pageEnd, offset: 0 }` and re-windows the prefix itself
1456
+ * (visibility filter + slice), so the producer honors the literal `offset`
1457
+ * (brainy's `0` ⇒ the `[0, limit)` prefix). Omitting `opts` returns the
1458
+ * FULL match set (back-compat).
1459
+ */
1460
+ async getIdsForFilter(filter, opts) {
1461
+ if (!filter || Object.keys(filter).length === 0)
1462
+ return [];
1463
+ // Ensure the filter's fields are loaded into the native index.
1464
+ const fields = this.extractFilterFields(filter);
1465
+ await this.ensureFieldsLoaded(fields);
1466
+ // Negation operators (ne / exists:false / missing:true) need the all-ids universe.
1467
+ // The native index self-sources it from the canonical in-memory idMapper — no storage
1468
+ // scan, and correct even when the field was never registered on any entity (R4).
1469
+ // `?? null` (not `|| null`) so a `limit`/`offset` of 0 reaches native intact.
1470
+ return this.native.getIdsForFilter(JSON.stringify(filter), null, opts?.limit ?? null, opts?.offset ?? null);
1471
+ }
1472
+ async getIdsForMultipleFields(fieldValuePairs) {
1473
+ if (fieldValuePairs.length === 0)
1474
+ return [];
1475
+ const fields = fieldValuePairs.map(p => p.field);
1476
+ await this.ensureFieldsLoaded(fields);
1477
+ const pairsJson = JSON.stringify(fieldValuePairs.map(p => ({
1478
+ field: p.field,
1479
+ value: p.value
1480
+ })));
1481
+ return this.native.getIdsForMultipleFields(pairsJson);
1482
+ }
1483
+ async getIdsForTextQuery(query) {
1484
+ await this.ensureFieldLoaded('__words__');
1485
+ const resultJson = this.native.getIdsForTextQuery(query);
1486
+ return JSON.parse(resultJson);
1487
+ }
1488
+ async getSortedIdsForFilter(filter, orderBy, order = 'asc', topK) {
1489
+ const filteredIds = await this.getIdsForFilter(filter);
1490
+ if (filteredIds.length === 0)
1491
+ return [];
1492
+ const idValuePairs = [];
1493
+ for (const id of filteredIds) {
1494
+ const value = await this.getFieldValueForEntity(id, orderBy);
1495
+ idValuePairs.push({ id, value });
1496
+ }
1497
+ idValuePairs.sort((a, b) => {
1498
+ if (a.value == null && b.value == null)
1499
+ return 0;
1500
+ if (a.value == null)
1501
+ return order === 'asc' ? 1 : -1;
1502
+ if (b.value == null)
1503
+ return order === 'asc' ? -1 : 1;
1504
+ if (a.value === b.value)
1505
+ return 0;
1506
+ // Numbers compare numerically; everything else by code-point (UTF-8 byte)
1507
+ // order, matching the native column store and brainy's getSortedIdsForFilter
1508
+ // exactly (and deterministic across environments, unlike `<` for strings).
1509
+ let comparison;
1510
+ if (typeof a.value === 'number' && typeof b.value === 'number') {
1511
+ comparison = a.value < b.value ? -1 : 1;
1512
+ }
1513
+ else {
1514
+ comparison = compareCodePoints(String(a.value), String(b.value));
1515
+ }
1516
+ return order === 'asc' ? comparison : -comparison;
1517
+ });
1518
+ // Honor brainy's page bound (#79): brainy passes topK = offset+limit+hidden
1519
+ // so a broad filter + orderBy returning a small page doesn't ship every
1520
+ // matching sorted id across the FFI boundary into brainy's heap. The full
1521
+ // sort still sees all matches (required to pick the true top-K); slicing
1522
+ // bounds the RETURNED set. (Internal O(matches) value-fetch → native
1523
+ // filteredSortTopK primitive is a tracked perf follow-up, #79.)
1524
+ const bounded = topK != null && topK >= 0 ? idValuePairs.slice(0, topK) : idValuePairs;
1525
+ return bounded.map(p => p.id);
1526
+ }
1527
+ /**
1528
+ * Get field value for a specific entity (helper for sorted queries).
1529
+ *
1530
+ * Three-path lookup:
1531
+ *
1532
+ * 1. **Bucketed fields** (timestamps) — the native index stores values
1533
+ * rounded to coarser buckets to keep the index compact for range
1534
+ * queries. That bucketing loses precision, so sorting must read the
1535
+ * actual value directly from entity storage via resolveEntityField.
1536
+ *
1537
+ * 2. **Custom fields with no indexed values** — VFS fields like
1538
+ * `modified`/`accessed`, plus any user custom field not currently
1539
+ * present in the native index. Resolved from entity storage via
1540
+ * resolveEntityField, which knows the top-level-vs-metadata
1541
+ * shape contract.
1542
+ *
1543
+ * 3. **Indexed fields** — strings, enums, and low-cardinality ints that
1544
+ * the native index knows about. O(values) lookup.
1545
+ *
1546
+ * Mirrors getFieldValueForEntity in brainy's src/utils/metadataIndex.ts —
1547
+ * keep in lockstep when the entity shape contract evolves.
1548
+ */
1549
+ async getFieldValueForEntity(entityId, field) {
1550
+ // Path 1: Bucketed fields need the actual value from storage.
1551
+ if (BUCKETED_INDEX_FIELDS.has(field)) {
1552
+ const noun = await this.storage.getNoun(entityId);
1553
+ return noun ? resolveEntityField(noun, field) : undefined;
1554
+ }
1555
+ // Path 3: Scan the native index for this entity's value.
1556
+ await this.ensureFieldLoaded(field);
1557
+ const values = this.native.getFilterValues(field);
1558
+ for (const val of values) {
1559
+ const matchIds = this.native.getIds(field, this.serializeValue(val));
1560
+ if (matchIds.includes(entityId)) {
1561
+ return this.denormalizeValue(val, field);
1562
+ }
1563
+ }
1564
+ // Path 2: Not in the native index — fall back to entity storage.
1565
+ // Covers VFS custom fields (modified, accessed) and user fields
1566
+ // that haven't been inserted into the native index yet.
1567
+ const noun = await this.storage.getNoun(entityId);
1568
+ return noun ? resolveEntityField(noun, field) : undefined;
1569
+ }
1570
+ denormalizeValue(normalized, _field) {
1571
+ const asNumber = Number(normalized);
1572
+ if (!isNaN(asNumber))
1573
+ return asNumber;
1574
+ return normalized;
1575
+ }
1576
+ // ==========================================================================
1577
+ // Filter discovery
1578
+ // ==========================================================================
1579
+ async getFilterValues(field) {
1580
+ await this.ensureFieldLoaded(field);
1581
+ return this.native.getFilterValues(field);
1582
+ }
1583
+ async getFilterFields() {
1584
+ return this.native.getFilterFields();
1585
+ }
1586
+ // ==========================================================================
1587
+ // Mutation methods
1588
+ // ==========================================================================
1589
+ async addToIndex(id, entityOrMetadata, skipFlush = false, deferWrites = false) {
1590
+ // Extract field names from entity to know which fields to load
1591
+ const fieldNames = this.native.extractFieldNames(JSON.stringify(entityOrMetadata));
1592
+ await this.ensureFieldsLoaded(fieldNames);
1593
+ // Execute mutation in Rust (single FFI call)
1594
+ const resultJson = this.native.addToIndex(id, JSON.stringify(entityOrMetadata));
1595
+ const result = JSON.parse(resultJson);
1596
+ // Track new fields in loadedFields
1597
+ for (const field of result.newFields) {
1598
+ this.loadedFields.add(field);
1599
+ this.knownFields.add(field);
1600
+ }
1601
+ // Update TS-side type tracking
1602
+ this.updateTypeTracking(id, entityOrMetadata, 'add');
1603
+ // Populate the column store. The native addToIndex above assigned this
1604
+ // entity's int id, so uuidToInt now resolves. Feed the SAME indexable
1605
+ // field→value map brainy's own MetadataIndexManager feeds its column store,
1606
+ // so cor sorts/filters over an identical column set.
1607
+ const intId = this.native.uuidToInt(id);
1608
+ if (intId !== null) {
1609
+ const fieldsMap = this.extractColumnFields(entityOrMetadata);
1610
+ this.columnStore.addEntity(intId, fieldsMap);
1611
+ }
1612
+ // Persist dirty data (unless deferred for batch operations)
1613
+ if (!deferWrites) {
1614
+ await this.persistMutationResult(result);
1615
+ }
1616
+ // Adaptive auto-flush
1617
+ if (!skipFlush) {
1618
+ for (const field of fieldNames) {
1619
+ this.dirtyFields.add(field);
1620
+ }
1621
+ const timeSinceLastFlush = Date.now() - this.lastFlushTime;
1622
+ const shouldAutoFlush = this.dirtyFields.size >= this.autoFlushThreshold ||
1623
+ (this.dirtyFields.size > 10 && timeSinceLastFlush > 5000);
1624
+ if (shouldAutoFlush) {
1625
+ const startTime = Date.now();
1626
+ await this.flush();
1627
+ const flushTime = Date.now() - startTime;
1628
+ if (flushTime < 50) {
1629
+ this.autoFlushThreshold = Math.min(200, this.autoFlushThreshold * 1.2);
1630
+ }
1631
+ else if (flushTime > 200) {
1632
+ this.autoFlushThreshold = Math.max(20, this.autoFlushThreshold * 0.8);
1633
+ }
1634
+ }
1635
+ }
1636
+ // **LSM durability (step 5) — bounded-memtable flush trigger.** Drain the
1637
+ // memtable to a durable SSTable the moment its resident estimate crosses
1638
+ // the budget-tracked threshold, so it never climbs to the admission
1639
+ // ceiling and wedges writers (the whole point of the bounded memtable).
1640
+ // Runs on EVERY mutation, INDEPENDENT of `skipFlush` — `skipFlush` governs
1641
+ // only the field-index JSON flush cadence, whereas the memtable bound is a
1642
+ // hard native-memory safety constraint that must also hold on the
1643
+ // deferred-write rebuild path (`addToIndex(id, md, true, true)` per
1644
+ // entity), where a billion-entity re-index would otherwise grow the
1645
+ // memtable unbounded until admission deadlocked the rebuild. Gated on a
1646
+ // persistent engine (`lsmMetaDir !== null`): the non-persistent fallback
1647
+ // has no SSTable surface, so `flushLsm` cannot drain it and the trigger
1648
+ // would spin no-ops. When the field-index auto-flush above already drained
1649
+ // the memtable this session, `memtableBytes()` reads ~0 and this no-ops.
1650
+ const engine = this.lsmEngine;
1651
+ if (engine !== null &&
1652
+ this.lsmMetaDir !== null &&
1653
+ Number(engine.memtableBytes()) >= this.lsmFlushThresholdBytes) {
1654
+ await this.flushLsm();
1655
+ }
1656
+ this.reportLsmMemoryToResourceManager();
1657
+ }
1658
+ async removeFromIndex(id, metadata) {
1659
+ // Capture the int id BEFORE the native removal (which may drop the mapping)
1660
+ // so the column store can tombstone the same entity.
1661
+ const intId = this.native.uuidToInt(id);
1662
+ if (metadata) {
1663
+ const fieldNames = this.native.extractFieldNames(JSON.stringify(metadata));
1664
+ await this.ensureFieldsLoaded(fieldNames);
1665
+ const resultJson = this.native.removeFromIndex(id, JSON.stringify(metadata));
1666
+ const result = JSON.parse(resultJson);
1667
+ this.updateTypeTracking(id, metadata, 'remove');
1668
+ await this.persistMutationResult(result);
1669
+ }
1670
+ else {
1671
+ // Remove from all known fields (slower path). Ensure every registered
1672
+ // field's value-enumeration index is loaded so the native slow-path
1673
+ // remove (which walks `field_indexes` against the LSM snapshot) sees
1674
+ // the complete candidate set.
1675
+ prodLog.warn(`Removing ID ${id} without metadata requires scanning all fields (slow)`);
1676
+ for (const field of this.knownFields) {
1677
+ await this.ensureFieldLoaded(field);
1678
+ }
1679
+ const resultJson = this.native.removeFromIndex(id);
1680
+ const result = JSON.parse(resultJson);
1681
+ await this.persistMutationResult(result);
1682
+ }
1683
+ // Tombstone in the column store so the entity drops out of sort/filter/range.
1684
+ if (intId !== null) {
1685
+ this.columnStore.removeEntity(intId);
1686
+ }
1687
+ this.reportLsmMemoryToResourceManager();
1688
+ }
1689
+ /**
1690
+ * Report the LSM engine's current native memory footprint
1691
+ * (memtable + reader cache) to the ResourceManager so the dynamic
1692
+ * per-category rebalance can detect pressure on the 'metadata'
1693
+ * category and shift capacity from underutilized neighbors.
1694
+ *
1695
+ * No-op in JSON mode (no engine attached) and after `close()` has
1696
+ * unregistered the subscriber. Called from `addToIndex` /
1697
+ * `removeFromIndex` on the production hot path — the underlying
1698
+ * `nativeMemoryBytes()` is a single atomic load + sum, sub-microsecond
1699
+ * cost.
1700
+ *
1701
+ * The reportNativeMemory call short-circuits inside ResourceManager
1702
+ * if the subscriber name was already cleared (deregistered between
1703
+ * the mutation and this call), so the unregister race is benign.
1704
+ */
1705
+ reportLsmMemoryToResourceManager() {
1706
+ if (this.lsmEngine === null || this.lsmSubscriberName === null)
1707
+ return;
1708
+ const bytes = Number(this.lsmEngine.nativeMemoryBytes());
1709
+ ResourceManager.getInstance().reportNativeMemory(this.lsmSubscriberName, bytes);
1710
+ }
1711
+ updateTypeTracking(id, entityOrMetadata, operation) {
1712
+ const nounValue = entityOrMetadata?.type || entityOrMetadata?.noun;
1713
+ if (!nounValue)
1714
+ return;
1715
+ const entityType = String(nounValue).toLowerCase().trim();
1716
+ if (operation === 'add') {
1717
+ const current = this.totalEntitiesByType.get(entityType) || 0;
1718
+ const newCount = current + 1;
1719
+ this.totalEntitiesByType.set(entityType, newCount);
1720
+ try {
1721
+ const nounTypeIndex = TypeUtils.getNounIndex(entityType);
1722
+ this.entityCountsByTypeFixed[nounTypeIndex] = newCount;
1723
+ }
1724
+ catch {
1725
+ // Not a recognized noun type
1726
+ }
1727
+ // Update type-field affinity
1728
+ if (!this.typeFieldAffinity.has(entityType)) {
1729
+ this.typeFieldAffinity.set(entityType, new Map());
1730
+ }
1731
+ const typeFields = this.typeFieldAffinity.get(entityType);
1732
+ const fieldNames = this.native.extractFieldNames(JSON.stringify(entityOrMetadata));
1733
+ for (const field of fieldNames) {
1734
+ const count = typeFields.get(field) || 0;
1735
+ typeFields.set(field, count + 1);
1736
+ }
1737
+ }
1738
+ else if (operation === 'remove') {
1739
+ const current = this.totalEntitiesByType.get(entityType) || 0;
1740
+ if (current > 1) {
1741
+ const newCount = current - 1;
1742
+ this.totalEntitiesByType.set(entityType, newCount);
1743
+ try {
1744
+ const nounTypeIndex = TypeUtils.getNounIndex(entityType);
1745
+ this.entityCountsByTypeFixed[nounTypeIndex] = newCount;
1746
+ }
1747
+ catch { }
1748
+ }
1749
+ else {
1750
+ this.totalEntitiesByType.delete(entityType);
1751
+ this.typeFieldAffinity.delete(entityType);
1752
+ try {
1753
+ const nounTypeIndex = TypeUtils.getNounIndex(entityType);
1754
+ this.entityCountsByTypeFixed[nounTypeIndex] = 0;
1755
+ }
1756
+ catch { }
1757
+ }
1758
+ }
1759
+ }
1760
+ // ==========================================================================
1761
+ // Tokenization (delegates to Rust via extractFieldNames, but also exposed)
1762
+ // ==========================================================================
1763
+ tokenize(text) {
1764
+ if (!text)
1765
+ return [];
1766
+ return text
1767
+ .toLowerCase()
1768
+ .replace(/[^\w\s]/g, ' ')
1769
+ .split(/\s+/)
1770
+ .filter(w => w.length >= 2 && w.length <= 50)
1771
+ .filter((w, i, arr) => arr.indexOf(w) === i);
1772
+ }
1773
+ hashWord(word) {
1774
+ let hash = 2166136261;
1775
+ for (let i = 0; i < word.length; i++) {
1776
+ hash ^= word.charCodeAt(i);
1777
+ hash = Math.imul(hash, 16777619);
1778
+ }
1779
+ return hash | 0;
1780
+ }
1781
+ extractTextContent(data) {
1782
+ if (data === null || data === undefined)
1783
+ return '';
1784
+ if (typeof data === 'string')
1785
+ return data;
1786
+ if (typeof data === 'number' || typeof data === 'boolean')
1787
+ return String(data);
1788
+ if (Array.isArray(data)) {
1789
+ if (data.length > 0 && typeof data[0] === 'number')
1790
+ return '';
1791
+ return data.map(d => this.extractTextContent(d)).filter(Boolean).join(' ');
1792
+ }
1793
+ if (typeof data === 'object') {
1794
+ const skipKeys = new Set(['vector', 'embedding', 'embeddings', 'connections', 'level', 'id']);
1795
+ const texts = [];
1796
+ for (const [key, value] of Object.entries(data)) {
1797
+ if (skipKeys.has(key) || /^\d+$/.test(key))
1798
+ continue;
1799
+ const text = this.extractTextContent(value);
1800
+ if (text)
1801
+ texts.push(text);
1802
+ }
1803
+ return texts.join(' ');
1804
+ }
1805
+ return '';
1806
+ }
1807
+ /**
1808
+ * Whether a field should be indexed, honoring this index's configured
1809
+ * `excludeFields` / `indexedFields`. Mirrors the Rust `should_index_field`
1810
+ * (and brainy's TS `shouldIndexField`) so the column store indexes exactly the
1811
+ * fields the metadata index does.
1812
+ *
1813
+ * @param field - Fully-qualified field name (dotted for nested).
1814
+ * @returns True if the field is indexable under the current config.
1815
+ */
1816
+ shouldIndexField(field) {
1817
+ if (this.config.excludeFields.includes(field))
1818
+ return false;
1819
+ if (this.config.indexedFields.length > 0) {
1820
+ return this.config.indexedFields.includes(field);
1821
+ }
1822
+ return true;
1823
+ }
1824
+ /**
1825
+ * Build the indexable field→value map fed to the column store, identical to
1826
+ * brainy's `MetadataIndexManager.extractIndexableFields()` → `fieldsMap`:
1827
+ *
1828
+ * - Recurses objects; flattens a top-level `metadata` object to the root.
1829
+ * - Maps a top-level `type` field to `noun` (backward-compat with indexes).
1830
+ * - Skips the never-index set (vector/embedding/connections/level/id), purely
1831
+ * numeric keys (array indices), config-excluded fields, and arrays longer
1832
+ * than 10 elements (likely vectors/bulk data).
1833
+ * - Small arrays (≤10) become multi-value fields: one array entry per element.
1834
+ * - Adds `__words__` as a multi-value array of FNV-1a int32 word hashes from
1835
+ * the entity's text content (capped at 5000 words), matching brainy.
1836
+ *
1837
+ * Replicated in TS rather than reusing the Rust extractor because the native
1838
+ * `addToIndex` does not return the extracted pairs and the engine is frozen.
1839
+ *
1840
+ * @param data - The entity or plain-metadata object being indexed.
1841
+ * @returns Field → value (or array of values) map for `columnStore.addEntity`.
1842
+ */
1843
+ extractColumnFields(data) {
1844
+ const fieldsMap = {};
1845
+ const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'level', 'id']);
1846
+ const pushValue = (field, value) => {
1847
+ const existing = fieldsMap[field];
1848
+ if (Array.isArray(existing)) {
1849
+ existing.push(value);
1850
+ }
1851
+ else if (existing !== undefined) {
1852
+ // A second value for a scalar field → promote to a multi-value array,
1853
+ // preserving insertion order (matches brainy feeding repeated pairs).
1854
+ fieldsMap[field] = [existing, value];
1855
+ }
1856
+ else {
1857
+ fieldsMap[field] = value;
1858
+ }
1859
+ };
1860
+ const extract = (obj, prefix = '') => {
1861
+ if (!obj || typeof obj !== 'object')
1862
+ return;
1863
+ for (const [key, value] of Object.entries(obj)) {
1864
+ const fullKey = prefix ? `${prefix}.${key}` : key;
1865
+ if (!prefix && NEVER_INDEX.has(key))
1866
+ continue;
1867
+ if (/^\d+$/.test(key))
1868
+ continue;
1869
+ if (!this.shouldIndexField(fullKey))
1870
+ continue;
1871
+ // Flatten a top-level metadata object to the root (no prefix).
1872
+ if (key === 'metadata' && !prefix && value && typeof value === 'object' && !Array.isArray(value)) {
1873
+ extract(value, '');
1874
+ continue;
1875
+ }
1876
+ if (Array.isArray(value)) {
1877
+ if (value.length > 10)
1878
+ continue;
1879
+ for (const item of value) {
1880
+ if (item !== null && typeof item !== 'object') {
1881
+ pushValue(fullKey, item);
1882
+ }
1883
+ }
1884
+ }
1885
+ else if (value && typeof value === 'object') {
1886
+ extract(value, fullKey);
1887
+ }
1888
+ else if (value !== null && value !== undefined) {
1889
+ const indexField = !prefix && key === 'type' ? 'noun' : fullKey;
1890
+ pushValue(indexField, value);
1891
+ }
1892
+ }
1893
+ };
1894
+ extract(data);
1895
+ // Hybrid text-search words: FNV-1a int32 hashes, multi-value, capped at 5000.
1896
+ const textContent = this.extractTextContent(data);
1897
+ if (textContent) {
1898
+ const MAX_WORDS_PER_ENTITY = 5000;
1899
+ const words = this.tokenize(textContent).slice(0, MAX_WORDS_PER_ENTITY);
1900
+ if (words.length > 0) {
1901
+ fieldsMap.__words__ = words.map(w => this.hashWord(w));
1902
+ }
1903
+ }
1904
+ return fieldsMap;
1905
+ }
1906
+ // ==========================================================================
1907
+ // Entity counting
1908
+ // ==========================================================================
1909
+ getEntityCountByType(type) {
1910
+ return this.totalEntitiesByType.get(type) || 0;
1911
+ }
1912
+ getTotalEntityCount() {
1913
+ let total = 0;
1914
+ for (const count of this.totalEntitiesByType.values()) {
1915
+ total += count;
1916
+ }
1917
+ return total;
1918
+ }
1919
+ getAllEntityCounts() {
1920
+ return new Map(this.totalEntitiesByType);
1921
+ }
1922
+ getEntityCountByTypeEnum(type) {
1923
+ const index = TypeUtils.getNounIndex(type);
1924
+ return this.entityCountsByTypeFixed[index];
1925
+ }
1926
+ getVerbCountByTypeEnum(type) {
1927
+ const index = TypeUtils.getVerbIndex(type);
1928
+ return this.verbCountsByTypeFixed[index];
1929
+ }
1930
+ getTopNounTypes(n) {
1931
+ const types = [];
1932
+ for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
1933
+ const count = this.entityCountsByTypeFixed[i];
1934
+ if (count > 0) {
1935
+ const type = TypeUtils.getNounFromIndex(i);
1936
+ types.push({ type, count });
1937
+ }
1938
+ }
1939
+ return types.sort((a, b) => b.count - a.count).slice(0, n).map(t => t.type);
1940
+ }
1941
+ getTopVerbTypes(n) {
1942
+ const types = [];
1943
+ for (let i = 0; i < VERB_TYPE_COUNT; i++) {
1944
+ const count = this.verbCountsByTypeFixed[i];
1945
+ if (count > 0) {
1946
+ const type = TypeUtils.getVerbFromIndex(i);
1947
+ types.push({ type, count });
1948
+ }
1949
+ }
1950
+ return types.sort((a, b) => b.count - a.count).slice(0, n).map(t => t.type);
1951
+ }
1952
+ getAllNounTypeCounts() {
1953
+ const counts = new Map();
1954
+ for (let i = 0; i < NOUN_TYPE_COUNT; i++) {
1955
+ const count = this.entityCountsByTypeFixed[i];
1956
+ if (count > 0) {
1957
+ counts.set(TypeUtils.getNounFromIndex(i), count);
1958
+ }
1959
+ }
1960
+ return counts;
1961
+ }
1962
+ getAllVerbTypeCounts() {
1963
+ const counts = new Map();
1964
+ for (let i = 0; i < VERB_TYPE_COUNT; i++) {
1965
+ const count = this.verbCountsByTypeFixed[i];
1966
+ if (count > 0) {
1967
+ counts.set(TypeUtils.getVerbFromIndex(i), count);
1968
+ }
1969
+ }
1970
+ return counts;
1971
+ }
1972
+ // ==========================================================================
1973
+ // VFS Statistics (roaring bitmap intersection via Rust)
1974
+ // ==========================================================================
1975
+ async getVFSEntityCountByType(type) {
1976
+ await this.ensureFieldsLoaded(['isVFSEntity', 'noun']);
1977
+ const vfsIds = this.native.getIds('isVFSEntity', JSON.stringify(true));
1978
+ const typeIds = this.native.getIds('noun', JSON.stringify(type));
1979
+ const vfsSet = new Set(vfsIds);
1980
+ return typeIds.filter(id => vfsSet.has(id)).length;
1981
+ }
1982
+ async getAllVFSEntityCounts() {
1983
+ await this.ensureFieldsLoaded(['isVFSEntity', 'noun']);
1984
+ const vfsIds = this.native.getIds('isVFSEntity', JSON.stringify(true));
1985
+ if (vfsIds.length === 0)
1986
+ return new Map();
1987
+ const vfsSet = new Set(vfsIds);
1988
+ const result = new Map();
1989
+ for (const type of this.totalEntitiesByType.keys()) {
1990
+ const typeIds = this.native.getIds('noun', JSON.stringify(type));
1991
+ const count = typeIds.filter(id => vfsSet.has(id)).length;
1992
+ if (count > 0)
1993
+ result.set(type, count);
1994
+ }
1995
+ return result;
1996
+ }
1997
+ async getTotalVFSEntityCount() {
1998
+ await this.ensureFieldLoaded('isVFSEntity');
1999
+ // Native count — no per-entity UUID materialization (#78 pt2).
2000
+ return this.native.getIdsCount('isVFSEntity', JSON.stringify(true));
2001
+ }
2002
+ // ==========================================================================
2003
+ // Criteria-based counting
2004
+ // ==========================================================================
2005
+ async getCountForCriteria(field, value) {
2006
+ // Native count — mirrors getIds(field, value).length exactly but allocates
2007
+ // no UUID strings (#78 pt2).
2008
+ await this.ensureFieldLoaded(field);
2009
+ return this.native.getIdsCount(field, this.serializeValue(value));
2010
+ }
2011
+ // ==========================================================================
2012
+ // Flush
2013
+ // ==========================================================================
2014
+ async flush() {
2015
+ // Always save field registry + msync the entity ID mapper — even with
2016
+ // no dirty fields. The field registry is the critical file init() needs
2017
+ // to discover persisted indices. Without it, the metadata index appears
2018
+ // empty after restart.
2019
+ if (this.knownFields.size > 0) {
2020
+ await this.saveFieldRegistry();
2021
+ }
2022
+ // #72 Phase C: msync the mmap BinaryIdMapper FIRST, before the bitmaps /
2023
+ // column segments that reference its ints — so a crash can never leave a
2024
+ // persisted bitmap pointing at an int the mapper has not yet durably
2025
+ // assigned (which would silently drop those rows on reopen).
2026
+ this.idMapperNative?.flush();
2027
+ // LSM durability (steps 3-4): drain the memtable to a durable SSTable +
2028
+ // manifest. Runs AFTER the id-mapper msync above (the SSTable's posting
2029
+ // lists reference entity ints that must be durably assigned first) and
2030
+ // BEFORE the dirty-field early-return below (the memtable can hold
2031
+ // un-persisted writes even when no field index is dirty — e.g. a
2032
+ // deferred-write batch). No-op when the memtable is empty or on the
2033
+ // non-persistent fallback engine.
2034
+ await this.flushLsm();
2035
+ // Flush the column store unconditionally — drains tail buffers to L0 `.cidx`
2036
+ // segments and persists manifests + deleted bitmaps. Must run even when no
2037
+ // field indexes are dirty (e.g. a deferred-write batch buffered column data
2038
+ // without touching the dirty-field set), so orderBy survives a reopen.
2039
+ await this.columnStore.flush();
2040
+ if (this.dirtyFields.size === 0)
2041
+ return;
2042
+ // Save dirty field indexes
2043
+ const promises = [];
2044
+ for (const field of this.dirtyFields) {
2045
+ const json = this.native.saveFieldIndex(field);
2046
+ if (json) {
2047
+ const indexId = `__metadata_field_index__field_${field}`;
2048
+ promises.push(this.storage.saveMetadata(indexId, JSON.parse(json)));
2049
+ }
2050
+ }
2051
+ await Promise.all(promises);
2052
+ // Save field registry (the mmap mapper was already msync'd above).
2053
+ await this.saveFieldRegistry();
2054
+ this.dirtyFields.clear();
2055
+ this.lastFlushTime = Date.now();
2056
+ }
2057
+ /**
2058
+ * Persist all state and release the column store's resources. Brainy's
2059
+ * shutdown path calls `metadataIndex.close()` when present; flushing here
2060
+ * guarantees the column store's tail buffers reach disk before the process
2061
+ * exits, so a reopened index can serve `orderBy` from persisted segments.
2062
+ */
2063
+ async close() {
2064
+ await this.flush();
2065
+ await this.columnStore.close();
2066
+ // P3-5: deregister the ResourceManager subscriber so the rebalance
2067
+ // loop stops calling our callback after shutdown. The engine
2068
+ // itself stays alive as long as `this.native` (via setLsmEngine's
2069
+ // Arc clone) or any held snapshot holds a reference — extra
2070
+ // post-close setAllowedBytes calls would be harmless but we want
2071
+ // the loop to be efficient. Unsubscribing is idempotent; the
2072
+ // returned deregistration function silently no-ops on second call.
2073
+ if (this.unsubscribeFromBudget !== null) {
2074
+ this.unsubscribeFromBudget();
2075
+ this.unsubscribeFromBudget = null;
2076
+ this.lsmSubscriberName = null;
2077
+ }
2078
+ }
2079
+ // ==========================================================================
2080
+ // Stats
2081
+ // ==========================================================================
2082
+ async getStats() {
2083
+ const statsJson = this.native.getStats();
2084
+ const stats = JSON.parse(statsJson);
2085
+ return {
2086
+ totalEntries: stats.totalEntries || 0,
2087
+ totalIds: 0,
2088
+ fieldsIndexed: stats.fieldsIndexed || [],
2089
+ lastRebuild: Date.now(),
2090
+ indexSize: (stats.totalEntries || 0) * 100
2091
+ };
2092
+ }
2093
+ /**
2094
+ * @description Report which index path a `find({ where: { [field]: ... } })`
2095
+ * clause will hit. Brainy's `brain.explain()` calls this per `where` field so an
2096
+ * operator can see *before* running a query whether the field has any index
2097
+ * entries at all — a `where` on an unindexed field returns `[]` silently, and
2098
+ * `path: 'none'` gives that empty result an explanation.
2099
+ *
2100
+ * The native engine serves all `where` lookups from the column store (the
2101
+ * single source of truth — there is no separate legacy sparse-index path at
2102
+ * query time, unlike brainy's TypeScript baseline). So a field either has
2103
+ * column-store entries (`'column-store'`) or none (`'none'`). The field's
2104
+ * segments are loaded first so `hasField` reflects on-disk state, not just
2105
+ * what has been touched this session.
2106
+ *
2107
+ * @param field - The `where`-clause field name to explain.
2108
+ * @returns The serving path and an operator-facing note.
2109
+ * @example
2110
+ * const { path, notes } = await metadataIndex.explainField('status')
2111
+ * if (path === 'none') console.warn(notes)
2112
+ */
2113
+ async explainField(field) {
2114
+ await this.ensureFieldLoaded(field);
2115
+ if (this.columnStore.hasField(field)) {
2116
+ return {
2117
+ path: 'column-store',
2118
+ notes: 'O(log n) binary search + roaring bitmap (native Rust column store). Best path.'
2119
+ };
2120
+ }
2121
+ return {
2122
+ path: 'none',
2123
+ notes: `No index entries for field "${field}". A find({ where: { ${field}: ... } }) ` +
2124
+ `will return an empty result regardless of whether matching entities exist on disk. ` +
2125
+ `Likely causes: (1) the writer registered the field in memory but has not flushed; ` +
2126
+ `(2) the field name does not match what was written (typo or casing); ` +
2127
+ `(3) the field is genuinely absent from all entities. Call requestFlush() on the ` +
2128
+ `writer or call brain.flush() before relying on the result.`
2129
+ };
2130
+ }
2131
+ // ==========================================================================
2132
+ // Validation
2133
+ // ==========================================================================
2134
+ async validateConsistency() {
2135
+ const entityCount = this.native.entityIdMapperSize();
2136
+ if (entityCount === 0) {
2137
+ return {
2138
+ healthy: true,
2139
+ avgEntriesPerEntity: 0,
2140
+ entityCount: 0,
2141
+ indexEntryCount: 0,
2142
+ recommendation: null
2143
+ };
2144
+ }
2145
+ // Load all fields to count entries
2146
+ for (const field of this.knownFields) {
2147
+ await this.ensureFieldLoaded(field);
2148
+ }
2149
+ const statsJson = this.native.getStats();
2150
+ const stats = JSON.parse(statsJson);
2151
+ const indexEntryCount = stats.totalEntries || 0;
2152
+ const avgEntriesPerEntity = indexEntryCount / entityCount;
2153
+ const CORRUPTION_THRESHOLD = 100;
2154
+ const healthy = avgEntriesPerEntity <= CORRUPTION_THRESHOLD;
2155
+ let recommendation = null;
2156
+ if (!healthy) {
2157
+ recommendation = `Index corruption detected (${avgEntriesPerEntity.toFixed(1)} avg entries/entity, expected ~30). ` +
2158
+ `Run brain.index.clearAllIndexData() followed by brain.index.rebuild() to repair.`;
2159
+ }
2160
+ return { healthy, avgEntriesPerEntity, entityCount, indexEntryCount, recommendation };
2161
+ }
2162
+ /**
2163
+ * O(1)-ish cross-bucket consistency probe (brainy rc.6 matrix 7.7).
2164
+ *
2165
+ * Brainy calls this ONCE on the first read; on `false` it runs
2166
+ * {@link detectAndRepairCorruption}. The check is fully internal — it never
2167
+ * re-reads entity data. It loads a bounded set of value-enumeration field
2168
+ * indexes (so the native sampler can enumerate each field's value buckets),
2169
+ * biased toward `noun` (the canonical single-valued type field where the
2170
+ * staff→timeslot class of phantom lands), then defers to the native
2171
+ * {@link NativeMetadataIndexInstance.probeConsistency} bounded sampler.
2172
+ *
2173
+ * Best-effort: any error (a sampling glitch, a storage hiccup loading a field
2174
+ * index) degrades to `true` so the probe NEVER breaks brainy's read path.
2175
+ *
2176
+ * @returns `true` if the index looks consistent (or the check is
2177
+ * indeterminate), `false` if a cross-bucket phantom was sampled.
2178
+ */
2179
+ async probeConsistency() {
2180
+ try {
2181
+ // Load value-enumeration field indexes so the native sampler can
2182
+ // enumerate each field's buckets. Bounded to keep the probe cheap: at
2183
+ // most PROBE_MAX_FIELDS small field-index JSON blobs, `noun` first. The
2184
+ // native check itself is O(1)-ish (a bounded sample of ints against a
2185
+ // bounded set of buckets) — it never walks the full index.
2186
+ const PROBE_MAX_FIELDS = 64;
2187
+ let loaded = 0;
2188
+ if (this.knownFields.has('noun')) {
2189
+ await this.ensureFieldLoaded('noun');
2190
+ loaded++;
2191
+ }
2192
+ for (const field of this.knownFields) {
2193
+ if (loaded >= PROBE_MAX_FIELDS)
2194
+ break;
2195
+ if (field === 'noun' || field === '__words__')
2196
+ continue;
2197
+ await this.ensureFieldLoaded(field);
2198
+ loaded++;
2199
+ }
2200
+ return this.native.probeConsistency();
2201
+ }
2202
+ catch (error) {
2203
+ // A probe must never throw into the read path — assume healthy.
2204
+ prodLog.debug(`[probeConsistency] benign sampling error — assuming healthy: ${error}`);
2205
+ return true;
2206
+ }
2207
+ }
2208
+ async detectAndRepairCorruption() {
2209
+ const validation = await this.validateConsistency();
2210
+ if (!validation.healthy) {
2211
+ prodLog.warn(`Index corruption detected (${validation.avgEntriesPerEntity.toFixed(1)} avg entries/entity)`);
2212
+ prodLog.warn('Auto-rebuilding index to repair...');
2213
+ await this.clearAllIndexData();
2214
+ await this.rebuild();
2215
+ const postRebuild = await this.validateConsistency();
2216
+ if (postRebuild.healthy) {
2217
+ prodLog.info(`Index rebuilt successfully (${postRebuild.avgEntriesPerEntity.toFixed(1)} avg entries/entity)`);
2218
+ }
2219
+ else {
2220
+ prodLog.error(`Index still appears corrupted after rebuild (${postRebuild.avgEntriesPerEntity.toFixed(1)} avg entries/entity).`);
2221
+ }
2222
+ // The full rebuild re-indexed every entity from storage (the source of
2223
+ // truth) — each entity lands in exactly its current value bucket, so no
2224
+ // cross-bucket phantom can survive. The targeted purge below is therefore
2225
+ // redundant after a rebuild.
2226
+ return;
2227
+ }
2228
+ // The avg-entries heuristic above catches GROSS corruption (a runaway
2229
+ // entries/entity ratio) but NOT the cross-bucket phantom: one stale posting
2230
+ // that survived a value change barely moves the ratio. Run the targeted
2231
+ // state-level purge so brainy's cold-open self-heal (matrix 7.8b) repairs a
2232
+ // pre-poisoned index at the STATE level, not just masking it at query time.
2233
+ await this.purgeCrossBucketPhantoms();
2234
+ }
2235
+ /**
2236
+ * State-level cross-bucket phantom purge (brainy matrix 7.8b).
2237
+ *
2238
+ * Scans the native index for entities whose int sits in more than one value
2239
+ * bucket of a field, then tombstones the stale buckets against each entity's
2240
+ * CURRENT stored value(s) — read straight from storage (brainy's source of
2241
+ * truth), NOT from the index being repaired. A multi-valued field keeps all
2242
+ * its stored values (a no-op); a single-valued phantom is purged down to the
2243
+ * one current value; an orphaned int (entity gone) is evicted entirely.
2244
+ *
2245
+ * Only reached after {@link probeConsistency} flagged the index, so the
2246
+ * full field-index load + scan is acceptable here (unlike the O(1) probe).
2247
+ */
2248
+ async purgeCrossBucketPhantoms() {
2249
+ // The scan enumerates each field's value buckets from the in-memory
2250
+ // value-enumeration surface, so every known field must be loaded first
2251
+ // (mirrors validateConsistency's load-all).
2252
+ for (const field of this.knownFields) {
2253
+ await this.ensureFieldLoaded(field);
2254
+ }
2255
+ // Cap the scan so a massively-corrupt index produces a bounded list; the
2256
+ // remainder is healed on a subsequent cold-open (probe → repair again).
2257
+ const PHANTOM_SCAN_CAP = 100_000;
2258
+ let candidates;
2259
+ try {
2260
+ candidates = JSON.parse(this.native.detectCrossBucketPhantoms(PHANTOM_SCAN_CAP));
2261
+ }
2262
+ catch (error) {
2263
+ prodLog.warn(`[purgeCrossBucketPhantoms] scan failed (skipping): ${error}`);
2264
+ return;
2265
+ }
2266
+ if (candidates.length === 0)
2267
+ return;
2268
+ prodLog.warn(`[purgeCrossBucketPhantoms] ${candidates.length} cross-bucket candidate(s) — repairing against stored values`);
2269
+ const repairedFields = new Set();
2270
+ let purged = 0;
2271
+ for (const { uuid, field } of candidates) {
2272
+ // The entity's CURRENT value(s) for this field — brainy's source of
2273
+ // truth, read from storage (NOT the index we're repairing). For `noun`
2274
+ // resolveEntityField maps to entity.type; other fields read entity
2275
+ // metadata. An empty result (entity gone / field dropped) keeps nothing,
2276
+ // so the orphan int is evicted from every bucket of the field.
2277
+ let currentValues = [];
2278
+ try {
2279
+ const noun = await this.storage.getNoun(uuid);
2280
+ if (noun) {
2281
+ const raw = resolveEntityField(noun, field);
2282
+ if (raw !== undefined && raw !== null) {
2283
+ currentValues = Array.isArray(raw) ? raw : [raw];
2284
+ }
2285
+ }
2286
+ }
2287
+ catch (error) {
2288
+ prodLog.debug(`[purgeCrossBucketPhantoms] could not read stored ${uuid}.${field}: ${error}`);
2289
+ }
2290
+ try {
2291
+ const didPurge = this.native.repairEntityFieldPhantom(uuid, field, JSON.stringify(currentValues));
2292
+ if (didPurge) {
2293
+ purged++;
2294
+ repairedFields.add(field);
2295
+ }
2296
+ }
2297
+ catch (error) {
2298
+ prodLog.warn(`[purgeCrossBucketPhantoms] repair of ${uuid}.${field} failed: ${error}`);
2299
+ }
2300
+ }
2301
+ // Persist the value-enumeration field indexes the purge mutated so a cold
2302
+ // reopen doesn't re-enumerate an emptied stale value.
2303
+ for (const field of repairedFields) {
2304
+ const json = this.native.saveFieldIndex(field);
2305
+ if (json) {
2306
+ await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, JSON.parse(json));
2307
+ }
2308
+ }
2309
+ if (purged > 0) {
2310
+ prodLog.info(`[purgeCrossBucketPhantoms] purged ${purged} stale cross-bucket posting set(s) across ${repairedFields.size} field(s)`);
2311
+ // Drain the eviction tombstones to a durable SSTable so the repair
2312
+ // survives a restart even without further writes.
2313
+ await this.flush();
2314
+ }
2315
+ }
2316
+ // ==========================================================================
2317
+ // Rebuild
2318
+ // ==========================================================================
2319
+ async rebuild() {
2320
+ if (this.skipRebuildOnce) {
2321
+ // getIdMapper().rebuild() (brainy restore() mapper hook) already ran the
2322
+ // full rebuild this cycle; skipping here keeps the entity-id mapper stable
2323
+ // for the concurrent graphIndex.rebuild() in brainy's Promise.all instead
2324
+ // of re-clearing it mid-flight (which silently drops resolved edges). (#68)
2325
+ this.skipRebuildOnce = false;
2326
+ prodLog.debug('NativeMetadataIndex.rebuild(): skipped — getIdMapper().rebuild() already rebuilt this restore cycle');
2327
+ return;
2328
+ }
2329
+ if (this.isRebuilding)
2330
+ return;
2331
+ this.isRebuilding = true;
2332
+ try {
2333
+ prodLog.info('Starting metadata index rebuild with batch processing...');
2334
+ // **LSM durability (step 6) — LSM-aware rebuild teardown.** A rebuild
2335
+ // commonly follows brainy's `restore()`, which WIPES and replaces the
2336
+ // storage tree under the still-live engine. `native.clear()` below
2337
+ // cannot reset the attached engine (it owns its memtable + live-SSTable
2338
+ // set out of `clear()`'s reach), so the engine must be re-opened fresh
2339
+ // against the new tree BEFORE the re-index walk — otherwise its stale
2340
+ // mmap'd readers (which survive the files' unlink) keep serving
2341
+ // pre-restore rows and the stale manifest dangles. {@link
2342
+ // reopenLsmEngineFresh} wipes the stale `_metadata/sstables/*` + manifest
2343
+ // + memtable logs, recreates the memtable dir, opens a fresh
2344
+ // `openWithDir` engine, and re-attaches it. No-op on the non-persistent
2345
+ // fallback. (Supersedes the steps-3+4 minimal memtable-dir recreate —
2346
+ // the wipe+re-open subsumes the `mkdir`.)
2347
+ await this.reopenLsmEngineFresh();
2348
+ // Clear in-memory index state (field registry + value enumeration).
2349
+ // #72 Phase C: native.clear() does NOT reset the shared mmap id-mapper
2350
+ // (it has no in-place clear, and truncating its files while the old mmap
2351
+ // is still mapped would risk the kernel flushing stale MAP_SHARED pages
2352
+ // over fresh data). The mapper persists, so the rebuild's addToIndex
2353
+ // calls re-resolve each surviving UUID to its SAME int via get_or_assign
2354
+ // — ints are stable across rebuild. The column store is still reset +
2355
+ // repopulated below so its segments are rebuilt cleanly from those
2356
+ // (identical) ints. The only residue is orphaned int→uuid entries for
2357
+ // entities dropped by a restore-to-different-snapshot — a bounded space
2358
+ // leak, never a read-correctness issue.
2359
+ this.native.clear();
2360
+ await this.columnStore.reset();
2361
+ this.loadedFields.clear();
2362
+ this.knownFields.clear();
2363
+ this.dirtyFields.clear();
2364
+ this.totalEntitiesByType.clear();
2365
+ this.entityCountsByTypeFixed.fill(0);
2366
+ this.verbCountsByTypeFixed.fill(0);
2367
+ this.typeFieldAffinity.clear();
2368
+ this.unifiedCache.clear('metadata');
2369
+ // Delete existing persisted field indexes from storage
2370
+ prodLog.info('Clearing existing metadata field indexes from storage...');
2371
+ const existingFields = await this.getPersistedFieldList();
2372
+ if (existingFields.length > 0) {
2373
+ for (const field of existingFields) {
2374
+ await this.deletePersistedFieldIndex(field);
2375
+ }
2376
+ try {
2377
+ await this.storage.saveMetadata('__metadata_field_registry__', null);
2378
+ }
2379
+ catch { }
2380
+ prodLog.info(`Cleared ${existingFields.length} field indexes from storage`);
2381
+ }
2382
+ // #72 Phase C: there is no `brainy:entityIdMapper` JSON blob to clear —
2383
+ // the mapper is the mmap BinaryIdMapper, which persists across rebuild.
2384
+ // Rebuild noun + verb metadata indexes via ONE cursor-paginated path for
2385
+ // EVERY storage type (see rebuildEntityMetadata). The previous
2386
+ // local-vs-cloud fork OOM'd / silently TRUNCATED at scale: the local
2387
+ // branch loaded the whole corpus in a single getNouns/getVerbs({limit:1e6})
2388
+ // call (cap 1M), and the cloud branch capped at MAX_ITERATIONS(10000)×
2389
+ // batch(25) ≈ 250K. (#74 — perf-at-all-scales mandate.)
2390
+ const totalNounsProcessed = await this.rebuildEntityMetadata('noun');
2391
+ const totalVerbsProcessed = await this.rebuildEntityMetadata('verb');
2392
+ // Final flush
2393
+ await this.flushRebuildDirty();
2394
+ await this.flush();
2395
+ prodLog.info(`Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`);
2396
+ }
2397
+ finally {
2398
+ this.isRebuilding = false;
2399
+ }
2400
+ }
2401
+ /**
2402
+ * Cursor-paginate every entity of a kind ('noun'|'verb') through the metadata
2403
+ * index during rebuild. ONE streaming path for ALL storage types — replaces
2404
+ * the old local-vs-cloud fork that OOM'd / silently TRUNCATED at scale: the
2405
+ * local branch loaded the whole corpus in a single `getNouns/getVerbs({limit:
2406
+ * 1e6})` call (cap 1M), and the cloud branch capped at MAX_ITERATIONS(10000)×
2407
+ * batch(25) ≈ 250K. Cursor pagination is O(page) RAM and complete at any
2408
+ * scale; it terminates on `hasMore === false` (8.0's cursor is no-rescan), with
2409
+ * an empty-page backstop. (#74 — perf-at-all-scales mandate.)
2410
+ * @returns the number of entities indexed (those carrying metadata).
2411
+ */
2412
+ async rebuildEntityMetadata(kind) {
2413
+ const PAGE = 1000;
2414
+ let cursor = undefined;
2415
+ let hasMore = true;
2416
+ let processed = 0;
2417
+ let sinceFlush = 0;
2418
+ while (hasMore) {
2419
+ const page = kind === 'noun'
2420
+ ? await this.storage.getNouns({ pagination: { limit: PAGE, cursor } })
2421
+ : await this.storage.getVerbs({ pagination: { limit: PAGE, cursor } });
2422
+ const items = page.items;
2423
+ if (items.length === 0)
2424
+ break; // empty page = done (defensive backstop)
2425
+ const ids = items.map((it) => it.id);
2426
+ const md = await this.loadRebuildMetadataBatch(kind, ids);
2427
+ for (const it of items) {
2428
+ const metadata = md.get(it.id);
2429
+ if (metadata) {
2430
+ await this.addToIndex(it.id, metadata, true, true);
2431
+ processed++;
2432
+ if (++sinceFlush >= 5000) {
2433
+ await this.flushRebuildDirty();
2434
+ sinceFlush = 0;
2435
+ }
2436
+ }
2437
+ }
2438
+ hasMore = page.hasMore;
2439
+ cursor = page.nextCursor;
2440
+ await this.yieldToEventLoop();
2441
+ }
2442
+ return processed;
2443
+ }
2444
+ /**
2445
+ * Batch-load metadata for a page of noun/verb ids — prefers the storage
2446
+ * adapter's batch API, falls back to per-id reads.
2447
+ */
2448
+ async loadRebuildMetadataBatch(kind, ids) {
2449
+ if (kind === 'noun') {
2450
+ if (this.storage.getMetadataBatch)
2451
+ return this.storage.getMetadataBatch(ids);
2452
+ }
2453
+ else if (this.storage.getVerbMetadataBatch) {
2454
+ return this.storage.getVerbMetadataBatch(ids);
2455
+ }
2456
+ const m = new Map();
2457
+ for (const id of ids) {
2458
+ try {
2459
+ const md = kind === 'noun'
2460
+ ? await this.storage.getNounMetadata(id)
2461
+ : await this.storage.getVerbMetadata(id);
2462
+ if (md)
2463
+ m.set(id, md);
2464
+ }
2465
+ catch { }
2466
+ }
2467
+ return m;
2468
+ }
2469
+ /**
2470
+ * During rebuild, msync the durable state accumulated so far.
2471
+ *
2472
+ * Posting lists land in the LSM engine's memtable (drained to durable
2473
+ * SSTables by the step-5 size-flush trigger as the re-index walk proceeds)
2474
+ * and the entity ints land in the mmap id mapper — so the only durable
2475
+ * write to force here is the mapper msync. The value-enumeration field
2476
+ * indexes + the LSM memtable tail are persisted by the final {@link flush}
2477
+ * at the end of the rebuild.
2478
+ */
2479
+ async flushRebuildDirty() {
2480
+ // #72 Phase C: msync the mmap mapper (no JSON blob to write).
2481
+ this.idMapperNative?.flush();
2482
+ }
2483
+ // ==========================================================================
2484
+ // Clear
2485
+ // ==========================================================================
2486
+ async clearAllIndexData() {
2487
+ prodLog.warn('Clearing ALL metadata index data from storage...');
2488
+ const fields = await this.getPersistedFieldList();
2489
+ for (const field of fields) {
2490
+ await this.deletePersistedFieldIndex(field);
2491
+ }
2492
+ try {
2493
+ await this.storage.saveMetadata('__metadata_field_registry__', null);
2494
+ }
2495
+ catch { }
2496
+ // Clear in-memory index state. #72 Phase C: the shared mmap id-mapper
2497
+ // persists (native.clear() no longer resets it); the rebuild that
2498
+ // follows this repair re-resolves each surviving UUID to its SAME int,
2499
+ // so the reset column store repopulates consistently.
2500
+ this.native.clear();
2501
+ await this.columnStore.reset();
2502
+ this.loadedFields.clear();
2503
+ this.knownFields.clear();
2504
+ this.dirtyFields.clear();
2505
+ this.totalEntitiesByType.clear();
2506
+ this.entityCountsByTypeFixed.fill(0);
2507
+ this.verbCountsByTypeFixed.fill(0);
2508
+ this.typeFieldAffinity.clear();
2509
+ this.unifiedCache.clear('metadata');
2510
+ prodLog.info(`Cleared ${fields.length} field indexes and all in-memory state`);
2511
+ }
2512
+ async getPersistedFieldList() {
2513
+ try {
2514
+ const registry = await this.storage.getMetadata('__metadata_field_registry__');
2515
+ if (!registry?.fields || !Array.isArray(registry.fields))
2516
+ return [];
2517
+ return registry.fields.filter((f) => typeof f === 'string' && f.length > 0);
2518
+ }
2519
+ catch {
2520
+ return [];
2521
+ }
2522
+ }
2523
+ /**
2524
+ * Delete a field's persisted value-enumeration index
2525
+ * (`__metadata_field_index__field_<field>`) from storage. Called by
2526
+ * {@link rebuild} and {@link clearAllIndexData} to wipe the JSON
2527
+ * value-enumeration surface before re-indexing; the posting lists
2528
+ * themselves are wiped from the LSM engine separately
2529
+ * ({@link reopenLsmEngineFresh}).
2530
+ */
2531
+ async deletePersistedFieldIndex(field) {
2532
+ try {
2533
+ await this.storage.saveMetadata(`__metadata_field_index__field_${field}`, null);
2534
+ }
2535
+ catch {
2536
+ prodLog.debug(`Could not clear field index for field '${field}'`);
2537
+ }
2538
+ }
2539
+ // ==========================================================================
2540
+ // Field statistics (TS-only analytics)
2541
+ // ==========================================================================
2542
+ async getFieldStatistics() {
2543
+ for (const field of this.knownFields) {
2544
+ if (!this.fieldStats.has(field)) {
2545
+ this.fieldStats.set(field, {
2546
+ cardinality: {
2547
+ uniqueValues: 0,
2548
+ totalValues: 0,
2549
+ distribution: 'uniform',
2550
+ updateFrequency: 0,
2551
+ lastAnalyzed: Date.now()
2552
+ },
2553
+ queryCount: 0,
2554
+ rangeQueryCount: 0,
2555
+ exactQueryCount: 0,
2556
+ avgQueryTime: 0,
2557
+ indexType: 'hash'
2558
+ });
2559
+ }
2560
+ }
2561
+ return new Map(this.fieldStats);
2562
+ }
2563
+ async getFieldCardinality(field) {
2564
+ const stats = this.fieldStats.get(field);
2565
+ return stats ? stats.cardinality : null;
2566
+ }
2567
+ async getFieldsWithCardinality() {
2568
+ const fields = [];
2569
+ for (const [field, stats] of this.fieldStats) {
2570
+ fields.push({
2571
+ field,
2572
+ cardinality: stats.cardinality.uniqueValues,
2573
+ distribution: stats.cardinality.distribution
2574
+ });
2575
+ }
2576
+ fields.sort((a, b) => a.cardinality - b.cardinality);
2577
+ return fields;
2578
+ }
2579
+ async getOptimalQueryPlan(filters) {
2580
+ const fieldOrder = [];
2581
+ let hasRangeQueries = false;
2582
+ let totalEstimatedCost = 0;
2583
+ for (const [field, value] of Object.entries(filters)) {
2584
+ const stats = this.fieldStats.get(field);
2585
+ if (!stats)
2586
+ continue;
2587
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
2588
+ hasRangeQueries = true;
2589
+ }
2590
+ const cardinality = stats.cardinality.uniqueValues;
2591
+ totalEstimatedCost += Math.log2(Math.max(1, cardinality));
2592
+ fieldOrder.push(field);
2593
+ }
2594
+ fieldOrder.sort((a, b) => {
2595
+ const statsA = this.fieldStats.get(a);
2596
+ const statsB = this.fieldStats.get(b);
2597
+ if (!statsA || !statsB)
2598
+ return 0;
2599
+ return statsA.cardinality.uniqueValues - statsB.cardinality.uniqueValues;
2600
+ });
2601
+ return {
2602
+ strategy: hasRangeQueries ? 'hybrid' : 'exact',
2603
+ fieldOrder,
2604
+ estimatedCost: totalEstimatedCost
2605
+ };
2606
+ }
2607
+ async getFieldsForType(nounType) {
2608
+ const typeFields = this.typeFieldAffinity.get(nounType);
2609
+ const totalEntities = this.totalEntitiesByType.get(nounType);
2610
+ if (!typeFields || !totalEntities)
2611
+ return [];
2612
+ const result = [];
2613
+ for (const [field, count] of typeFields.entries()) {
2614
+ result.push({
2615
+ field,
2616
+ affinity: count / totalEntities,
2617
+ occurrences: count,
2618
+ totalEntities
2619
+ });
2620
+ }
2621
+ result.sort((a, b) => b.affinity - a.affinity);
2622
+ return result;
2623
+ }
2624
+ // ==========================================================================
2625
+ // Private helpers
2626
+ // ==========================================================================
2627
+ async yieldToEventLoop() {
2628
+ return new Promise(resolve => setImmediate(resolve));
2629
+ }
2630
+ }
2631
+ //# sourceMappingURL=NativeMetadataIndex.js.map