@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,960 @@
1
+ /**
2
+ * @module resource/ResourceManager
3
+ * @description Adaptive resource manager for multi-instance Brainy deployments.
4
+ *
5
+ * Observes system resources (RAM, CPU, cgroups limits, active instance count)
6
+ * and dynamically adjusts cache sizing and memory budgets. All products
7
+ * (Workshop, Venue, Academy) benefit automatically — zero configuration needed.
8
+ *
9
+ * Singleton — one ResourceManager per Node.js process, shared across all
10
+ * Brainy instances. Registered as a Cor provider so SDK and brainy can
11
+ * query resource profiles without importing cor directly.
12
+ *
13
+ * ## Per-category memory budget (Piece 8 extension, cor 3.0)
14
+ *
15
+ * The cache budget is split across four named categories — `vectors`,
16
+ * `metadata`, `embedding`, `other` — each with a hardcoded base share of
17
+ * the total cache pool (50% / 25% / 15% / 10% respectively). Subsystems
18
+ * (cor's native LSM metadata index, DiskANN graph, embedding cache, etc.)
19
+ * register as `MemoryBudgetSubscriber`s with their category and a callback
20
+ * that fires whenever their effective allocation changes.
21
+ *
22
+ * Per the zero-config principle: the base shares are the **starting point**,
23
+ * not a hard cap. The ResourceManager observes actual utilization
24
+ * (`reportNativeMemory(name, bytes)` calls) on a 5-second pressure-tick
25
+ * cadence and **dynamically rebalances** allocations at runtime —
26
+ * under-utilized categories donate capacity to over-utilized ones,
27
+ * bounded so no category can shrink below 25% of its base share or grow
28
+ * above 200% of its base share. Operators never tune the split; the
29
+ * system adapts to its workload.
30
+ *
31
+ * The pre-existing `setCacheResizeCallback` API is preserved as a
32
+ * back-compat shim that auto-subscribes to the `other` category with a
33
+ * zero-bytes report — no behavior change for existing wiring.
34
+ *
35
+ * ## OS-memory observation (Piece J, cor 3.0)
36
+ *
37
+ * A built-in {@link OsMemoryProbe} reads `/proc/meminfo` on Linux
38
+ * (with an `os.totalmem`/`os.freemem` fallback elsewhere) and
39
+ * exposes the result via {@link getResourceProfile}'s `osMemory`
40
+ * field and the convenience accessor
41
+ * {@link ResourceManager.getOsMemorySnapshot}. The **Adaptive
42
+ * DiskANN** mode selector (Piece I) reads this snapshot at
43
+ * brain-open time to pick between in-memory / hybrid / on-disk
44
+ * operating modes — the key signal is `availableBytes`
45
+ * (`MemAvailable` on Linux), which counts reclaimable page cache
46
+ * as "available for new allocations." Naïve `os.freemem()`
47
+ * undercounts in multi-tenant scenarios where many brainy+cor
48
+ * instances share a box.
49
+ */
50
+ import os from 'node:os';
51
+ import v8 from 'node:v8';
52
+ import { readFileSync, existsSync } from 'node:fs';
53
+ import { OsMemoryProbe, } from './OsMemoryProbe.js';
54
+ import { prodLog } from '@soulcraft/brainy/internals';
55
+ import { getOpenFileLimitInfo } from '../native/index.js';
56
+ // ---------------------------------------------------------------------------
57
+ // Constants
58
+ // ---------------------------------------------------------------------------
59
+ /** Fixed process overhead: embedding model (88MB) + Node.js baseline (~150MB) */
60
+ const FIXED_OVERHEAD_BYTES = 240 * 1024 * 1024;
61
+ /**
62
+ * **Piece 8 commit 2 — fallback per-entity RAM estimate.** Used by
63
+ * `trackInstance` + `canAccommodateInstance` ONLY when no subscriber
64
+ * has reported native memory yet (cold-boot, no instances active).
65
+ * Once any subscriber reports nativeMemoryBytes, the observed
66
+ * `totalReportedBytes / totalEntities` ratio takes precedence — see
67
+ * {@link ResourceManager.observedBytesPerEntity}.
68
+ *
69
+ * The pre-Piece-8 constant was `1024` bytes/entity, which over-
70
+ * estimated the RAM working set by ~300× at the cor 3.0 10B
71
+ * design target (32 GB / 10B entities = ~3.2 bytes/entity for
72
+ * everything that's actually resident). The new fallback of 256
73
+ * bytes is still conservative — accurate for sub-million-entity
74
+ * brains where most state is RAM-resident, intentionally over-
75
+ * estimating for billion-scale so the admission gate errs toward
76
+ * "don't admit a new instance" until real reports correct the
77
+ * estimate.
78
+ */
79
+ const FALLBACK_BYTES_PER_ENTITY = 256;
80
+ /**
81
+ * Floor for the observed-bytes-per-entity calculation. Prevents an
82
+ * absurdly-low estimate (e.g., a subscriber temporarily reporting
83
+ * zero bytes while a flush is in progress) from making
84
+ * `canAccommodateInstance` say "yes" to instances that can't
85
+ * possibly fit. 4 bytes is the per-entity u32 ID alone; any real
86
+ * deployment carries more state than that.
87
+ */
88
+ const MIN_BYTES_PER_ENTITY = 4;
89
+ /** Per-instance baseline overhead before entities */
90
+ const INSTANCE_BASELINE_BYTES = 4 * 1024 * 1024;
91
+ /** Minimum cache size — never shrink below this */
92
+ const MIN_CACHE_BYTES = 128 * 1024 * 1024;
93
+ /** Maximum fraction of available RAM for cache when instances are present */
94
+ const MAX_CACHE_RATIO = 0.35;
95
+ /** Memory pressure thresholds (fraction of effective limit) */
96
+ const PRESSURE_ELEVATED = 0.75;
97
+ const PRESSURE_CRITICAL = 0.90;
98
+ /**
99
+ * V8 heap pressure thresholds (fraction of `heap_size_limit`).
100
+ * Matched to the system-RSS thresholds for consistency; the V8 GC
101
+ * starts thrashing around 0.90 ratio so we want to fire `critical`
102
+ * before subsystems run out of allocation headroom.
103
+ */
104
+ const V8_HEAP_PRESSURE_ELEVATED = 0.75;
105
+ const V8_HEAP_PRESSURE_CRITICAL = 0.90;
106
+ /** Rebalance interval in milliseconds */
107
+ const REBALANCE_INTERVAL_MS = 30_000;
108
+ /**
109
+ * Hardcoded base shares of the cache budget per category. These are the
110
+ * starting point — the ResourceManager dynamically rebalances at runtime
111
+ * based on observed pressure. NEVER expose these as a tuning knob: the
112
+ * [[zero-config-dynamic-adaptation]] principle says configuration is the
113
+ * last escape hatch, not the first. If a workload genuinely needs a
114
+ * different split, the adaptation loop figures it out from the reported
115
+ * bytes.
116
+ */
117
+ const BASE_CATEGORY_SHARES = {
118
+ vectors: 0.5,
119
+ metadata: 0.25,
120
+ embedding: 0.15,
121
+ other: 0.1,
122
+ };
123
+ /**
124
+ * Pressure-tick cadence — 5 seconds. The pressure-tick only fires when
125
+ * SOME category is at >90% of its current allocation; it stops on its
126
+ * own when every category drops back under 70%. The 30s rebalance
127
+ * remains the always-on cadence for whole-pool sizing changes (instance
128
+ * count, system memory shifts).
129
+ */
130
+ const PRESSURE_TICK_INTERVAL_MS = 5_000;
131
+ /**
132
+ * Per-category pressure thresholds used by the pressure-tick.
133
+ */
134
+ const CATEGORY_PRESSURE_HIGH = 0.9;
135
+ const CATEGORY_PRESSURE_LOW = 0.7;
136
+ /**
137
+ * Per-category bounds on dynamic rebalancing. A category can shrink to
138
+ * 25% of its base allocation (giving the rest to others) or grow to
139
+ * 200% of its base allocation (consuming surplus from underutilized
140
+ * categories). Outside these bounds the system stops adapting in that
141
+ * direction — protecting against pathological workloads that would
142
+ * starve a small category entirely.
143
+ */
144
+ const CATEGORY_MIN_SHARE = 0.25;
145
+ const CATEGORY_MAX_SHARE = 2.0;
146
+ /**
147
+ * Minimum change in allowed bytes before notifying a subscriber. Avoids
148
+ * spurious callbacks when the rebalance only nudges allocations by a
149
+ * few KB. 5% relative + 1 MB absolute.
150
+ */
151
+ const NOTIFY_DELTA_RATIO = 0.05;
152
+ const NOTIFY_DELTA_BYTES = 1024 * 1024;
153
+ // ---------------------------------------------------------------------------
154
+ // Cgroup detection (reuses brainy's approach)
155
+ // ---------------------------------------------------------------------------
156
+ /**
157
+ * Detect container memory limit from cgroups v1 or v2.
158
+ *
159
+ * @returns Memory limit in bytes, or null if not in a container / unlimited
160
+ */
161
+ function detectCgroupLimit() {
162
+ try {
163
+ // cgroups v2
164
+ const v2Path = '/sys/fs/cgroup/memory.max';
165
+ if (existsSync(v2Path)) {
166
+ const content = readFileSync(v2Path, 'utf8').trim();
167
+ if (content !== 'max') {
168
+ const bytes = parseInt(content, 10);
169
+ if (bytes > 64 * 1024 * 1024 && bytes < 1024 * 1024 * 1024 * 1024) {
170
+ return bytes;
171
+ }
172
+ }
173
+ }
174
+ // cgroups v1
175
+ const v1Path = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
176
+ if (existsSync(v1Path)) {
177
+ const content = readFileSync(v1Path, 'utf8').trim();
178
+ const bytes = parseInt(content, 10);
179
+ if (bytes > 64 * 1024 * 1024 && bytes < os.totalmem() * 2) {
180
+ return bytes;
181
+ }
182
+ }
183
+ }
184
+ catch {
185
+ // Not in a cgroup environment
186
+ }
187
+ return null;
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // ResourceManager
191
+ // ---------------------------------------------------------------------------
192
+ /**
193
+ * Adaptive resource manager for multi-instance Brainy deployments.
194
+ *
195
+ * Tracks active brainy instances, observes system resources, and computes
196
+ * dynamic memory budgets for cache sizing and pool eviction.
197
+ *
198
+ * @example
199
+ * ```typescript
200
+ * const rm = ResourceManager.getInstance()
201
+ * rm.trackInstance('tenant-wicks', 450)
202
+ *
203
+ * const profile = rm.getResourceProfile()
204
+ * console.log(`Cache budget: ${profile.recommendedCacheSize / 1024 / 1024}MB`)
205
+ * console.log(`Headroom for new instances: ${profile.headroom / 1024 / 1024}MB`)
206
+ * ```
207
+ */
208
+ export class ResourceManager {
209
+ static instance = null;
210
+ instances = new Map();
211
+ effectiveLimit;
212
+ isContainer;
213
+ rebalanceTimer = null;
214
+ onCacheResize = null;
215
+ /**
216
+ * **Piece J — OS-memory observation.** `/proc/meminfo`-backed
217
+ * snapshot source. Held as an instance field so tests can swap
218
+ * the probe via {@link _testInjectOsMemoryProbe}.
219
+ */
220
+ osMemoryProbe = new OsMemoryProbe();
221
+ /**
222
+ * Active per-category memory subscribers, keyed by name. Each
223
+ * subscriber receives notifications when its effective allocation
224
+ * changes (whether from a whole-pool rebalance or a category-level
225
+ * dynamic adaptation).
226
+ */
227
+ subscribers = new Map();
228
+ /**
229
+ * Current dynamic share for each category. Starts at `BASE_CATEGORY_SHARES`
230
+ * and adapts at runtime to reported pressure. Sum across categories is
231
+ * preserved at exactly 1.0 by the rebalance algorithm.
232
+ */
233
+ categoryShares = { ...BASE_CATEGORY_SHARES };
234
+ /**
235
+ * 5-second pressure-tick timer. Only running while at least one
236
+ * category is at or above the high-water threshold; auto-stops once
237
+ * every category falls back below the low-water threshold.
238
+ */
239
+ pressureTickTimer = null;
240
+ /**
241
+ * Tracks how many consecutive ticks have shown no pressure — used to
242
+ * decide when to stop the pressure-tick.
243
+ */
244
+ quietTicks = 0;
245
+ /** One-shot guard for the multi-tenant OS-limits deployment warning. */
246
+ osLimitsWarned = false;
247
+ constructor() {
248
+ const cgroupLimit = detectCgroupLimit();
249
+ const systemTotal = os.totalmem();
250
+ this.isContainer = cgroupLimit !== null;
251
+ this.effectiveLimit = cgroupLimit !== null
252
+ ? Math.min(cgroupLimit, systemTotal)
253
+ : systemTotal;
254
+ }
255
+ /**
256
+ * Get the singleton ResourceManager instance.
257
+ */
258
+ static getInstance() {
259
+ if (!ResourceManager.instance) {
260
+ ResourceManager.instance = new ResourceManager();
261
+ }
262
+ return ResourceManager.instance;
263
+ }
264
+ /**
265
+ * **Back-compat callback** for the cortex 2.x cache resize wiring.
266
+ * The cor plugin (`src/plugin.ts`) registers brainy's
267
+ * `UnifiedCache.setMaxSize` via this method; the callback fires on
268
+ * every whole-pool rebalance with the TOTAL cache budget (not a
269
+ * per-category slice).
270
+ *
271
+ * Behavior is unchanged from cortex 2.x: the callback receives the
272
+ * full `recommendedCacheSize` from `getResourceProfile()` whenever
273
+ * the pool resizes. The new per-category subscriber API
274
+ * ([`registerMemoryBudgetSubscriber`](#registerMemoryBudgetSubscriber))
275
+ * runs in PARALLEL — they don't interfere. Legacy callers can keep
276
+ * their existing wiring; new code that wants a category-specific
277
+ * allocation registers as a subscriber.
278
+ *
279
+ * @param callback - Called with the new recommended cache size in bytes
280
+ */
281
+ setCacheResizeCallback(callback) {
282
+ this.onCacheResize = callback;
283
+ }
284
+ /**
285
+ * Register a subscriber for per-category memory budget notifications.
286
+ *
287
+ * Subscribers receive an `allowedBytes` value whenever their effective
288
+ * allocation changes — either because the overall pool resized (system
289
+ * memory shift, instance count change) or because the dynamic
290
+ * rebalance loop shifted capacity between categories under observed
291
+ * pressure.
292
+ *
293
+ * The returned function deregisters the subscriber. Each `name` must
294
+ * be unique; registering twice with the same name replaces the prior
295
+ * registration.
296
+ *
297
+ * @param name - Unique subscriber name (used with `reportNativeMemory`)
298
+ * @param category - The category this subscriber draws from
299
+ * @param callback - Invoked with the current `allowedBytes` ceiling
300
+ * @returns A deregistration function (call to detach the subscriber)
301
+ *
302
+ * @example
303
+ * ```typescript
304
+ * const rm = ResourceManager.getInstance()
305
+ * const unsubscribe = rm.registerMemoryBudgetSubscriber(
306
+ * 'metadata-lsm',
307
+ * 'metadata',
308
+ * (allowed) => { lsm.setMaxBytes(allowed) }
309
+ * )
310
+ * // Later, when the LSM measures its footprint:
311
+ * rm.reportNativeMemory('metadata-lsm', lsm.currentBytes())
312
+ * // To detach:
313
+ * unsubscribe()
314
+ * ```
315
+ */
316
+ registerMemoryBudgetSubscriber(name, category, callback) {
317
+ const subscriber = {
318
+ name,
319
+ category,
320
+ callback,
321
+ reportedBytes: 0,
322
+ lastAllowed: 0,
323
+ };
324
+ this.subscribers.set(name, subscriber);
325
+ // Fire the initial allowed-bytes value so the subscriber knows its
326
+ // starting ceiling without having to call getResourceProfile() first.
327
+ const initial = this.computeAllowedBytes(category);
328
+ subscriber.lastAllowed = initial;
329
+ try {
330
+ callback(initial);
331
+ }
332
+ catch {
333
+ // Subscriber callback threw; swallow — we don't trust foreign
334
+ // callbacks to bring down the rebalance loop.
335
+ }
336
+ return () => {
337
+ const current = this.subscribers.get(name);
338
+ if (current === subscriber) {
339
+ this.subscribers.delete(name);
340
+ }
341
+ };
342
+ }
343
+ /**
344
+ * Report the current resident-memory footprint of a registered
345
+ * subscriber, in bytes. The rebalance loop uses these values to
346
+ * detect category-level pressure and shift capacity dynamically.
347
+ *
348
+ * Call this whenever the subscriber's footprint changes meaningfully
349
+ * (after a flush, after a major allocation, etc). The pressure-tick
350
+ * fires within 5 seconds of any single category exceeding 90% of its
351
+ * current allocation; calling more often is harmless.
352
+ *
353
+ * Unknown names are silently dropped — a subscriber that has
354
+ * deregistered may still have pending reports in flight.
355
+ */
356
+ reportNativeMemory(name, bytes) {
357
+ const subscriber = this.subscribers.get(name);
358
+ if (!subscriber)
359
+ return;
360
+ subscriber.reportedBytes = Math.max(0, bytes);
361
+ // Cheap check: if THIS subscriber's category is now over the
362
+ // high-water mark, start the pressure-tick. The full rebalance
363
+ // runs inside the tick to keep this call O(1).
364
+ const allocation = this.computeAllowedBytes(subscriber.category);
365
+ if (allocation > 0 && subscriber.reportedBytes >= allocation * CATEGORY_PRESSURE_HIGH) {
366
+ this.ensurePressureTickRunning();
367
+ }
368
+ }
369
+ /**
370
+ * Report the current dynamic share for each category. Mostly used by
371
+ * tests + the diagnostics surface; production callers should subscribe
372
+ * via [`registerMemoryBudgetSubscriber`] instead of polling.
373
+ *
374
+ * The sum across categories is always exactly 1.0 (modulo float
375
+ * rounding); allocations sum to the cache budget.
376
+ */
377
+ getCategoryShares() {
378
+ return { ...this.categoryShares };
379
+ }
380
+ /**
381
+ * The current per-category allowed-bytes ceiling. Same as what an
382
+ * active subscriber received in its last callback. Provided for
383
+ * diagnostics; production callers subscribe instead of polling.
384
+ */
385
+ getCategoryAllowedBytes(category) {
386
+ return this.computeAllowedBytes(category);
387
+ }
388
+ /**
389
+ * Start periodic rebalancing (every 30s). Aligns with brainy's existing
390
+ * memory pressure monitoring interval.
391
+ */
392
+ startRebalancing() {
393
+ if (this.rebalanceTimer)
394
+ return;
395
+ this.rebalanceTimer = setInterval(() => {
396
+ this.rebalance();
397
+ }, REBALANCE_INTERVAL_MS);
398
+ // Don't keep the process alive just for rebalancing
399
+ if (this.rebalanceTimer.unref) {
400
+ this.rebalanceTimer.unref();
401
+ }
402
+ }
403
+ /**
404
+ * Stop periodic rebalancing.
405
+ */
406
+ stopRebalancing() {
407
+ if (this.rebalanceTimer) {
408
+ clearInterval(this.rebalanceTimer);
409
+ this.rebalanceTimer = null;
410
+ }
411
+ this.stopPressureTick();
412
+ }
413
+ /**
414
+ * Start the 5-second pressure-tick if it isn't already running.
415
+ * Idempotent. Called from `reportNativeMemory` when a category crosses
416
+ * the high-water threshold; stops on its own when every category
417
+ * falls back below the low-water threshold for 3 consecutive ticks.
418
+ */
419
+ ensurePressureTickRunning() {
420
+ if (this.pressureTickTimer) {
421
+ this.quietTicks = 0;
422
+ return;
423
+ }
424
+ this.quietTicks = 0;
425
+ this.pressureTickTimer = setInterval(() => {
426
+ this.runPressureTick();
427
+ }, PRESSURE_TICK_INTERVAL_MS);
428
+ if (this.pressureTickTimer.unref) {
429
+ this.pressureTickTimer.unref();
430
+ }
431
+ }
432
+ stopPressureTick() {
433
+ if (this.pressureTickTimer) {
434
+ clearInterval(this.pressureTickTimer);
435
+ this.pressureTickTimer = null;
436
+ }
437
+ this.quietTicks = 0;
438
+ }
439
+ /**
440
+ * Test-only hook to drive a pressure tick synchronously without
441
+ * waiting for the timer. Not exposed to production callers; tests
442
+ * use this to assert the rebalance behaviour deterministically.
443
+ * @internal
444
+ */
445
+ _testTickPressure() {
446
+ this.runPressureTick();
447
+ }
448
+ // ── Instance tracking ────────────────────────────────────────────────────
449
+ /**
450
+ * Track a new or updated brainy instance. Call when a brainy instance is
451
+ * created or when its entity count changes significantly.
452
+ *
453
+ * @param key - Unique scope key for this instance (e.g. tenant slug, user:workspace)
454
+ * @param entityCount - Current entity count in this instance
455
+ */
456
+ trackInstance(key, entityCount) {
457
+ const estimatedMemory = INSTANCE_BASELINE_BYTES + entityCount * this.observedBytesPerEntity();
458
+ this.instances.set(key, {
459
+ key,
460
+ entityCount,
461
+ estimatedMemory,
462
+ lastAccess: Date.now(),
463
+ });
464
+ this.rebalance();
465
+ this.maybeWarnOsLimits();
466
+ }
467
+ /**
468
+ * One-shot deployment warning: when enough brains are pooled in ONE
469
+ * process that OS file/map limits could bottleneck before RAM — and the
470
+ * limits are below the projected need — log the exact fix. Cor
471
+ * auto-raises the SOFT `nofile` limit itself; the hard cap +
472
+ * `vm.max_map_count` must be set by the deployment (a process cannot
473
+ * raise those). Context-aware: silent for a single big brain (Venue);
474
+ * fires only at real pool scale. See `docs/ADR-004-multi-tenant-storage.md`.
475
+ */
476
+ maybeWarnOsLimits() {
477
+ if (this.osLimitsWarned)
478
+ return;
479
+ const n = this.instances.size;
480
+ if (n < 100)
481
+ return; // only matters once a real pool is running
482
+ const info = getOpenFileLimitInfo();
483
+ if (!info)
484
+ return;
485
+ // Post-lazy-shard per-brain estimates, with margin.
486
+ const projFds = n * 16;
487
+ const projMaps = n * 24;
488
+ const fdTight = info.hard > 0 && info.hard < projFds;
489
+ const mapTight = info.vmMaxMapCount > 0 && info.vmMaxMapCount < projMaps;
490
+ if (!fdTight && !mapTight)
491
+ return;
492
+ this.osLimitsWarned = true;
493
+ prodLog.warn(`[cor] ${n} brains pooled in one process — OS limits may bottleneck before RAM. ` +
494
+ `A process can't raise these; set them in the deployment:\n` +
495
+ ` docker run --ulimit nofile=65536:65536 --sysctl vm.max_map_count=262144 …\n` +
496
+ ` (current hard nofile=${info.hard}, vm.max_map_count=${info.vmMaxMapCount}; ` +
497
+ `cor already raised soft nofile to ${info.softAfter}). See cor ADR-004.`);
498
+ }
499
+ /**
500
+ * Stop tracking a brainy instance. Call when the instance is evicted or closed.
501
+ *
502
+ * @param key - The scope key that was passed to trackInstance()
503
+ */
504
+ untrackInstance(key) {
505
+ this.instances.delete(key);
506
+ this.rebalance();
507
+ }
508
+ /**
509
+ * Record an access to an instance (updates lastAccess for weighted eviction).
510
+ *
511
+ * @param key - The scope key
512
+ */
513
+ touchInstance(key) {
514
+ const info = this.instances.get(key);
515
+ if (info) {
516
+ info.lastAccess = Date.now();
517
+ }
518
+ }
519
+ // ── Resource profiling ───────────────────────────────────────────────────
520
+ /**
521
+ * **Piece 8 — V8 heap tracking.** Snapshot the current V8 heap
522
+ * state and classify pressure. Cheap call (a single
523
+ * `v8.getHeapStatistics()` syscall + a few divisions); fine to
524
+ * invoke per-query on the hot path. Subsystems like brainy's filter
525
+ * lowering use this to early-exit before allocating big
526
+ * intermediate bitmaps that would push the heap over the ceiling.
527
+ *
528
+ * The pressure classification uses the same three-level shape as
529
+ * system-RSS pressure (`normal` / `elevated` / `critical`) so
530
+ * subscribers can branch uniformly.
531
+ */
532
+ getHeapStats() {
533
+ const stats = v8.getHeapStatistics();
534
+ const ratio = stats.heap_size_limit > 0
535
+ ? stats.used_heap_size / stats.heap_size_limit
536
+ : 0;
537
+ let pressure = 'normal';
538
+ if (ratio >= V8_HEAP_PRESSURE_CRITICAL)
539
+ pressure = 'critical';
540
+ else if (ratio >= V8_HEAP_PRESSURE_ELEVATED)
541
+ pressure = 'elevated';
542
+ return {
543
+ usedHeapSize: stats.used_heap_size,
544
+ heapSizeLimit: stats.heap_size_limit,
545
+ externalMemory: stats.external_memory,
546
+ ratio,
547
+ pressure,
548
+ };
549
+ }
550
+ /**
551
+ * **Piece 8.** Convenience accessor returning just the heap
552
+ * pressure classification — cheaper than constructing a full
553
+ * {@link V8HeapSnapshot} when the caller only needs the branch.
554
+ */
555
+ getHeapPressure() {
556
+ return this.getHeapStats().pressure;
557
+ }
558
+ /**
559
+ * **Piece J.** Latest OS-memory snapshot from `/proc/meminfo`
560
+ * (Linux) or the os-fallback path. Cached for 250 ms by the
561
+ * underlying {@link OsMemoryProbe}; consecutive calls inside the
562
+ * cache window reuse the value. Cheaper than
563
+ * {@link getResourceProfile} when the caller only needs the
564
+ * memory snapshot (the Adaptive DiskANN selector — Piece I — is
565
+ * the canonical caller).
566
+ */
567
+ getOsMemorySnapshot() {
568
+ return this.osMemoryProbe.snapshot();
569
+ }
570
+ /**
571
+ * Test-only hook to swap the OS-memory probe with a deterministic
572
+ * stand-in. Production code must not call this — the singleton's
573
+ * probe is part of the resource-observation contract.
574
+ * @internal
575
+ */
576
+ _testInjectOsMemoryProbe(probe) {
577
+ this.osMemoryProbe = probe;
578
+ }
579
+ /**
580
+ * Compute a full resource profile snapshot.
581
+ *
582
+ * @returns Current system resources, instance stats, and recommendations
583
+ */
584
+ getResourceProfile() {
585
+ const mem = process.memoryUsage();
586
+ const totalEntities = this.getTotalEntities();
587
+ const totalInstanceMemory = this.getTotalInstanceMemory();
588
+ const activeInstances = this.instances.size;
589
+ const recommendedCacheSize = this.computeCacheBudget(totalInstanceMemory);
590
+ const pressure = this.computePressure(mem.rss);
591
+ const usedByFixed = FIXED_OVERHEAD_BYTES;
592
+ const usedByInstances = totalInstanceMemory;
593
+ const usedByCache = recommendedCacheSize;
594
+ const headroom = Math.max(0, this.effectiveLimit - usedByFixed - usedByInstances - usedByCache);
595
+ const avgInstanceMemory = activeInstances > 0
596
+ ? totalInstanceMemory / activeInstances
597
+ : INSTANCE_BASELINE_BYTES;
598
+ return {
599
+ totalMemory: os.totalmem(),
600
+ freeMemory: os.freemem(),
601
+ effectiveLimit: this.effectiveLimit,
602
+ isContainer: this.isContainer,
603
+ processRss: mem.rss,
604
+ heapUsed: mem.heapUsed,
605
+ cpuCount: os.cpus().length,
606
+ activeInstances,
607
+ totalEntities,
608
+ recommendedCacheSize,
609
+ pressure,
610
+ avgInstanceMemory,
611
+ headroom,
612
+ v8Heap: this.getHeapStats(),
613
+ osMemory: this.getOsMemorySnapshot(),
614
+ };
615
+ }
616
+ /**
617
+ * Check if there's enough memory headroom to create a new instance
618
+ * with the given entity count.
619
+ *
620
+ * @param estimatedEntities - Expected entity count for the new instance
621
+ * @returns true if headroom is sufficient
622
+ */
623
+ canAccommodateInstance(estimatedEntities = 500) {
624
+ const profile = this.getResourceProfile();
625
+ const needed = INSTANCE_BASELINE_BYTES + estimatedEntities * this.observedBytesPerEntity();
626
+ return profile.headroom >= needed && profile.pressure !== 'critical';
627
+ }
628
+ /**
629
+ * **Piece 8 commit 2 — observed per-entity RAM estimate.** When
630
+ * any subscriber has reported native memory via
631
+ * {@link reportNativeMemory}, the ratio of total reported bytes
632
+ * to total tracked entities is the actual observed per-entity
633
+ * footprint — accurate for THIS deployment's workload mix instead
634
+ * of a one-size-fits-all guess. Falls back to
635
+ * {@link FALLBACK_BYTES_PER_ENTITY} when subscribers haven't
636
+ * reported (cold boot, no instances tracked, or workloads where
637
+ * the brain hasn't done any reading yet).
638
+ *
639
+ * Floored at {@link MIN_BYTES_PER_ENTITY} so a subscriber that
640
+ * temporarily under-reports during a flush doesn't make
641
+ * `canAccommodateInstance` say "yes" to instances that can't fit.
642
+ *
643
+ * Cheap call: O(subscribers + instances) — both bounded by a few
644
+ * dozen in production. Fine to invoke per trackInstance /
645
+ * canAccommodateInstance call.
646
+ */
647
+ observedBytesPerEntity() {
648
+ const totalEntities = this.getTotalEntities();
649
+ if (totalEntities === 0)
650
+ return FALLBACK_BYTES_PER_ENTITY;
651
+ let totalReported = 0;
652
+ for (const sub of this.subscribers.values()) {
653
+ totalReported += sub.reportedBytes;
654
+ }
655
+ if (totalReported === 0)
656
+ return FALLBACK_BYTES_PER_ENTITY;
657
+ const ratio = Math.floor(totalReported / totalEntities);
658
+ return Math.max(MIN_BYTES_PER_ENTITY, ratio);
659
+ }
660
+ /**
661
+ * Get the scope key of the instance that should be evicted first.
662
+ * Uses weighted scoring: large + idle instances are evicted before
663
+ * small + active ones.
664
+ *
665
+ * @returns The key to evict, or null if no instances are tracked
666
+ */
667
+ getEvictionCandidate() {
668
+ if (this.instances.size === 0)
669
+ return null;
670
+ const now = Date.now();
671
+ let bestKey = null;
672
+ let bestScore = -Infinity;
673
+ for (const [key, info] of this.instances) {
674
+ // Score: idle time weighted by memory usage
675
+ // Higher score = better eviction candidate
676
+ const idleMs = now - info.lastAccess;
677
+ const memoryWeight = info.estimatedMemory / (1024 * 1024); // MB
678
+ const score = (idleMs / 1000) * memoryWeight; // seconds × MB
679
+ if (score > bestScore) {
680
+ bestScore = score;
681
+ bestKey = key;
682
+ }
683
+ }
684
+ return bestKey;
685
+ }
686
+ // ── Internal ─────────────────────────────────────────────────────────────
687
+ /**
688
+ * Rebalance cache sizing based on current instance count and memory state.
689
+ *
690
+ * Three responsibilities:
691
+ * 1. Recompute the total cache budget from instance count + system
692
+ * memory (the pre-Piece-8 behaviour).
693
+ * 2. Fire the back-compat `onCacheResize` if it exists.
694
+ * 3. Notify every per-category subscriber of its current
695
+ * `allowedBytes` ceiling — picks up any whole-pool shift even
696
+ * when the per-category dynamic adaptation isn't pressed.
697
+ */
698
+ rebalance() {
699
+ const totalInstanceMemory = this.getTotalInstanceMemory();
700
+ const newCacheSize = this.computeCacheBudget(totalInstanceMemory);
701
+ if (this.onCacheResize) {
702
+ try {
703
+ this.onCacheResize(newCacheSize);
704
+ }
705
+ catch {
706
+ // back-compat callback threw; protect the loop
707
+ }
708
+ }
709
+ this.notifySubscribers();
710
+ }
711
+ /**
712
+ * Drive one tick of the per-category dynamic rebalance. Reads the
713
+ * latest `reportNativeMemory` values, identifies over- and under-
714
+ * utilized categories, shifts the dynamic shares between them
715
+ * (bounded by `CATEGORY_MIN_SHARE` / `CATEGORY_MAX_SHARE`), and
716
+ * notifies any subscriber whose allowance changed meaningfully.
717
+ *
718
+ * Auto-stops the pressure-tick loop after 3 consecutive quiet ticks
719
+ * (no category > low-water).
720
+ */
721
+ runPressureTick() {
722
+ const categoryBytes = this.aggregateReportedBytesByCategory();
723
+ const cacheBudget = this.computeCacheBudget(this.getTotalInstanceMemory());
724
+ let anyPressure = false;
725
+ // Identify donors (low utilization) and recipients (high utilization).
726
+ const donors = [];
727
+ const recipients = [];
728
+ for (const category of Object.keys(BASE_CATEGORY_SHARES)) {
729
+ const allocation = cacheBudget * this.categoryShares[category];
730
+ if (allocation <= 0)
731
+ continue;
732
+ const reported = categoryBytes[category];
733
+ const utilization = reported / allocation;
734
+ if (utilization >= CATEGORY_PRESSURE_HIGH) {
735
+ anyPressure = true;
736
+ recipients.push(category);
737
+ }
738
+ else if (utilization <= 1 - CATEGORY_PRESSURE_HIGH) {
739
+ // Symmetric low-water: a category using <10% of its allocation
740
+ // is a candidate donor. Lower threshold than the high-water on
741
+ // purpose — we don't want to flap on small fluctuations.
742
+ donors.push(category);
743
+ }
744
+ }
745
+ if (recipients.length > 0 && donors.length > 0) {
746
+ this.rebalanceShares(donors, recipients, categoryBytes, cacheBudget);
747
+ }
748
+ // Quiet-tick detection: if no category is over the LOW-water mark
749
+ // (i.e., everyone is using < 70% of their allocation), count it as
750
+ // a quiet tick. Three in a row → stop the pressure-tick.
751
+ const anyAboveLow = Object.keys(BASE_CATEGORY_SHARES).some((c) => {
752
+ const allocation = cacheBudget * this.categoryShares[c];
753
+ return allocation > 0 && categoryBytes[c] >= allocation * CATEGORY_PRESSURE_LOW;
754
+ });
755
+ if (anyAboveLow) {
756
+ this.quietTicks = 0;
757
+ }
758
+ else {
759
+ this.quietTicks++;
760
+ if (this.quietTicks >= 3) {
761
+ this.stopPressureTick();
762
+ }
763
+ }
764
+ // Always notify after a tick — subscribers should see the new
765
+ // ceiling even if we didn't shift shares this round (e.g. if a
766
+ // subscriber's reported bytes alone changed the allowed ceiling
767
+ // via the back-compat cache resize callback).
768
+ if (anyPressure || this.quietTicks === 0) {
769
+ this.notifySubscribers();
770
+ }
771
+ }
772
+ /**
773
+ * Shift dynamic share from donor categories to recipient categories.
774
+ *
775
+ * Both sides are PRE-CAPPED by the per-category min/max share bounds
776
+ * (25% / 200% of base share, respectively) BEFORE the proportional
777
+ * distribution runs. Pre-capping is the only safe pattern: clamping
778
+ * after a proportional split would either (a) lose capacity (donors
779
+ * give shares that recipients can't accept due to their ceiling) or
780
+ * (b) require renormalization, which silently violates the ceiling.
781
+ *
782
+ * `transfer = min(total bounded contribution, total bounded demand)`
783
+ * — by construction the per-donor `give` and per-recipient `take`
784
+ * sums are equal, so the shares always sum to 1.0 without
785
+ * renormalization. The float drift over many cycles is bounded by
786
+ * the floating-point precision of a single division, well under any
787
+ * test's threshold.
788
+ */
789
+ rebalanceShares(donors, recipients, categoryBytes, cacheBudget) {
790
+ // Pre-cap contributions: each donor's bounded contribution is the
791
+ // smaller of (headroom × 0.5) and (currentShare - minShare).
792
+ let totalContribution = 0;
793
+ const donorContributions = {};
794
+ for (const donor of donors) {
795
+ const allocation = cacheBudget * this.categoryShares[donor];
796
+ const reported = categoryBytes[donor];
797
+ const headroomShare = (allocation - reported) / cacheBudget;
798
+ const unboundedGive = Math.max(0, headroomShare * 0.5);
799
+ const baseShare = BASE_CATEGORY_SHARES[donor];
800
+ const minShare = baseShare * CATEGORY_MIN_SHARE;
801
+ const maxGive = Math.max(0, this.categoryShares[donor] - minShare);
802
+ const bounded = Math.min(unboundedGive, maxGive);
803
+ if (bounded > 0) {
804
+ donorContributions[donor] = bounded;
805
+ totalContribution += bounded;
806
+ }
807
+ }
808
+ if (totalContribution <= 0)
809
+ return;
810
+ // Pre-cap demands: each recipient's bounded demand is the smaller of
811
+ // (over-utilization + growth wish) and (maxShare - currentShare).
812
+ let totalDemand = 0;
813
+ const recipientDemands = {};
814
+ for (const recipient of recipients) {
815
+ const allocation = cacheBudget * this.categoryShares[recipient];
816
+ const reported = categoryBytes[recipient];
817
+ const overByShare = Math.max(0, (reported - allocation) / cacheBudget);
818
+ const growthWish = this.categoryShares[recipient] * 0.05;
819
+ const unboundedWant = overByShare + growthWish;
820
+ const baseShare = BASE_CATEGORY_SHARES[recipient];
821
+ const maxShare = baseShare * CATEGORY_MAX_SHARE;
822
+ const maxTake = Math.max(0, maxShare - this.categoryShares[recipient]);
823
+ const bounded = Math.min(unboundedWant, maxTake);
824
+ if (bounded > 0) {
825
+ recipientDemands[recipient] = bounded;
826
+ totalDemand += bounded;
827
+ }
828
+ }
829
+ if (totalDemand <= 0)
830
+ return;
831
+ // Transfer = min of bounded totals. Equally distributed across both
832
+ // sides → sum of give == sum of take → categoryShares sum is
833
+ // preserved without renormalization.
834
+ const transfer = Math.min(totalContribution, totalDemand);
835
+ for (const donor of donors) {
836
+ const contribution = donorContributions[donor];
837
+ if (!contribution)
838
+ continue;
839
+ const give = (contribution / totalContribution) * transfer;
840
+ this.categoryShares[donor] -= give;
841
+ }
842
+ for (const recipient of recipients) {
843
+ const demand = recipientDemands[recipient];
844
+ if (!demand)
845
+ continue;
846
+ const take = (demand / totalDemand) * transfer;
847
+ this.categoryShares[recipient] += take;
848
+ }
849
+ // Defensive renormalize only if float drift accumulated meaningfully
850
+ // (1e-9 threshold). Should be a no-op in steady state because the
851
+ // pre-capped transfer is symmetric.
852
+ this.renormalizeShares();
853
+ }
854
+ /**
855
+ * Compute the current allowed-bytes ceiling for a category.
856
+ */
857
+ computeAllowedBytes(category) {
858
+ const cacheBudget = this.computeCacheBudget(this.getTotalInstanceMemory());
859
+ return Math.floor(cacheBudget * this.categoryShares[category]);
860
+ }
861
+ /**
862
+ * Sum the reportedBytes of every subscriber by category.
863
+ */
864
+ aggregateReportedBytesByCategory() {
865
+ const totals = {
866
+ vectors: 0,
867
+ metadata: 0,
868
+ embedding: 0,
869
+ other: 0,
870
+ };
871
+ for (const sub of this.subscribers.values()) {
872
+ totals[sub.category] += sub.reportedBytes;
873
+ }
874
+ return totals;
875
+ }
876
+ /**
877
+ * Notify every subscriber of its current allowed-bytes ceiling. Fires
878
+ * the callback only when the allowance has shifted by more than the
879
+ * `NOTIFY_DELTA` threshold from the last delivered value — avoids
880
+ * spurious wakeups on small re-rebalances.
881
+ */
882
+ notifySubscribers() {
883
+ for (const subscriber of this.subscribers.values()) {
884
+ const allowed = this.computeAllowedBytes(subscriber.category);
885
+ const delta = Math.abs(allowed - subscriber.lastAllowed);
886
+ const ratio = subscriber.lastAllowed > 0 ? delta / subscriber.lastAllowed : Infinity;
887
+ if (delta >= NOTIFY_DELTA_BYTES && ratio >= NOTIFY_DELTA_RATIO) {
888
+ subscriber.lastAllowed = allowed;
889
+ try {
890
+ subscriber.callback(allowed);
891
+ }
892
+ catch {
893
+ // foreign callback threw; swallow
894
+ }
895
+ }
896
+ }
897
+ }
898
+ /**
899
+ * Normalize categoryShares so they sum to exactly 1.0. Compensates for
900
+ * float drift over many rebalance cycles. Preserves the relative
901
+ * proportions of the four categories.
902
+ */
903
+ renormalizeShares() {
904
+ const sum = this.categoryShares.vectors +
905
+ this.categoryShares.metadata +
906
+ this.categoryShares.embedding +
907
+ this.categoryShares.other;
908
+ if (sum <= 0)
909
+ return;
910
+ if (Math.abs(sum - 1) < 1e-9)
911
+ return;
912
+ this.categoryShares.vectors /= sum;
913
+ this.categoryShares.metadata /= sum;
914
+ this.categoryShares.embedding /= sum;
915
+ this.categoryShares.other /= sum;
916
+ }
917
+ /**
918
+ * Compute the recommended cache budget given current instance memory usage.
919
+ *
920
+ * Formula: min(MAX_CACHE_RATIO × available, available - fixed - instances - headroom)
921
+ * Clamped to MIN_CACHE_BYTES.
922
+ */
923
+ computeCacheBudget(totalInstanceMemory) {
924
+ const available = this.effectiveLimit;
925
+ const afterFixed = available - FIXED_OVERHEAD_BYTES;
926
+ const afterInstances = afterFixed - totalInstanceMemory;
927
+ // Reserve 20% of remaining for headroom (new instances, spikes)
928
+ const forCache = afterInstances * 0.8;
929
+ // Also cap at MAX_CACHE_RATIO of total available (don't let cache dominate)
930
+ const ratioCap = afterFixed * MAX_CACHE_RATIO;
931
+ const cacheSize = Math.min(forCache, ratioCap);
932
+ return Math.max(MIN_CACHE_BYTES, Math.floor(cacheSize));
933
+ }
934
+ /**
935
+ * Determine memory pressure level from current RSS.
936
+ */
937
+ computePressure(rss) {
938
+ const ratio = rss / this.effectiveLimit;
939
+ if (ratio >= PRESSURE_CRITICAL)
940
+ return 'critical';
941
+ if (ratio >= PRESSURE_ELEVATED)
942
+ return 'elevated';
943
+ return 'normal';
944
+ }
945
+ getTotalEntities() {
946
+ let total = 0;
947
+ for (const info of this.instances.values()) {
948
+ total += info.entityCount;
949
+ }
950
+ return total;
951
+ }
952
+ getTotalInstanceMemory() {
953
+ let total = 0;
954
+ for (const info of this.instances.values()) {
955
+ total += info.estimatedMemory;
956
+ }
957
+ return total;
958
+ }
959
+ }
960
+ //# sourceMappingURL=ResourceManager.js.map