sparda-mcp 0.5.1 → 0.5.2

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.
@@ -0,0 +1,579 @@
1
+ // server/engine.js — the living engine (Bloc B), slice 1: the stability brain.
2
+ // A passive, per-tool read of which response fields stay put across calls and
3
+ // which move. It feeds the eventual "serve a stable answer without paying the
4
+ // host again" flywheel, but slice 1 only OBSERVES and CLASSIFIES — it never
5
+ // serves, never blocks.
6
+ //
7
+ // Three invariants keep it honest:
8
+ // - off the hot path: stdio.js feeds it through the idle harvester (rule #1);
9
+ // - value-free: it keeps a fixed-size fingerprint per field, never the value
10
+ // itself — so nothing it holds can leak PII (ADR-014 ethos). We keep hashed
11
+ // fingerprints and emit changed field NAMES only — never {from,to} values —
12
+ // which is stricter and all the brain needs;
13
+ // - runtime-only: nothing here is persisted, so there is no carry-over to
14
+ // protect (rule #5) — same posture as the router's purity detector.
15
+
16
+ // bounded memory, same philosophy as antibodies (ADR-010) and circuits.
17
+ export const ENGINE_LIMITS = { MAX_TOOLS: 100, MAX_FIELDS: 60, MAX_TS: 50, MAX_AXONS: 200, MAX_GHOSTS: 200 };
18
+
19
+ // FNV-1a 32-bit: collapses a field's value to a fixed token so change can be
20
+ // detected without retaining the value. Non-crypto on purpose — nothing heavy,
21
+ // and it runs in idle time anyway.
22
+ function fnv1a(str) {
23
+ let h = 0x811c9dc5;
24
+ for (let i = 0; i < str.length; i++) {
25
+ h ^= str.charCodeAt(i);
26
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
27
+ }
28
+ return h.toString(16);
29
+ }
30
+
31
+ function safeStringify(v) {
32
+ try { return JSON.stringify(v) ?? 'null'; } catch { return String(v); }
33
+ }
34
+
35
+ // top-level field fingerprints only — bounded, value-free. A non-object body
36
+ // (array, scalar) is one synthetic field: we can still tell if it moved, just
37
+ // not which part. Per-element granularity is a later slice.
38
+ function fieldHashes(result) {
39
+ const out = new Map();
40
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
41
+ for (const [k, v] of Object.entries(result)) {
42
+ if (out.size >= ENGINE_LIMITS.MAX_FIELDS) break;
43
+ out.set(k, fnv1a(safeStringify(v)));
44
+ }
45
+ } else {
46
+ out.set('__value__', fnv1a(safeStringify(result)));
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // per-tool stability model. observe() classifies a fresh result against the last
52
+ // fingerprints; snapshot() reports names + counts only.
53
+ export function createPredictiveEngine() {
54
+ const tools = new Map(); // tool -> { calls, fields: Map<name, {hash, seen, changed}> }
55
+ const stats = { firstCall: 0, noChange: 0, delta: 0 };
56
+
57
+ function observe(tool, result) {
58
+ const hashes = fieldHashes(result);
59
+ let rec = tools.get(tool);
60
+
61
+ if (!rec) {
62
+ if (tools.size >= ENGINE_LIMITS.MAX_TOOLS) return null; // bounded: stop learning new tools
63
+ rec = { calls: 1, fields: new Map() };
64
+ for (const [k, h] of hashes) rec.fields.set(k, { hash: h, seen: 1, changed: 0 });
65
+ tools.set(tool, rec);
66
+ stats.firstCall += 1;
67
+ return { type: 'FIRST_CALL', tool };
68
+ }
69
+
70
+ rec.calls += 1;
71
+ const changed = [];
72
+ for (const [k, h] of hashes) {
73
+ const f = rec.fields.get(k);
74
+ if (!f) { // a field that wasn't there before is itself a change of shape
75
+ if (rec.fields.size < ENGINE_LIMITS.MAX_FIELDS) rec.fields.set(k, { hash: h, seen: 1, changed: 0 });
76
+ changed.push(k);
77
+ continue;
78
+ }
79
+ f.seen += 1;
80
+ if (f.hash !== h) { f.hash = h; f.changed += 1; changed.push(k); }
81
+ }
82
+ // a field that vanished this call also counts as movement
83
+ for (const k of rec.fields.keys()) if (!hashes.has(k)) changed.push(k);
84
+
85
+ if (changed.length === 0) { stats.noChange += 1; return { type: 'NO_CHANGE', tool }; }
86
+ stats.delta += 1;
87
+ return { type: 'DELTA', tool, changedFields: changed };
88
+ }
89
+
90
+ // after a write the underlying resource may have moved — drop the model so a
91
+ // stale "stable" reading can't survive it. (Wired later; kept faithful here.)
92
+ function invalidate(tool) { tools.delete(tool); }
93
+
94
+ // names + counts only — never a value. A field is "stable" once it has been
95
+ // seen more than once without ever changing; "volatile" the moment it moves;
96
+ // a field seen only on the first call is still unknown (reported in neither).
97
+ function snapshot() {
98
+ const perTool = {};
99
+ for (const [tool, rec] of tools) {
100
+ const stable = [];
101
+ const volatile = [];
102
+ for (const [name, f] of rec.fields) {
103
+ if (f.changed > 0) volatile.push(name);
104
+ else if (f.seen > 1) stable.push(name);
105
+ }
106
+ perTool[tool] = { calls: rec.calls, stable, volatile };
107
+ }
108
+ return { stats: { ...stats }, tools: perTool };
109
+ }
110
+
111
+ return { observe, invalidate, snapshot };
112
+ }
113
+
114
+ // rhythm constants. A tool needs at least MIN_TS sightings before its cadence is
115
+ // judged; a coefficient of variation (stddev/mean of the gaps between calls) at
116
+ // or under REGULARITY_THRESHOLD means the calls land on a steady beat.
117
+ const RHYTHM = { MIN_TS: 5, REGULARITY_THRESHOLD: 0.25 };
118
+
119
+ // the rhythm detector (Bloc B, slice 2 — ported from sparda.ts FourierDetector).
120
+ // It watches WHEN a tool is called, not what it returns: a steady cadence paired
121
+ // with a stable result (slice 1) is the textbook pre-fetch candidate. It keeps
122
+ // only call timestamps (plain numbers — never a payload, never PII), bounded per
123
+ // tool, runtime-only.
124
+ export function createRhythmDetector() {
125
+ const times = new Map(); // tool -> number[] (ms timestamps, ring)
126
+ const patterns = new Map(); // tool -> { periodMs, confidence, observations, nextPredictedMs }
127
+ const stats = { patternsDetected: 0 };
128
+
129
+ function record(tool, ts) {
130
+ let arr = times.get(tool);
131
+ if (!arr) {
132
+ if (times.size >= ENGINE_LIMITS.MAX_TOOLS) return; // bounded: stop tracking new tools
133
+ arr = [];
134
+ times.set(tool, arr);
135
+ }
136
+ arr.push(ts);
137
+ if (arr.length > ENGINE_LIMITS.MAX_TS) arr.shift();
138
+ if (arr.length >= RHYTHM.MIN_TS) analyze(tool, arr);
139
+ }
140
+
141
+ function analyze(tool, arr) {
142
+ const gaps = arr.slice(1).map((t, i) => t - arr[i]);
143
+ const mean = gaps.reduce((s, x) => s + x, 0) / gaps.length;
144
+ if (mean <= 0) return; // no forward progress — can't be a cadence
145
+ const stdDev = Math.sqrt(gaps.reduce((s, x) => s + (x - mean) ** 2, 0) / gaps.length);
146
+ const cv = stdDev / mean;
147
+ if (cv <= RHYTHM.REGULARITY_THRESHOLD) {
148
+ if (!patterns.has(tool)) stats.patternsDetected += 1;
149
+ const last = arr[arr.length - 1];
150
+ patterns.set(tool, {
151
+ periodMs: Math.round(mean),
152
+ confidence: Number((1 - cv).toFixed(3)),
153
+ observations: arr.length,
154
+ nextPredictedMs: Math.round(last + mean),
155
+ });
156
+ } else if (patterns.has(tool)) {
157
+ // the beat broke — drop the stale pattern so the snapshot stays honest.
158
+ // The literal port kept a pattern forever once seen; we don't.
159
+ patterns.delete(tool);
160
+ }
161
+ }
162
+
163
+ function invalidate(tool) { times.delete(tool); patterns.delete(tool); }
164
+
165
+ // detected patterns only — names + cadence, never a value. nextEstimate is an
166
+ // ISO instant so the reader can see WHEN the next call is expected.
167
+ function snapshot() {
168
+ const tools = {};
169
+ for (const [tool, p] of patterns) {
170
+ tools[tool] = {
171
+ periodMs: p.periodMs,
172
+ confidence: p.confidence,
173
+ observations: p.observations,
174
+ nextEstimate: new Date(p.nextPredictedMs).toISOString(),
175
+ };
176
+ }
177
+ return { stats: { ...stats }, tools };
178
+ }
179
+
180
+ return { record, invalidate, snapshot };
181
+ }
182
+
183
+ // myelin constants. An edge "A-->B" (tool B called right after tool A) gains one
184
+ // layer of strength each time it is traversed; at THRESHOLD layers the path is
185
+ // "myelinated" — an entrenched habit. Strength saturates at MAX_LAYERS.
186
+ const MYELIN = { THRESHOLD: 3, MAX_LAYERS: 10 };
187
+
188
+ // the myelin tracker (Bloc A — myelination, runtime organ).
189
+ // It learns habitual tool ADJACENCY: which tool tends to follow which, and how
190
+ // entrenched that habit is. This complements the condenser, which links tools by
191
+ // DATA FLOW (an output value feeding the next call's arg) and persists/crystallizes
192
+ // them. Myelin needs no data link — pure succession — so it surfaces habits the
193
+ // condenser can't see. Runtime-only, value-free (edges are tool NAMES only),
194
+ // bounded. We deliberately drop the sandbox's simulated latency: prod measures
195
+ // real latency in the immune system and won't report an invented number.
196
+ export function createMyelinTracker() {
197
+ const axons = new Map(); // "src-->tgt" -> { strength, traversals, lastTs, myelinated }
198
+ let prev = null; // the previously observed tool — the chain is the session
199
+
200
+ function observe(tool, ts) {
201
+ if (prev !== null && prev !== tool) reinforce(`${prev}-->${tool}`, ts); // no self-edges
202
+ prev = tool;
203
+ }
204
+
205
+ function reinforce(key, ts) {
206
+ let a = axons.get(key);
207
+ if (!a) {
208
+ if (axons.size >= ENGINE_LIMITS.MAX_AXONS) evictWeakest();
209
+ a = { strength: 0, traversals: 0, lastTs: ts, myelinated: false };
210
+ axons.set(key, a);
211
+ }
212
+ a.traversals += 1;
213
+ a.lastTs = ts;
214
+ if (a.strength < MYELIN.MAX_LAYERS) a.strength += 1;
215
+ a.myelinated = a.strength >= MYELIN.THRESHOLD;
216
+ }
217
+
218
+ // weakest first; among equals, the stalest — same eviction shape as circuits
219
+ function evictWeakest() {
220
+ let victimKey = null;
221
+ let victim = null;
222
+ for (const [k, a] of axons) {
223
+ if (!victim || a.strength < victim.strength || (a.strength === victim.strength && a.lastTs < victim.lastTs)) {
224
+ victimKey = k;
225
+ victim = a;
226
+ }
227
+ }
228
+ if (victimKey !== null) axons.delete(victimKey);
229
+ }
230
+
231
+ function invalidate(tool) {
232
+ if (prev === tool) prev = null;
233
+ for (const k of [...axons.keys()]) {
234
+ if (k.startsWith(`${tool}-->`) || k.endsWith(`-->${tool}`)) axons.delete(k);
235
+ }
236
+ }
237
+
238
+ // established habits only (myelinated edges) — names + strength, never a value.
239
+ function snapshot() {
240
+ const edges = {};
241
+ let myelinated = 0;
242
+ for (const [k, a] of axons) {
243
+ if (a.myelinated) {
244
+ myelinated += 1;
245
+ edges[k] = { strength: a.strength, traversals: a.traversals };
246
+ }
247
+ }
248
+ return { stats: { tracked: axons.size, myelinated }, edges };
249
+ }
250
+
251
+ return { observe, invalidate, snapshot };
252
+ }
253
+
254
+ // the cartography of the invisible (Bloc D — slice 4, ported from sparda.ts
255
+ // NoetherScanner + GravitationalLens). The other organs read a tool in isolation
256
+ // (does ITS output move — slice 1; on what beat — slice 2) or simple succession
257
+ // (slice 3 / the condenser). Bloc D maps the relationships none of them can see:
258
+ // - a Noether INVARIANT: a response field that stays conserved even while the app
259
+ // is being mutated by writes — a true constant, the safest thing to cache hard;
260
+ // - a GHOST DEPENDENCY: a write that silently MOVES some unrelated read — no data
261
+ // flows between them (so the condenser is blind to it), they need not be adjacent
262
+ // (so myelin is blind to it), yet the write reliably perturbs the read.
263
+ //
264
+ // Both sub-organs in the literal port retained whole result VALUES to compare them —
265
+ // a direct ADR-014 violation in prod (sparda.json is git-committed; values can be
266
+ // PII). We keep only FNV-1a fingerprints — the same trick slice 1 uses — so the map
267
+ // holds field/tool NAMES, counts and hashes, never a payload. We also drop the port's
268
+ // CategoryMapper "virtual routes": it is the 2-hop transitive closure of slice-3
269
+ // myelin (redundant), its confidence is an arbitrary min(a,b)/10, and its triple-
270
+ // nested rescan is O(E^2) on every call — the wrong trade even off the hot path.
271
+
272
+ // a field is a conserved invariant once it has held the same fingerprint across at
273
+ // least MIN_OBS reads with at least MIN_STABILITY of them unchanged. We surface it
274
+ // ONLY while writes have also been seen — without a mutation in play it is just
275
+ // slice-1 stability restated, nothing new.
276
+ const NOETHER = { MIN_OBS: 5, MIN_STABILITY: 0.85 };
277
+
278
+ export function createNoetherScanner() {
279
+ const fields = new Map(); // tool -> Map<field, { first, total, matches }>
280
+ const writeTools = new Set(); // names of tools observed mutating (bounded)
281
+
282
+ function observe(tool, result, isWrite) {
283
+ if (isWrite) {
284
+ if (writeTools.size < ENGINE_LIMITS.MAX_TOOLS) writeTools.add(tool);
285
+ return; // a write's own body is not a state read — it only proves mutation happened
286
+ }
287
+ let rec = fields.get(tool);
288
+ if (!rec) {
289
+ if (fields.size >= ENGINE_LIMITS.MAX_TOOLS) return; // bounded: stop learning new tools
290
+ rec = new Map();
291
+ fields.set(tool, rec);
292
+ }
293
+ for (const [k, h] of fieldHashes(result)) {
294
+ const f = rec.get(k);
295
+ if (!f) {
296
+ if (rec.size < ENGINE_LIMITS.MAX_FIELDS) rec.set(k, { first: h, total: 1, matches: 1 });
297
+ continue;
298
+ }
299
+ f.total += 1;
300
+ if (f.first === h) f.matches += 1;
301
+ }
302
+ }
303
+
304
+ function invalidate(tool) { fields.delete(tool); writeTools.delete(tool); }
305
+
306
+ // conserved fields only — names + rates, never a value. Surfaced solely once writes
307
+ // have been observed, so each invariant is a genuine "stable despite mutation" claim.
308
+ function snapshot() {
309
+ const invariants = [];
310
+ if (writeTools.size > 0) {
311
+ for (const [tool, rec] of fields) {
312
+ for (const [field, f] of rec) {
313
+ if (f.total < NOETHER.MIN_OBS) continue;
314
+ const rate = f.matches / f.total;
315
+ if (rate >= NOETHER.MIN_STABILITY) {
316
+ invariants.push({ tool, field, stability: Number(rate.toFixed(3)), observations: f.total });
317
+ }
318
+ }
319
+ }
320
+ }
321
+ return { invariants, concurrentWrites: [...writeTools] };
322
+ }
323
+
324
+ return { observe, invalidate, snapshot };
325
+ }
326
+
327
+ // a ghost dependency crystallizes once a write tool W has been followed by a MOVED
328
+ // read G in at least MIN_CORRELATION of MIN_OBS distinct write episodes. The literal
329
+ // port incremented hits and total together, so its correlation was always 1.0 — it
330
+ // could never see a write that DOESN'T move a read. We count every post-write sighting
331
+ // (one vote per episode) and only the moves as hits, so the rate is honest.
332
+ const GHOST = { MIN_OBS: 3, MIN_CORRELATION: 0.7 };
333
+
334
+ export function createGravitationalLens() {
335
+ const lastHash = new Map(); // read tool -> last whole-result fingerprint
336
+ const corr = new Map(); // "W:::G" -> { hits, total }
337
+ let pendingWrite = null; // the write whose effect we are still attributing
338
+ let snap = null; // fingerprint of every read at the instant W fired
339
+ let scored = new Set(); // "W:::G" already counted this write episode
340
+
341
+ function observe(tool, result, isWrite) {
342
+ if (isWrite) {
343
+ snap = new Map(lastHash); // freeze the readable world the moment W mutates it
344
+ pendingWrite = tool;
345
+ scored = new Set();
346
+ return;
347
+ }
348
+ const h = fnv1a(safeStringify(result));
349
+ if (pendingWrite !== null && snap.has(tool)) {
350
+ const key = `${pendingWrite}:::${tool}`;
351
+ if (!scored.has(key)) { // one vote per write episode — never inflate a repeated read
352
+ scored.add(key);
353
+ let c = corr.get(key);
354
+ if (!c) {
355
+ if (corr.size >= ENGINE_LIMITS.MAX_GHOSTS) evictWeakest();
356
+ c = { hits: 0, total: 0 };
357
+ corr.set(key, c);
358
+ }
359
+ c.total += 1;
360
+ if (snap.get(tool) !== h) c.hits += 1; // the write moved this read
361
+ }
362
+ }
363
+ if (lastHash.size < ENGINE_LIMITS.MAX_TOOLS || lastHash.has(tool)) lastHash.set(tool, h);
364
+ }
365
+
366
+ // least-observed first; among equals, the least-correlated — same "weakest goes"
367
+ // shape as circuits and axons.
368
+ function evictWeakest() {
369
+ let victimKey = null;
370
+ let victim = null;
371
+ for (const [k, c] of corr) {
372
+ if (!victim || c.total < victim.total || (c.total === victim.total && c.hits < victim.hits)) {
373
+ victimKey = k;
374
+ victim = c;
375
+ }
376
+ }
377
+ if (victimKey !== null) corr.delete(victimKey);
378
+ }
379
+
380
+ function invalidate(tool) {
381
+ lastHash.delete(tool);
382
+ if (pendingWrite === tool) { pendingWrite = null; snap = null; }
383
+ for (const k of [...corr.keys()]) {
384
+ const [w, g] = k.split(':::');
385
+ if (w === tool || g === tool) corr.delete(k);
386
+ }
387
+ }
388
+
389
+ // crystallized ghost edges only — tool names + correlation, never a value.
390
+ function snapshot() {
391
+ const ghosts = [];
392
+ for (const [k, c] of corr) {
393
+ if (c.total < GHOST.MIN_OBS) continue;
394
+ const rate = c.hits / c.total;
395
+ if (rate >= GHOST.MIN_CORRELATION) {
396
+ const [writeTool, affects] = k.split(':::');
397
+ ghosts.push({ writeTool, affects, correlation: Number(rate.toFixed(3)), observations: c.total });
398
+ }
399
+ }
400
+ return { ghosts };
401
+ }
402
+
403
+ return { observe, invalidate, snapshot };
404
+ }
405
+
406
+ // Bloc D spine — Noether invariants + ghost dependencies behind one surface, so the
407
+ // engine spine adds a single organ. (CategoryMapper deliberately omitted — see above.)
408
+ export function createDependencyMap() {
409
+ const noether = createNoetherScanner();
410
+ const lens = createGravitationalLens();
411
+ return {
412
+ observe(tool, result, isWrite) {
413
+ noether.observe(tool, result, isWrite);
414
+ lens.observe(tool, result, isWrite);
415
+ },
416
+ invalidate(tool) { noether.invalidate(tool); lens.invalidate(tool); },
417
+ snapshot() {
418
+ const n = noether.snapshot();
419
+ const l = lens.snapshot();
420
+ return {
421
+ stats: { invariants: n.invariants.length, ghosts: l.ghosts.length },
422
+ invariants: n.invariants,
423
+ ghosts: l.ghosts,
424
+ concurrentWrites: n.concurrentWrites,
425
+ };
426
+ },
427
+ };
428
+ }
429
+
430
+ // the flywheel (Bloc B, slice 5 — ported from sparda.ts BloomGate/BlocB.preCall).
431
+ // THE turning point: every organ above only OBSERVES and reports. This one ACTS —
432
+ // preCall() can serve a proven-stable read straight from memory so the host call is
433
+ // never made (R4.3, ADR-020). It is therefore the first organ that retains result
434
+ // VALUES — it must, to hand them back. That is NOT an ADR-014 violation: ADR-014
435
+ // forbids values in PERSISTED state (sparda.json is git-committed; values can be
436
+ // PII). This cache is RAM-only, runtime-only (rule #5 — nothing to carry over),
437
+ // never serialized, and absent from snapshot() (which stays counts only). The bytes
438
+ // it holds are the same ones the host already holds before replying, memoized for
439
+ // one TTL window, dead with the process; a value is reachable ONLY through preCall
440
+ // serving it back to the same client that would have received it from the host.
441
+ //
442
+ // Three guards keep it honest: (1) it serves ONLY reads proven pure — the identical
443
+ // whole-response fingerprint seen MIN_HITS times for the exact same canonical arg
444
+ // signature (the same ≥3 bar as the router's purity detector, ADR-017); never a
445
+ // response it has not watched repeat. (2) TTL bounds staleness for ANY cause,
446
+ // including a mutation through a channel SPARDA can't see — freshness is measured
447
+ // from the last real host fetch and a hit never extends it. (3) write-invalidation
448
+ // (orchestrated by the spine via Bloc D ghost deps) purges precisely the reads an
449
+ // observed write moves. Bounded (rule #1): MAX_ENTRIES, oldest-evicted.
450
+ const FLYWHEEL = { MIN_HITS: 3, TTL_MS: 30_000, MAX_ENTRIES: 256 };
451
+
452
+ // recursively key-sorted so {a,b} and {b,a} are the SAME query (safeStringify alone
453
+ // is order-sensitive). Arrays keep order; scalars pass through.
454
+ function canonicalize(v) {
455
+ if (Array.isArray(v)) return v.map(canonicalize);
456
+ if (v && typeof v === 'object') {
457
+ const out = {};
458
+ for (const k of Object.keys(v).sort()) out[k] = canonicalize(v[k]);
459
+ return out;
460
+ }
461
+ return v;
462
+ }
463
+ function canonicalArgSig(args) { return safeStringify(canonicalize(args)); }
464
+
465
+ export function createFlywheel() {
466
+ // entryKey "tool::<fnv1a(argsig)>" -> { tool, hash, hits, value, ts }. The key
467
+ // hashes the args too, so not even a raw arg string is held — only a fixed token.
468
+ const cache = new Map();
469
+ const stats = { served: 0, misses: 0, evictions: 0 };
470
+
471
+ function keyFor(tool, args) { return `${tool}::${fnv1a(canonicalArgSig(args))}`; }
472
+
473
+ // hot-path surface (wired in 5b): return a cached value ONLY when proven pure and
474
+ // fresh. now is injectable so freshness is deterministic in tests.
475
+ function preCall(tool, args, now = Date.now()) {
476
+ const e = cache.get(keyFor(tool, args));
477
+ if (!e || e.hits < FLYWHEEL.MIN_HITS || now - e.ts > FLYWHEEL.TTL_MS) {
478
+ stats.misses += 1;
479
+ return { hit: false, value: null };
480
+ }
481
+ stats.served += 1;
482
+ return { hit: true, value: e.value };
483
+ }
484
+
485
+ // population (off the hot path, reads only). An identical answer advances the
486
+ // purity proof; a changed answer restarts it and the latest bytes win. ts is the
487
+ // real fetch time — freshness is anchored here, never on a serve.
488
+ function observe(tool, args, result, ts = Date.now()) {
489
+ const k = keyFor(tool, args);
490
+ const h = fnv1a(safeStringify(result));
491
+ let e = cache.get(k);
492
+ if (!e) {
493
+ if (cache.size >= FLYWHEEL.MAX_ENTRIES) evictOldest();
494
+ cache.set(k, { tool, hash: h, hits: 1, value: result, ts });
495
+ return;
496
+ }
497
+ if (e.hash === h) e.hits += 1; // another identical sighting — closer to / past the bar
498
+ else { e.hash = h; e.hits = 1; } // the answer moved — restart the proof
499
+ e.value = result; // latest bytes always win
500
+ e.ts = ts; // freshness from this real fetch
501
+ }
502
+
503
+ // oldest fetch goes first — same "weakest goes" shape as circuits/axons/ghosts.
504
+ function evictOldest() {
505
+ let victimKey = null;
506
+ let oldest = Infinity;
507
+ for (const [k, e] of cache) if (e.ts < oldest) { oldest = e.ts; victimKey = k; }
508
+ if (victimKey !== null) { cache.delete(victimKey); stats.evictions += 1; }
509
+ }
510
+
511
+ // drop every entry for a tool — used by the spine to purge reads a write moved.
512
+ function invalidate(tool) {
513
+ for (const [k, e] of cache) if (e.tool === tool) cache.delete(k);
514
+ }
515
+
516
+ // counts only — never a value, never an arg. ready = entries currently serveable
517
+ // (pure AND fresh), the honest "how much is live right now".
518
+ function snapshot(now = Date.now()) {
519
+ let ready = 0;
520
+ for (const e of cache.values()) {
521
+ if (e.hits >= FLYWHEEL.MIN_HITS && now - e.ts <= FLYWHEEL.TTL_MS) ready += 1;
522
+ }
523
+ return { stats: { ...stats, entries: cache.size, ready } };
524
+ }
525
+
526
+ return { preCall, observe, invalidate, snapshot };
527
+ }
528
+
529
+ // the engine spine. Slices 1 (stability), 2 (rhythm), 3 (myelin), 4 (dependency
530
+ // map) and 5 (flywheel) all hang off one observe/snapshot surface, so the bridge
531
+ // wiring stays minimal as slices land.
532
+ export function createSpardaEngine() {
533
+ const predictive = createPredictiveEngine();
534
+ const rhythm = createRhythmDetector();
535
+ const myelin = createMyelinTracker();
536
+ const deps = createDependencyMap();
537
+ const flywheel = createFlywheel();
538
+ return {
539
+ // hot-path serve (R4.3): ask the flywheel for a proven-stable answer before the
540
+ // bridge pays the host. Always safe to call — returns {hit:false} until an organ
541
+ // has proof. The bridge gates the *use* of a hit behind SPARDA_FLYWHEEL (5b).
542
+ preCall(tool, args) { return flywheel.preCall(tool, args); },
543
+ // ts is captured on the hot path (a cheap clock read) and passed in, so the
544
+ // cadence reflects real call times — not whenever the idle harvester drains.
545
+ // isWrite lets Bloc D separate state reads from mutations (a GET can't move the
546
+ // app; a write can); the other organs treat every successful call the same. args
547
+ // feeds only the flywheel (its cache key); the rest ignore it.
548
+ observe(tool, result, ts = Date.now(), isWrite = false, args = undefined) {
549
+ rhythm.record(tool, ts);
550
+ myelin.observe(tool, ts);
551
+ deps.observe(tool, result, isWrite);
552
+ if (isWrite) {
553
+ // a write may silently move cached reads. Purge precisely via the learned
554
+ // ghost map (the slice-4 payoff) instead of nuking the whole cache. Runs in
555
+ // idle, so the bounded ghost scan never touches a request (rule #1).
556
+ flywheel.invalidate(tool);
557
+ for (const g of deps.snapshot().ghosts) if (g.writeTool === tool) flywheel.invalidate(g.affects);
558
+ } else {
559
+ flywheel.observe(tool, args, result, ts);
560
+ }
561
+ return predictive.observe(tool, result);
562
+ },
563
+ invalidate(tool) { predictive.invalidate(tool); rhythm.invalidate(tool); myelin.invalidate(tool); deps.invalidate(tool); flywheel.invalidate(tool); },
564
+ // structural cache purge (5b): only the bridge knows HTTP paths, so when a write
565
+ // hits a path it asks us to drop the cached GET on that SAME path. Flywheel-only —
566
+ // unlike invalidate(), it must NOT erase the sibling read's learned rhythm/myelin/
567
+ // deps, only its now-stale cached answer.
568
+ invalidateCache(tool) { flywheel.invalidate(tool); },
569
+ snapshot() {
570
+ return {
571
+ stability: predictive.snapshot(),
572
+ rhythm: rhythm.snapshot(),
573
+ myelin: myelin.snapshot(),
574
+ dependencies: deps.snapshot(),
575
+ flywheel: flywheel.snapshot(),
576
+ };
577
+ },
578
+ };
579
+ }
@@ -1,4 +1,4 @@
1
- // server/idle.js — the idle harvester (ROADMAP R4.4): SPARDA's internal work
1
+ // server/idle.js — the idle harvester: SPARDA's internal work
2
2
  // (condensation, persistence, future organs) runs only when the event loop is
3
3
  // quiet. One job per tick — a drip, never a burst: perceived saturation stays
4
4
  // at zero even while the organism digests.