@remnic/core 10.0.101 → 10.0.103

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 (36) hide show
  1. package/dist/access-admin-ops-surface.js +2 -2
  2. package/dist/access-cli.js +2 -2
  3. package/dist/access-extraction-force-flush.js +2 -2
  4. package/dist/access-http-briefing.js +2 -2
  5. package/dist/access-http-query.js +2 -2
  6. package/dist/access-http.js +2 -2
  7. package/dist/access-identity-continuity-surface.js +2 -2
  8. package/dist/access-lcm-surface.js +2 -2
  9. package/dist/access-mcp.js +2 -2
  10. package/dist/access-observe-write-surface.js +2 -2
  11. package/dist/access-operations-batch.js +2 -2
  12. package/dist/access-operations.js +2 -2
  13. package/dist/access-recall-concurrency.js +2 -2
  14. package/dist/access-recall-response.js +2 -2
  15. package/dist/access-recall-surface.js +2 -2
  16. package/dist/access-service.js +2 -2
  17. package/dist/{chunk-QIPDTAWZ.js → chunk-D77I4MD4.js} +2 -2
  18. package/dist/{chunk-PL36IQPL.js → chunk-KCRHFW3N.js} +69 -20
  19. package/dist/chunk-KCRHFW3N.js.map +1 -0
  20. package/dist/{chunk-MMOXCCHF.js → chunk-T3X4CCHC.js} +2 -2
  21. package/dist/cli.js +2 -2
  22. package/dist/explicit-capture.js +2 -2
  23. package/dist/harmonic-retrieval.d.ts +14 -4
  24. package/dist/harmonic-retrieval.d.ts.map +1 -1
  25. package/dist/harmonic-retrieval.js +5 -3
  26. package/dist/index.js +2 -2
  27. package/dist/orchestration/backlog-scheduler.js +3 -3
  28. package/dist/orchestration/buffer-drain-runner.js +3 -3
  29. package/dist/orchestrator.js +2 -2
  30. package/dist/recall-navigation.js +2 -2
  31. package/package.json +2 -2
  32. package/src/harmonic-graph-cache.test.ts +172 -7
  33. package/src/harmonic-retrieval.ts +125 -27
  34. package/dist/chunk-PL36IQPL.js.map +0 -1
  35. /package/dist/{chunk-QIPDTAWZ.js.map → chunk-D77I4MD4.js.map} +0 -0
  36. /package/dist/{chunk-MMOXCCHF.js.map → chunk-T3X4CCHC.js.map} +0 -0
@@ -25,13 +25,20 @@ export interface ProjectedHarmonicGraph {
25
25
  nodes: AbstractionNode[];
26
26
  anchors: CueAnchor[];
27
27
  }
28
- /** Counters for tests and diagnostics. `shared` counts reads that joined a build already running. */
28
+ /** Counters for tests and diagnostics. `stale` hits also count as `hits`. */
29
29
  export declare const harmonicGraphCacheStats: {
30
30
  hits: number;
31
31
  misses: number;
32
+ stale: number;
32
33
  shared: number;
33
34
  };
34
35
  export declare function clearHarmonicGraphCache(): void;
36
+ /**
37
+ * Resolves once every build running now has settled, and after the failure bookkeeping of a background
38
+ * refresh (that handler was attached when the refresh started, so it runs first). For tests: a time-based
39
+ * wait races a slow build.
40
+ */
41
+ export declare function settleHarmonicGraphBuilds(): Promise<void>;
35
42
  /**
36
43
  * `readProjectedHarmonicGraph` with an opt-in in-memory cache. Every recall used
37
44
  * to re-read all nodes, anchors and source memories from disk (about 4.7 s on a
@@ -40,9 +47,12 @@ export declare function clearHarmonicGraphCache(): void;
40
47
  * cold-write version sentinels (bumped by every memory write, in any process) and
41
48
  * the graph version sentinel (bumped by node and anchor writes). A stamp read is
42
49
  * four stats, not a scan of the memories. The TTL bounds the one input a sentinel
43
- * cannot see, which is wall-clock validity expiry. Concurrent misses share one
44
- * build. An explicit `nowMs` bypasses the cache so tests stay deterministic. The
45
- * cached arrays are frozen: callers must not mutate them.
50
+ * cannot see, which is wall-clock validity expiry; past it the stale graph is served
51
+ * while one refresh runs (up to GRAPH_CACHE_MAX_STALE_MS). A changed stamp always
52
+ * waits for a fresh build, because a stale graph could carry governed metadata of a
53
+ * memory that left the active set. Concurrent misses share one build. An explicit
54
+ * `nowMs` bypasses the cache so tests stay deterministic. The cached arrays are
55
+ * frozen: callers must not mutate them.
46
56
  */
47
57
  export declare function readProjectedHarmonicGraph(options: {
48
58
  memoryDir: string;
@@ -1 +1 @@
1
- {"version":3,"file":"harmonic-retrieval.d.ts","sourceRoot":"","sources":["../src/harmonic-retrieval.ts"],"names":[],"mappings":"AAMC,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAC;AACjC,OAAO,EAA+C,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAsPnH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,aAAa,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,qBAAqB,EAAE,CAAC;CACzC;AA+ED,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAY7B;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAavB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,OAAO,EAAE,SAAS,EAAE,CAAC;CACtB;AAiGD,qGAAqG;AACrG,eAAO,MAAM,uBAAuB;;;;CAAoC,CAAC;AAWzE,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AAuDD;;;;;;;;;;;GAWG;AACH,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA4BlC;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,yHAAyH;IACzH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAoFrC"}
1
+ {"version":3,"file":"harmonic-retrieval.d.ts","sourceRoot":"","sources":["../src/harmonic-retrieval.ts"],"names":[],"mappings":"AAOC,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAC;AACjC,OAAO,EAA+C,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AA+PnH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,aAAa,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,qBAAqB,EAAE,CAAC;CACzC;AA+ED,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAY7B;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAavB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,OAAO,EAAE,SAAS,EAAE,CAAC;CACtB;AAqKD,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB;;;;;CAA8C,CAAC;AAWnF,wBAAgB,uBAAuB,IAAI,IAAI,CAI9C;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,IAAI,OAAO,CAAC,IAAI,CAAC,CAE/D;AAiDD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAyClC;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,yHAAyH;IACzH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAoFrC"}
@@ -4,8 +4,9 @@ import {
4
4
  readAbstractionNodes,
5
5
  readCueAnchors,
6
6
  readProjectedHarmonicGraph,
7
- searchHarmonicRetrieval
8
- } from "./chunk-PL36IQPL.js";
7
+ searchHarmonicRetrieval,
8
+ settleHarmonicGraphBuilds
9
+ } from "./chunk-KCRHFW3N.js";
9
10
  import "./chunk-HUDPXTXW.js";
10
11
  import "./chunk-AZEZSQ5R.js";
11
12
  import "./chunk-FL3O34XF.js";
@@ -132,6 +133,7 @@ export {
132
133
  readAbstractionNodes,
133
134
  readCueAnchors,
134
135
  readProjectedHarmonicGraph,
135
- searchHarmonicRetrieval
136
+ searchHarmonicRetrieval,
137
+ settleHarmonicGraphBuilds
136
138
  };
137
139
  //# sourceMappingURL=harmonic-retrieval.js.map
package/dist/index.js CHANGED
@@ -259,7 +259,7 @@ import {
259
259
  writeManifest,
260
260
  writeMeetingEpisodeMemory,
261
261
  writeTimelineState
262
- } from "./chunk-MMOXCCHF.js";
262
+ } from "./chunk-T3X4CCHC.js";
263
263
  import "./chunk-6P7U3HWG.js";
264
264
  import "./chunk-PQM3B54K.js";
265
265
  import {
@@ -717,7 +717,7 @@ import {
717
717
  import "./chunk-UOBPK2OS.js";
718
718
  import "./chunk-2YA5N2BY.js";
719
719
  import "./chunk-3HSRWJTC.js";
720
- import "./chunk-PL36IQPL.js";
720
+ import "./chunk-KCRHFW3N.js";
721
721
  import "./chunk-HUDPXTXW.js";
722
722
  import "./chunk-AZEZSQ5R.js";
723
723
  import "./chunk-YAZNBMNF.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  BufferDrainRunner
3
- } from "../chunk-QIPDTAWZ.js";
4
- import "../chunk-MMOXCCHF.js";
3
+ } from "../chunk-D77I4MD4.js";
4
+ import "../chunk-T3X4CCHC.js";
5
5
  import "../chunk-6P7U3HWG.js";
6
6
  import "../chunk-PQM3B54K.js";
7
7
  import "../chunk-B3DR2JBZ.js";
@@ -174,7 +174,7 @@ import "../chunk-MDPT6QFK.js";
174
174
  import "../chunk-UOBPK2OS.js";
175
175
  import "../chunk-2YA5N2BY.js";
176
176
  import "../chunk-3HSRWJTC.js";
177
- import "../chunk-PL36IQPL.js";
177
+ import "../chunk-KCRHFW3N.js";
178
178
  import "../chunk-HUDPXTXW.js";
179
179
  import "../chunk-AZEZSQ5R.js";
180
180
  import "../chunk-YAZNBMNF.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  BufferDrainRunner,
3
3
  ClaimBufferStorage
4
- } from "../chunk-QIPDTAWZ.js";
5
- import "../chunk-MMOXCCHF.js";
4
+ } from "../chunk-D77I4MD4.js";
5
+ import "../chunk-T3X4CCHC.js";
6
6
  import "../chunk-6P7U3HWG.js";
7
7
  import "../chunk-PQM3B54K.js";
8
8
  import "../chunk-B3DR2JBZ.js";
@@ -175,7 +175,7 @@ import "../chunk-MDPT6QFK.js";
175
175
  import "../chunk-UOBPK2OS.js";
176
176
  import "../chunk-2YA5N2BY.js";
177
177
  import "../chunk-3HSRWJTC.js";
178
- import "../chunk-PL36IQPL.js";
178
+ import "../chunk-KCRHFW3N.js";
179
179
  import "../chunk-HUDPXTXW.js";
180
180
  import "../chunk-AZEZSQ5R.js";
181
181
  import "../chunk-YAZNBMNF.js";
@@ -6,7 +6,7 @@ import {
6
6
  mergeGraphExpandedResults,
7
7
  qmdStartupCollectionChecksWithTimeout,
8
8
  resolveEffectiveIdentityInjectionMode
9
- } from "./chunk-MMOXCCHF.js";
9
+ } from "./chunk-T3X4CCHC.js";
10
10
  import "./chunk-6P7U3HWG.js";
11
11
  import "./chunk-PQM3B54K.js";
12
12
  import "./chunk-B3DR2JBZ.js";
@@ -183,7 +183,7 @@ import "./chunk-MDPT6QFK.js";
183
183
  import "./chunk-UOBPK2OS.js";
184
184
  import "./chunk-2YA5N2BY.js";
185
185
  import "./chunk-3HSRWJTC.js";
186
- import "./chunk-PL36IQPL.js";
186
+ import "./chunk-KCRHFW3N.js";
187
187
  import "./chunk-HUDPXTXW.js";
188
188
  import "./chunk-AZEZSQ5R.js";
189
189
  import "./chunk-YAZNBMNF.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runRecallNavigation
3
- } from "./chunk-MMOXCCHF.js";
3
+ } from "./chunk-T3X4CCHC.js";
4
4
  import "./chunk-6P7U3HWG.js";
5
5
  import "./chunk-PQM3B54K.js";
6
6
  import "./chunk-B3DR2JBZ.js";
@@ -173,7 +173,7 @@ import "./chunk-MDPT6QFK.js";
173
173
  import "./chunk-UOBPK2OS.js";
174
174
  import "./chunk-2YA5N2BY.js";
175
175
  import "./chunk-3HSRWJTC.js";
176
- import "./chunk-PL36IQPL.js";
176
+ import "./chunk-KCRHFW3N.js";
177
177
  import "./chunk-HUDPXTXW.js";
178
178
  import "./chunk-AZEZSQ5R.js";
179
179
  import "./chunk-YAZNBMNF.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "10.0.101",
3
+ "version": "10.0.103",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3386,7 +3386,7 @@
3386
3386
  "core"
3387
3387
  ],
3388
3388
  "peerDependencies": {
3389
- "@remnic/coding-graph": "^10.0.101"
3389
+ "@remnic/coding-graph": "^10.0.103"
3390
3390
  },
3391
3391
  "peerDependenciesMeta": {
3392
3392
  "@remnic/coding-graph": {
@@ -11,6 +11,7 @@ import {
11
11
  type ProjectedHarmonicGraph,
12
12
  readProjectedHarmonicGraph,
13
13
  searchHarmonicRetrieval,
14
+ settleHarmonicGraphBuilds,
14
15
  } from "./harmonic-retrieval.js";
15
16
  import { bumpMemoryCorpusVersionForDir } from "./memory-corpus-version.js";
16
17
  import { StorageManager } from "./storage.js";
@@ -232,6 +233,14 @@ test("pruneOrphanCueAnchors alone invalidates the cached graph", async () => {
232
233
  // Recall waits for the graph build, so a cache miss costs every recall that lands on it. Production: a
233
234
  // 2106 node, 9174 anchor graph rebuilt in 5 s standalone, 7 s inside the daemon, and 17 to 32 s when two
234
235
  // recalls overlapped and each ran its own build.
236
+ async function until(condition: () => Promise<boolean>) {
237
+ for (let i = 0; i < 400; i += 1) {
238
+ if (await condition()) return;
239
+ await new Promise((resolve) => setTimeout(resolve, 5));
240
+ }
241
+ assert.fail("condition not reached in 2 s");
242
+ }
243
+
235
244
  test("concurrent reads on a cold cache share one build", async () => {
236
245
  await withDir(async (memoryDir, storage) => {
237
246
  const memory = await storage.writeMemory("fact", "theta shared fact", { source: "test" });
@@ -257,6 +266,93 @@ test("a caller that aborts leaves the shared build for the others", async () =>
257
266
  });
258
267
  });
259
268
 
269
+ test("a read past the TTL answers from the stale graph and refreshes behind it", async (t) => {
270
+ await withDir(async (memoryDir, storage) => {
271
+ const memory = await storage.writeMemory("fact", "zeta ttl fact", { source: "test" });
272
+ const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "zeta ttl fact", [memory.id]) });
273
+ const start = Date.now();
274
+ const clock = t.mock.method(Date, "now", () => start);
275
+ assert.equal((await read(memoryDir)).nodes.length, 1);
276
+ await rm(nodeFile, { force: true });
277
+ clock.mock.mockImplementation(() => start + 29_000);
278
+ assert.equal((await read(memoryDir)).nodes.length, 1, "still cached inside the TTL");
279
+ const missesBefore = harmonicGraphCacheStats.misses;
280
+ clock.mock.mockImplementation(() => start + 31_000);
281
+ assert.equal((await read(memoryDir)).nodes.length, 1, "past the TTL the caller does not wait for a rebuild");
282
+ assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "a refresh was started");
283
+ await until(async () => (await read(memoryDir)).nodes.length === 0);
284
+ });
285
+ });
286
+
287
+ test("a graph past the stale limit is rebuilt before it is served", async (t) => {
288
+ await withDir(async (memoryDir, storage) => {
289
+ const memory = await storage.writeMemory("fact", "kappa limit fact", { source: "test" });
290
+ const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "kappa limit fact", [memory.id]) });
291
+ const start = Date.now();
292
+ const clock = t.mock.method(Date, "now", () => start);
293
+ assert.equal((await read(memoryDir)).nodes.length, 1);
294
+ await rm(nodeFile, { force: true });
295
+ clock.mock.mockImplementation(() => start + 330_000);
296
+ assert.equal((await read(memoryDir)).nodes.length, 0, "the stale window ends 300 s after the TTL, at 330 s");
297
+ });
298
+ });
299
+
300
+ test("a graph is still served stale one millisecond before the stale window ends", async (t) => {
301
+ await withDir(async (memoryDir, storage) => {
302
+ const memory = await storage.writeMemory("fact", "kappa edge fact", { source: "test" });
303
+ const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "kappa edge fact", [memory.id]) });
304
+ const start = Date.now();
305
+ const clock = t.mock.method(Date, "now", () => start);
306
+ assert.equal((await read(memoryDir)).nodes.length, 1);
307
+ await rm(nodeFile, { force: true });
308
+ clock.mock.mockImplementation(() => start + 329_999);
309
+ assert.equal((await read(memoryDir)).nodes.length, 1, "up to 300 s past the TTL the stale graph is served");
310
+ await settleHarmonicGraphBuilds();
311
+ });
312
+ });
313
+
314
+ test("an ineligible source's invalid_at does not end the cache entry", async (t) => {
315
+ await withDir(async (memoryDir, storage) => {
316
+ const start = Date.now();
317
+ const clock = t.mock.method(Date, "now", () => start);
318
+ const live = await storage.writeMemory("fact", "pi eligible fact", { source: "test" });
319
+ const gone = await storage.writeMemory("fact", "pi quarantined fact", {
320
+ source: "test",
321
+ invalidAt: new Date(start + 10_000).toISOString(),
322
+ });
323
+ const gonePath = (await storage.getMemoryById(gone.id))!.path;
324
+ await writeFile(gonePath, (await readFile(gonePath, "utf8")).replace("category: fact", "category: fact\nstatus: quarantined"));
325
+ bumpMemoryCorpusVersionForDir(memoryDir); // the out-of-band status edit needs the documented bump, as in the status test above
326
+ await recordAbstractionNode({ memoryDir, node: node("n1", "pi mixed fact", [live.id, gone.id]) });
327
+ assert.equal((await read(memoryDir)).nodes.length, 1);
328
+ const before = { ...harmonicGraphCacheStats };
329
+ clock.mock.mockImplementation(() => start + 20_000);
330
+ assert.equal((await read(memoryDir)).nodes.length, 1);
331
+ assert.equal(harmonicGraphCacheStats.misses, before.misses, "the quarantined source's invalid_at passing starts no rebuild");
332
+ assert.equal(harmonicGraphCacheStats.hits, before.hits + 1);
333
+ });
334
+ });
335
+
336
+ test("a private support passport source's invalid_at does not end the cache entry", async (t) => {
337
+ await withDir(async (memoryDir, storage) => {
338
+ const start = Date.now();
339
+ const clock = t.mock.method(Date, "now", () => start);
340
+ const live = await storage.writeMemory("fact", "rho eligible fact", { source: "test" });
341
+ const card = await storage.writeMemory("fact", "rho passport card fact", {
342
+ source: "test",
343
+ tags: ["support-passport-card"],
344
+ invalidAt: new Date(start + 10_000).toISOString(),
345
+ });
346
+ await recordAbstractionNode({ memoryDir, node: node("n1", "rho mixed fact", [live.id, card.id]) });
347
+ assert.equal((await read(memoryDir)).nodes.length, 1);
348
+ const before = { ...harmonicGraphCacheStats };
349
+ clock.mock.mockImplementation(() => start + 20_000);
350
+ assert.equal((await read(memoryDir)).nodes.length, 1);
351
+ assert.equal(harmonicGraphCacheStats.misses, before.misses, "the passport source's invalid_at passing starts no rebuild");
352
+ assert.equal(harmonicGraphCacheStats.hits, before.hits + 1);
353
+ });
354
+ });
355
+
260
356
  // Unreadable node store: a file where the nodes directory belongs is not ENOENT, so the strict listing throws
261
357
  // (a corrupt node file would not: readAbstractionNodes skips those on purpose).
262
358
  async function breakNodeStore(memoryDir: string) {
@@ -270,6 +366,24 @@ async function breakNodeStore(memoryDir: string) {
270
366
  };
271
367
  }
272
368
 
369
+ test("a source whose invalid_at passes inside the stale window is never served", async (t) => {
370
+ await withDir(async (memoryDir, storage) => {
371
+ const start = Date.now();
372
+ const clock = t.mock.method(Date, "now", () => start);
373
+ const memory = await storage.writeMemory("fact", "lambda expiry fact", {
374
+ source: "test",
375
+ invalidAt: new Date(start + 60_000).toISOString(),
376
+ });
377
+ await recordAbstractionNode({ memoryDir, node: node("n1", "lambda expiry fact", [memory.id]) });
378
+ assert.equal((await read(memoryDir)).nodes.length, 1);
379
+ clock.mock.mockImplementation(() => start + 31_000);
380
+ assert.equal((await read(memoryDir)).nodes.length, 1, "past the TTL but before the invalid_at: stale is fine");
381
+ await until(async () => harmonicGraphCacheStats.stale >= 1);
382
+ clock.mock.mockImplementation(() => start + 61_000);
383
+ assert.equal((await read(memoryDir)).nodes.length, 0, "after the invalid_at the graph is rebuilt before it is served");
384
+ });
385
+ });
386
+
273
387
  test("callers that joined a failing build all reject, and the next read retries and succeeds", async () => {
274
388
  await withDir(async (memoryDir, storage) => {
275
389
  const memory = await storage.writeMemory("fact", "mu retry fact", { source: "test" });
@@ -284,18 +398,69 @@ test("callers that joined a failing build all reject, and the next read retries
284
398
  });
285
399
  });
286
400
 
287
- test("an entry older than the TTL is rebuilt", async (t) => {
401
+ test("a failing refresh is retried at most once per TTL, not on every stale read", async (t) => {
288
402
  await withDir(async (memoryDir, storage) => {
289
- const memory = await storage.writeMemory("fact", "zeta ttl fact", { source: "test" });
290
- const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "zeta ttl fact", [memory.id]) });
403
+ const memory = await storage.writeMemory("fact", "nu backoff fact", { source: "test" });
404
+ await recordAbstractionNode({ memoryDir, node: node("n1", "nu backoff fact", [memory.id]) });
291
405
  const start = Date.now();
292
406
  const clock = t.mock.method(Date, "now", () => start);
293
407
  assert.equal((await read(memoryDir)).nodes.length, 1);
294
- await rm(nodeFile, { force: true });
295
- clock.mock.mockImplementation(() => start + 29_000);
296
- assert.equal((await read(memoryDir)).nodes.length, 1, "still cached inside the TTL");
408
+ const restore = await breakNodeStore(memoryDir);
297
409
  clock.mock.mockImplementation(() => start + 31_000);
298
- assert.equal((await read(memoryDir)).nodes.length, 0, "rebuilt past the TTL");
410
+ const missesBefore = harmonicGraphCacheStats.misses;
411
+ assert.equal((await read(memoryDir)).nodes.length, 1, "the stale graph is still served");
412
+ // The failing build bumps `misses` before it reads the broken store: wait for it to settle, not for a time.
413
+ await settleHarmonicGraphBuilds();
414
+ assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "one refresh was started");
415
+ clock.mock.mockImplementation(() => start + 40_000);
416
+ for (let i = 0; i < 5; i += 1) await read(memoryDir);
417
+ await settleHarmonicGraphBuilds();
418
+ assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "no new build inside the backoff");
419
+ await restore();
420
+ clock.mock.mockImplementation(() => start + 62_000);
421
+ await read(memoryDir);
422
+ await until(async () => harmonicGraphCacheStats.misses === missesBefore + 2);
423
+ });
424
+ });
425
+
426
+ // The build's own clock reading is taken before it reads the anchors, so an invalid_at can pass while a
427
+ // build is still running. Every caller that joined that build must still get an unexpired graph.
428
+ test("a source that expires while the build is running is not served from that build", async () => {
429
+ await withDir(async (memoryDir, storage) => {
430
+ const sources: Array<{ id: string; path: string }> = [];
431
+ for (let i = 0; i < 120; i += 1) {
432
+ const memory = await storage.writeMemory("fact", `xi race fact ${i}`, { source: "test" });
433
+ await recordAbstractionNode({ memoryDir, node: node(`n${i}`, `xi race fact ${i}`, [memory.id]) });
434
+ sources.push({ id: memory.id, path: (await storage.getMemoryById(memory.id))!.path });
435
+ }
436
+ // Anchors are read after the sources are projected, so many of them make the tail the deadline falls in.
437
+ await upsertCueAnchors({
438
+ memoryDir,
439
+ anchors: Array.from({ length: 1500 }, (_, i) => anchor(`xi-a${i}`, `n${i % 120}`, sources[i % 120]!.id)),
440
+ });
441
+ // Calibrate: how long one cold build takes on this machine.
442
+ const calibrationStart = Date.now();
443
+ await read(memoryDir);
444
+ const buildMs = Date.now() - calibrationStart;
445
+ clearHarmonicGraphCache();
446
+ // n0's source expires near the end of the next build, which started before the deadline.
447
+ const first = sources[0]!;
448
+ const deadlineMs = Date.now() + Math.max(2, Math.floor(buildMs * 0.85));
449
+ await writeFile(
450
+ first.path,
451
+ (await readFile(first.path, "utf8")).replace("category: fact", `category: fact\ninvalidAt: ${new Date(deadlineMs).toISOString()}`),
452
+ );
453
+ // The storage layer caches parsed memories by corpus version, so an out-of-band edit needs the documented bump.
454
+ bumpMemoryCorpusVersionForDir(memoryDir);
455
+ const graph = await read(memoryDir);
456
+ const returnedAtMs = Date.now();
457
+ const served = graph.nodes.some((candidate) => candidate.nodeId === "n0");
458
+ // A build that finished before the deadline may legitimately include n0 (a 25 ms margin covers the
459
+ // hand-off); one that returned after it must not.
460
+ if (returnedAtMs >= deadlineMs + 25) {
461
+ assert.equal(served, false, `expired source served; build took about ${buildMs} ms`);
462
+ assert.equal(graph.nodes.length, 119);
463
+ }
299
464
  });
300
465
  });
301
466