@remnic/core 10.0.101 → 10.0.102

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 +152 -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.102",
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.102"
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,73 @@ 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
+
260
336
  // Unreadable node store: a file where the nodes directory belongs is not ENOENT, so the strict listing throws
261
337
  // (a corrupt node file would not: readAbstractionNodes skips those on purpose).
262
338
  async function breakNodeStore(memoryDir: string) {
@@ -270,6 +346,24 @@ async function breakNodeStore(memoryDir: string) {
270
346
  };
271
347
  }
272
348
 
349
+ test("a source whose invalid_at passes inside the stale window is never served", async (t) => {
350
+ await withDir(async (memoryDir, storage) => {
351
+ const start = Date.now();
352
+ const clock = t.mock.method(Date, "now", () => start);
353
+ const memory = await storage.writeMemory("fact", "lambda expiry fact", {
354
+ source: "test",
355
+ invalidAt: new Date(start + 60_000).toISOString(),
356
+ });
357
+ await recordAbstractionNode({ memoryDir, node: node("n1", "lambda expiry fact", [memory.id]) });
358
+ assert.equal((await read(memoryDir)).nodes.length, 1);
359
+ clock.mock.mockImplementation(() => start + 31_000);
360
+ assert.equal((await read(memoryDir)).nodes.length, 1, "past the TTL but before the invalid_at: stale is fine");
361
+ await until(async () => harmonicGraphCacheStats.stale >= 1);
362
+ clock.mock.mockImplementation(() => start + 61_000);
363
+ assert.equal((await read(memoryDir)).nodes.length, 0, "after the invalid_at the graph is rebuilt before it is served");
364
+ });
365
+ });
366
+
273
367
  test("callers that joined a failing build all reject, and the next read retries and succeeds", async () => {
274
368
  await withDir(async (memoryDir, storage) => {
275
369
  const memory = await storage.writeMemory("fact", "mu retry fact", { source: "test" });
@@ -284,18 +378,69 @@ test("callers that joined a failing build all reject, and the next read retries
284
378
  });
285
379
  });
286
380
 
287
- test("an entry older than the TTL is rebuilt", async (t) => {
381
+ test("a failing refresh is retried at most once per TTL, not on every stale read", async (t) => {
288
382
  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]) });
383
+ const memory = await storage.writeMemory("fact", "nu backoff fact", { source: "test" });
384
+ await recordAbstractionNode({ memoryDir, node: node("n1", "nu backoff fact", [memory.id]) });
291
385
  const start = Date.now();
292
386
  const clock = t.mock.method(Date, "now", () => start);
293
387
  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");
388
+ const restore = await breakNodeStore(memoryDir);
297
389
  clock.mock.mockImplementation(() => start + 31_000);
298
- assert.equal((await read(memoryDir)).nodes.length, 0, "rebuilt past the TTL");
390
+ const missesBefore = harmonicGraphCacheStats.misses;
391
+ assert.equal((await read(memoryDir)).nodes.length, 1, "the stale graph is still served");
392
+ // The failing build bumps `misses` before it reads the broken store: wait for it to settle, not for a time.
393
+ await settleHarmonicGraphBuilds();
394
+ assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "one refresh was started");
395
+ clock.mock.mockImplementation(() => start + 40_000);
396
+ for (let i = 0; i < 5; i += 1) await read(memoryDir);
397
+ await settleHarmonicGraphBuilds();
398
+ assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "no new build inside the backoff");
399
+ await restore();
400
+ clock.mock.mockImplementation(() => start + 62_000);
401
+ await read(memoryDir);
402
+ await until(async () => harmonicGraphCacheStats.misses === missesBefore + 2);
403
+ });
404
+ });
405
+
406
+ // The build's own clock reading is taken before it reads the anchors, so an invalid_at can pass while a
407
+ // build is still running. Every caller that joined that build must still get an unexpired graph.
408
+ test("a source that expires while the build is running is not served from that build", async () => {
409
+ await withDir(async (memoryDir, storage) => {
410
+ const sources: Array<{ id: string; path: string }> = [];
411
+ for (let i = 0; i < 120; i += 1) {
412
+ const memory = await storage.writeMemory("fact", `xi race fact ${i}`, { source: "test" });
413
+ await recordAbstractionNode({ memoryDir, node: node(`n${i}`, `xi race fact ${i}`, [memory.id]) });
414
+ sources.push({ id: memory.id, path: (await storage.getMemoryById(memory.id))!.path });
415
+ }
416
+ // Anchors are read after the sources are projected, so many of them make the tail the deadline falls in.
417
+ await upsertCueAnchors({
418
+ memoryDir,
419
+ anchors: Array.from({ length: 1500 }, (_, i) => anchor(`xi-a${i}`, `n${i % 120}`, sources[i % 120]!.id)),
420
+ });
421
+ // Calibrate: how long one cold build takes on this machine.
422
+ const calibrationStart = Date.now();
423
+ await read(memoryDir);
424
+ const buildMs = Date.now() - calibrationStart;
425
+ clearHarmonicGraphCache();
426
+ // n0's source expires near the end of the next build, which started before the deadline.
427
+ const first = sources[0]!;
428
+ const deadlineMs = Date.now() + Math.max(2, Math.floor(buildMs * 0.85));
429
+ await writeFile(
430
+ first.path,
431
+ (await readFile(first.path, "utf8")).replace("category: fact", `category: fact\ninvalidAt: ${new Date(deadlineMs).toISOString()}`),
432
+ );
433
+ // The storage layer caches parsed memories by corpus version, so an out-of-band edit needs the documented bump.
434
+ bumpMemoryCorpusVersionForDir(memoryDir);
435
+ const graph = await read(memoryDir);
436
+ const returnedAtMs = Date.now();
437
+ const served = graph.nodes.some((candidate) => candidate.nodeId === "n0");
438
+ // A build that finished before the deadline may legitimately include n0 (a 25 ms margin covers the
439
+ // hand-off); one that returned after it must not.
440
+ if (returnedAtMs >= deadlineMs + 25) {
441
+ assert.equal(served, false, `expired source served; build took about ${buildMs} ms`);
442
+ assert.equal(graph.nodes.length, 119);
443
+ }
299
444
  });
300
445
  });
301
446
 
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { listJsonFilesStrict, readJsonFile } from "./json-store.js";
4
4
  import { raceAbort, throwIfAborted } from "./abort-error.js";
5
+ import { log } from "./logger.js";
5
6
  import { readHarmonicGraphStamp } from "./harmonic-graph-version.js";
6
7
  import { inferMemoryStatus, toMemoryPathRel } from "./memory-lifecycle-ledger-utils.js";
7
8
  import {
@@ -170,6 +171,20 @@ function parseSourceMemoryInsertedAt(node: AbstractionNode): Map<string, string>
170
171
  return insertedAtById;
171
172
  }
172
173
 
174
+ /**
175
+ * Whether a source memory can back a projected node at all. Shared with `earliestInvalidAtMs`, so the cache
176
+ * deadline only follows a source that the projection would use.
177
+ * #2332: a private support-passport record leaves the eligible set the same way a rejected one does — a node
178
+ * title or anchor attribution rebuilt from it would leak governed metadata into the deep-recall frontier (and
179
+ * every other graph reader) before any content check runs.
180
+ */
181
+ function isEligibleSourceMemory(memory: MemoryFile, memoryDir: string): boolean {
182
+ return (
183
+ !isSupportPassportPrivateMemory(memory) &&
184
+ inferMemoryStatus(memory.frontmatter, toMemoryPathRel(memoryDir, memory.path)) === "active"
185
+ );
186
+ }
187
+
173
188
  function projectSourceBackedNode(
174
189
  node: AbstractionNode,
175
190
  sourceMemories: SourceMemoryMap,
@@ -184,12 +199,7 @@ function projectSourceBackedNode(
184
199
  const memory = sourceMemories.get(memoryId);
185
200
  return (
186
201
  memory !== undefined &&
187
- // #2332: a private support-passport record leaves the eligible set the
188
- // same way a rejected one does — a node title or anchor attribution
189
- // rebuilt from it would leak governed metadata into the deep-recall
190
- // frontier (and every other graph reader) before any content check runs.
191
- !isSupportPassportPrivateMemory(memory) &&
192
- inferMemoryStatus(memory.frontmatter, toMemoryPathRel(memoryDir, memory.path)) === "active" &&
202
+ isEligibleSourceMemory(memory, memoryDir) &&
193
203
  (temporalExpiredInInjection || !isValidityExpiredNow(memory.frontmatter, nowMs))
194
204
  );
195
205
  });
@@ -404,6 +414,22 @@ export interface ProjectedHarmonicGraph {
404
414
  * one of that node's surviving active sources, and is dropped once no
405
415
  * reference survives.
406
416
  */
417
+ interface ExpiringHarmonicGraph extends ProjectedHarmonicGraph {
418
+ /** First instant a source memory's `invalid_at` passes: from then on the graph is wrong without any write. */
419
+ validUntilMs: number;
420
+ }
421
+
422
+ function earliestInvalidAtMs(sourceMemories: SourceMemoryMap, memoryDir: string, nowMs: number): number {
423
+ let earliest = Number.POSITIVE_INFINITY;
424
+ for (const memory of sourceMemories.values()) {
425
+ // An ineligible source drops out of the projection whatever its `invalid_at`, so its deadline changes nothing.
426
+ if (!isEligibleSourceMemory(memory, memoryDir)) continue;
427
+ const invalidAtMs = Date.parse(memory.frontmatter.invalid_at?.trim() ?? "");
428
+ if (Number.isFinite(invalidAtMs) && invalidAtMs > nowMs && invalidAtMs < earliest) earliest = invalidAtMs;
429
+ }
430
+ return earliest;
431
+ }
432
+
407
433
  async function buildProjectedHarmonicGraph(options: {
408
434
  memoryDir: string;
409
435
  abstractionNodeStoreDir?: string;
@@ -412,6 +438,18 @@ async function buildProjectedHarmonicGraph(options: {
412
438
  temporalExpiredInInjection?: boolean;
413
439
  nowMs?: number;
414
440
  }): Promise<ProjectedHarmonicGraph> {
441
+ const { nodes, anchors } = await buildExpiringHarmonicGraph(options);
442
+ return { nodes, anchors };
443
+ }
444
+
445
+ async function buildExpiringHarmonicGraph(options: {
446
+ memoryDir: string;
447
+ abstractionNodeStoreDir?: string;
448
+ anchorsEnabled: boolean;
449
+ abortSignal?: AbortSignal;
450
+ temporalExpiredInInjection?: boolean;
451
+ nowMs?: number;
452
+ }): Promise<ExpiringHarmonicGraph> {
415
453
  throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
416
454
  const nodes = await readAbstractionNodes(options);
417
455
  const sourceMemoryIds = [...new Set(nodes.flatMap((node) => node.sourceMemoryIds ?? []))];
@@ -424,6 +462,10 @@ async function buildProjectedHarmonicGraph(options: {
424
462
  })
425
463
  : new Map();
426
464
  const nowMs = options.nowMs ?? Date.now();
465
+ // With temporalExpiredInInjection the validity filter is off, so no `invalid_at` can age the graph.
466
+ const validUntilMs = options.temporalExpiredInInjection === true
467
+ ? Number.POSITIVE_INFINITY
468
+ : earliestInvalidAtMs(sourceMemories, options.memoryDir, nowMs);
427
469
  // A node whose full stored source set survived projection keeps legacy
428
470
  // anchor attribution (an anchor written before per-node source ids existed).
429
471
  const legacyCompatibleNodeRefs = new Set<string>();
@@ -444,7 +486,7 @@ async function buildProjectedHarmonicGraph(options: {
444
486
  }
445
487
  return projected ? [projected] : [];
446
488
  });
447
- if (!options.anchorsEnabled) return { nodes: projectedNodes, anchors: [] };
489
+ if (!options.anchorsEnabled) return { nodes: projectedNodes, anchors: [], validUntilMs };
448
490
  throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
449
491
  const nodeIndex = new Map(projectedNodes.map((node) => [node.nodeId, node]));
450
492
  const anchors = (await readCueAnchors(options)).flatMap((anchor) => {
@@ -458,20 +500,25 @@ async function buildProjectedHarmonicGraph(options: {
458
500
  });
459
501
  return nodeRefs.length > 0 ? [{ ...anchor, nodeRefs }] : [];
460
502
  });
461
- return { nodes: projectedNodes, anchors };
503
+ return { nodes: projectedNodes, anchors, validUntilMs };
462
504
  }
463
505
 
464
506
  const GRAPH_CACHE_TTL_MS = 30_000;
507
+ /**
508
+ * Past the TTL a graph whose stamp is unchanged is still served while a refresh runs behind it, so no
509
+ * recall waits for a rebuild just because the TTL lapsed. This is how long past the TTL; after it the graph is rebuilt first.
510
+ */
511
+ const GRAPH_CACHE_MAX_STALE_MS = 300_000;
465
512
  const GRAPH_CACHE_MAX_ENTRIES = 8;
466
513
 
467
514
  interface GraphCacheEntry {
468
515
  stamp: string;
469
- startedAtMs: number;
516
+ /** When the build finished, so the TTL measures how long the graph has been available. */
517
+ builtAtMs: number;
518
+ validUntilMs: number;
470
519
  graph: ProjectedHarmonicGraph;
471
520
  }
472
521
 
473
- const graphCache = new Map<string, GraphCacheEntry>();
474
-
475
522
  interface GraphBuild {
476
523
  stamp: string;
477
524
  promise: Promise<ProjectedHarmonicGraph>;
@@ -481,10 +528,41 @@ interface GraphBuild {
481
528
  waiting: number;
482
529
  }
483
530
 
531
+ const graphCache = new Map<string, GraphCacheEntry>();
484
532
  /** One build per cache key at a time: every recall that lands on a miss waits for the same build. */
485
533
  const graphBuilds = new Map<string, GraphBuild>();
486
- /** Counters for tests and diagnostics. `shared` counts reads that joined a build already running. */
487
- export const harmonicGraphCacheStats = { hits: 0, misses: 0, shared: 0 };
534
+ /** When a background refresh last failed, per key: stale reads retry at most once per TTL. Capped like the cache. */
535
+ const refreshFailedAtMs = new Map<string, number>();
536
+
537
+ function rememberRefreshFailure(key: string): void {
538
+ refreshFailedAtMs.delete(key);
539
+ refreshFailedAtMs.set(key, Date.now());
540
+ while (refreshFailedAtMs.size > GRAPH_CACHE_MAX_ENTRIES) {
541
+ const oldest = refreshFailedAtMs.keys().next().value;
542
+ if (oldest === undefined) break;
543
+ refreshFailedAtMs.delete(oldest);
544
+ }
545
+ }
546
+
547
+ /**
548
+ * A source's `invalid_at` can pass while the build is still reading anchors, which would hand every caller
549
+ * that joined this build a graph that is already expired. Rebuild until the graph is unexpired at completion.
550
+ * Each round drops the sources that expired, so the set of future deadlines shrinks and the loop ends.
551
+ */
552
+ async function buildUnexpiredHarmonicGraph(options: GraphReadOptions, abortSignal: AbortSignal): Promise<ExpiringHarmonicGraph> {
553
+ for (;;) {
554
+ const built = await buildExpiringHarmonicGraph({
555
+ memoryDir: options.memoryDir,
556
+ abstractionNodeStoreDir: options.abstractionNodeStoreDir,
557
+ anchorsEnabled: options.anchorsEnabled,
558
+ temporalExpiredInInjection: options.temporalExpiredInInjection,
559
+ abortSignal,
560
+ });
561
+ if (Date.now() < built.validUntilMs) return built;
562
+ }
563
+ }
564
+ /** Counters for tests and diagnostics. `stale` hits also count as `hits`. */
565
+ export const harmonicGraphCacheStats = { hits: 0, misses: 0, stale: 0, shared: 0 };
488
566
 
489
567
  /** Freeze records and their nested arrays/objects so a caller cannot corrupt the shared cache. */
490
568
  function deepFreeze<T extends object>(value: T): T {
@@ -498,6 +576,16 @@ function deepFreeze<T extends object>(value: T): T {
498
576
  export function clearHarmonicGraphCache(): void {
499
577
  graphCache.clear();
500
578
  graphBuilds.clear();
579
+ refreshFailedAtMs.clear();
580
+ }
581
+
582
+ /**
583
+ * Resolves once every build running now has settled, and after the failure bookkeeping of a background
584
+ * refresh (that handler was attached when the refresh started, so it runs first). For tests: a time-based
585
+ * wait races a slow build.
586
+ */
587
+ export async function settleHarmonicGraphBuilds(): Promise<void> {
588
+ await Promise.allSettled([...graphBuilds.values()].map((build) => build.promise));
501
589
  }
502
590
 
503
591
  type GraphReadOptions = Parameters<typeof buildProjectedHarmonicGraph>[0];
@@ -508,21 +596,15 @@ type GraphReadOptions = Parameters<typeof buildProjectedHarmonicGraph>[0];
508
596
  */
509
597
  function startGraphBuild(key: string, stamp: string, options: GraphReadOptions): GraphBuild {
510
598
  harmonicGraphCacheStats.misses++;
511
- const startedAtMs = Date.now();
512
599
  const controller = new AbortController();
513
- const promise: Promise<ProjectedHarmonicGraph> = buildProjectedHarmonicGraph({
514
- memoryDir: options.memoryDir,
515
- abstractionNodeStoreDir: options.abstractionNodeStoreDir,
516
- anchorsEnabled: options.anchorsEnabled,
517
- temporalExpiredInInjection: options.temporalExpiredInInjection,
518
- abortSignal: controller.signal,
519
- })
600
+ const promise: Promise<ProjectedHarmonicGraph> = buildUnexpiredHarmonicGraph(options, controller.signal)
520
601
  .then((built) => {
521
602
  const graph = { nodes: deepFreeze(built.nodes), anchors: deepFreeze(built.anchors) };
522
603
  // A cleared cache or a newer build for this key has replaced this one: do not store a stale graph.
523
604
  if (graphBuilds.get(key)?.promise !== promise) return graph;
605
+ refreshFailedAtMs.delete(key);
524
606
  graphCache.delete(key);
525
- graphCache.set(key, { stamp, startedAtMs, graph });
607
+ graphCache.set(key, { stamp, builtAtMs: Date.now(), validUntilMs: built.validUntilMs, graph });
526
608
  while (graphCache.size > GRAPH_CACHE_MAX_ENTRIES) {
527
609
  const oldest = graphCache.keys().next().value;
528
610
  if (oldest === undefined) break;
@@ -561,9 +643,12 @@ function joinGraphBuild(key: string, build: GraphBuild, signal: AbortSignal | un
561
643
  * cold-write version sentinels (bumped by every memory write, in any process) and
562
644
  * the graph version sentinel (bumped by node and anchor writes). A stamp read is
563
645
  * four stats, not a scan of the memories. The TTL bounds the one input a sentinel
564
- * cannot see, which is wall-clock validity expiry. Concurrent misses share one
565
- * build. An explicit `nowMs` bypasses the cache so tests stay deterministic. The
566
- * cached arrays are frozen: callers must not mutate them.
646
+ * cannot see, which is wall-clock validity expiry; past it the stale graph is served
647
+ * while one refresh runs (up to GRAPH_CACHE_MAX_STALE_MS). A changed stamp always
648
+ * waits for a fresh build, because a stale graph could carry governed metadata of a
649
+ * memory that left the active set. Concurrent misses share one build. An explicit
650
+ * `nowMs` bypasses the cache so tests stay deterministic. The cached arrays are
651
+ * frozen: callers must not mutate them.
567
652
  */
568
653
  export async function readProjectedHarmonicGraph(options: {
569
654
  memoryDir: string;
@@ -587,12 +672,25 @@ export async function readProjectedHarmonicGraph(options: {
587
672
  // The stamp is read BEFORE the build, so a write that lands mid-build leaves the
588
673
  // entry stale and the next call rebuilds.
589
674
  const stamp = readHarmonicGraphStamp(options.memoryDir, nodeStoreDir);
590
- const startedAtMs = Date.now();
675
+ const nowMs = Date.now();
591
676
  const hit = graphCache.get(key);
592
- if (hit && hit.stamp === stamp && startedAtMs - hit.startedAtMs < GRAPH_CACHE_TTL_MS) {
677
+ // A source's `invalid_at` passing changes the projection without bumping the stamp, so it ends the entry
678
+ // at the exact instant, however fresh the entry is.
679
+ if (hit && hit.stamp === stamp && nowMs < hit.validUntilMs && nowMs - hit.builtAtMs < GRAPH_CACHE_TTL_MS + GRAPH_CACHE_MAX_STALE_MS) {
593
680
  harmonicGraphCacheStats.hits++;
594
681
  graphCache.delete(key);
595
682
  graphCache.set(key, hit);
683
+ if (nowMs - hit.builtAtMs >= GRAPH_CACHE_TTL_MS) {
684
+ harmonicGraphCacheStats.stale++;
685
+ const failedAtMs = refreshFailedAtMs.get(key);
686
+ const backingOff = failedAtMs !== undefined && nowMs - failedAtMs < GRAPH_CACHE_TTL_MS;
687
+ if (graphBuilds.get(key)?.stamp !== stamp && !backingOff) {
688
+ startGraphBuild(key, stamp, options).promise.catch((err) => {
689
+ rememberRefreshFailure(key);
690
+ log.debug(`harmonic graph refresh failed: ${err}`);
691
+ });
692
+ }
693
+ }
596
694
  return hit.graph;
597
695
  }
598
696
  const running = graphBuilds.get(key);