recallnest 2.3.0 → 2.5.4

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 (221) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +17 -31
  3. package/README_CN.md +12 -22
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +22 -1
  15. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/auto-consolidation.test.ts +168 -0
  21. package/src/__tests__/auto-recall.test.ts +131 -0
  22. package/src/__tests__/babel-integration.test.ts +144 -0
  23. package/src/__tests__/batch-dedup.test.ts +65 -0
  24. package/src/__tests__/canary-eval.test.ts +114 -0
  25. package/src/__tests__/capture-engine.test.ts +1238 -0
  26. package/src/__tests__/capture-heuristic.test.ts +171 -0
  27. package/src/__tests__/cascade-forget.test.ts +210 -0
  28. package/src/__tests__/category-min-scores.test.ts +33 -0
  29. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  30. package/src/__tests__/circuit-breaker.test.ts +99 -0
  31. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  32. package/src/__tests__/confidence-metadata.test.ts +125 -0
  33. package/src/__tests__/confidence-tracker.test.ts +261 -0
  34. package/src/__tests__/conflict-advisor.test.ts +206 -0
  35. package/src/__tests__/conflict-escalation.test.ts +149 -0
  36. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  37. package/src/__tests__/conflict-output.test.ts +85 -0
  38. package/src/__tests__/conflict-store.test.ts +374 -0
  39. package/src/__tests__/connector-v1.test.ts +181 -0
  40. package/src/__tests__/consolidation-engine.test.ts +333 -0
  41. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  42. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  43. package/src/__tests__/context-composer-pins.test.ts +64 -0
  44. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  45. package/src/__tests__/context-reconstructor.test.ts +65 -0
  46. package/src/__tests__/context-renderer.test.ts +91 -0
  47. package/src/__tests__/continuity-eval.test.ts +275 -0
  48. package/src/__tests__/continuity-schema.test.ts +97 -0
  49. package/src/__tests__/contracts.test.ts +216 -0
  50. package/src/__tests__/conversation-importer.test.ts +692 -0
  51. package/src/__tests__/core-recall-eval.test.ts +235 -0
  52. package/src/__tests__/core-summary.test.ts +109 -0
  53. package/src/__tests__/data-checkup.test.ts +276 -0
  54. package/src/__tests__/decay-exemption.test.ts +84 -0
  55. package/src/__tests__/dedup-actions.test.ts +58 -0
  56. package/src/__tests__/dedup-guard.test.ts +39 -0
  57. package/src/__tests__/deterministic-id.test.ts +39 -0
  58. package/src/__tests__/distill-lock.test.ts +305 -0
  59. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  60. package/src/__tests__/doctor.test.ts +319 -0
  61. package/src/__tests__/dream-pipeline.test.ts +321 -0
  62. package/src/__tests__/drill-down.test.ts +108 -0
  63. package/src/__tests__/embedder.test.ts +122 -0
  64. package/src/__tests__/emotion-decay.test.ts +46 -0
  65. package/src/__tests__/emotion-detector.test.ts +56 -0
  66. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  67. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  68. package/src/__tests__/entity-resolver.test.ts +58 -0
  69. package/src/__tests__/env-config.test.ts +151 -0
  70. package/src/__tests__/error-signature-boost.test.ts +87 -0
  71. package/src/__tests__/error-signature.test.ts +95 -0
  72. package/src/__tests__/error-taxonomy.test.ts +129 -0
  73. package/src/__tests__/ethics-layer.test.ts +476 -0
  74. package/src/__tests__/event-segmenter.test.ts +153 -0
  75. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  76. package/src/__tests__/frequency-tracker.test.ts +113 -0
  77. package/src/__tests__/graph-export.test.ts +489 -0
  78. package/src/__tests__/hotness-blend.test.ts +76 -0
  79. package/src/__tests__/ingest-chunking.test.ts +44 -0
  80. package/src/__tests__/ingest.test.ts +360 -0
  81. package/src/__tests__/interference-detector.test.ts +203 -0
  82. package/src/__tests__/kg-extractor.test.ts +192 -0
  83. package/src/__tests__/kg-store.test.ts +300 -0
  84. package/src/__tests__/llm-consolidation.test.ts +226 -0
  85. package/src/__tests__/maintenance-locks.test.ts +174 -0
  86. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  87. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  88. package/src/__tests__/memory-boundaries.test.ts +250 -0
  89. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  90. package/src/__tests__/memory-evolution.test.ts +271 -0
  91. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  92. package/src/__tests__/memory-lint.test.ts +421 -0
  93. package/src/__tests__/memory-output.test.ts +215 -0
  94. package/src/__tests__/memory-promotion.test.ts +315 -0
  95. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  96. package/src/__tests__/multi-vector.test.ts +242 -0
  97. package/src/__tests__/narrative-architecture.test.ts +456 -0
  98. package/src/__tests__/noise-filter.test.ts +205 -0
  99. package/src/__tests__/obsidian-connector.test.ts +363 -0
  100. package/src/__tests__/omitted-reporting.test.ts +98 -0
  101. package/src/__tests__/pending-review.test.ts +77 -0
  102. package/src/__tests__/pii-redact.test.ts +314 -0
  103. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  104. package/src/__tests__/ppr-traversal.test.ts +256 -0
  105. package/src/__tests__/prediction-engine.test.ts +349 -0
  106. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  107. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  108. package/src/__tests__/preference-matcher.test.ts +230 -0
  109. package/src/__tests__/preference-slots.test.ts +205 -0
  110. package/src/__tests__/prospective-memory.test.ts +283 -0
  111. package/src/__tests__/query-entity-detector.test.ts +171 -0
  112. package/src/__tests__/query-expander-alias.test.ts +146 -0
  113. package/src/__tests__/query-expander.test.ts +34 -0
  114. package/src/__tests__/recall-governor.test.ts +227 -0
  115. package/src/__tests__/recall-mode.test.ts +64 -0
  116. package/src/__tests__/result-synthesizer.test.ts +198 -0
  117. package/src/__tests__/resume-context.test.ts +4898 -0
  118. package/src/__tests__/retrieval-trace.test.ts +213 -0
  119. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  120. package/src/__tests__/retroactive-boost.test.ts +223 -0
  121. package/src/__tests__/salience-scoring.test.ts +106 -0
  122. package/src/__tests__/scope-inventory.test.ts +200 -0
  123. package/src/__tests__/scope-policy.test.ts +69 -0
  124. package/src/__tests__/scope-suggester.test.ts +80 -0
  125. package/src/__tests__/session-checkpoint.test.ts +291 -0
  126. package/src/__tests__/session-distiller.test.ts +640 -0
  127. package/src/__tests__/session-gc.test.ts +215 -0
  128. package/src/__tests__/skill-engine.test.ts +552 -0
  129. package/src/__tests__/skill-promotion.test.ts +601 -0
  130. package/src/__tests__/skill-verifier.test.ts +122 -0
  131. package/src/__tests__/source-heartbeat.test.ts +250 -0
  132. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  133. package/src/__tests__/store-scope-audit.test.ts +81 -0
  134. package/src/__tests__/store-upsert.test.ts +184 -0
  135. package/src/__tests__/supersede-chain.test.ts +154 -0
  136. package/src/__tests__/temporal-parser.test.ts +241 -0
  137. package/src/__tests__/temporal-validity.test.ts +160 -0
  138. package/src/__tests__/term-registry.test.ts +121 -0
  139. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  140. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  141. package/src/__tests__/topic-tag.test.ts +113 -0
  142. package/src/__tests__/trusted-memory.test.ts +839 -0
  143. package/src/__tests__/usage-tracker.test.ts +287 -0
  144. package/src/__tests__/version-manager.test.ts +213 -0
  145. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  146. package/src/__tests__/workflow-observation.test.ts +433 -0
  147. package/src/__tests__/write-verifier.test.ts +122 -0
  148. package/src/access-tracker.ts +38 -32
  149. package/src/activity-counter.ts +46 -18
  150. package/src/aliases.ts +133 -0
  151. package/src/api-server.ts +27 -7
  152. package/src/audit-log.ts +4 -2
  153. package/src/auto-consolidation.ts +8 -3
  154. package/src/auto-gc.ts +85 -21
  155. package/src/capture-engine.ts +54 -21
  156. package/src/cli.ts +169 -37
  157. package/src/confidence-tracker.ts +36 -18
  158. package/src/consolidation-engine.ts +109 -11
  159. package/src/context-composer.ts +8 -1
  160. package/src/data-checkup.ts +95 -6
  161. package/src/distill-lock.ts +194 -15
  162. package/src/doctor.ts +9 -2
  163. package/src/dream-pipeline.ts +118 -34
  164. package/src/env-config.ts +77 -0
  165. package/src/error-signature.ts +153 -0
  166. package/src/error-taxonomy.ts +115 -0
  167. package/src/eval.ts +251 -6
  168. package/src/idempotency.ts +8 -0
  169. package/src/ingest.ts +31 -10
  170. package/src/kg-extractor.ts +6 -2
  171. package/src/kg-store.ts +236 -44
  172. package/src/llm-consolidation.ts +2 -1
  173. package/src/mcp-server.ts +85 -1799
  174. package/src/mcp-tool-deps.ts +48 -0
  175. package/src/mcp-tools-advanced.ts +941 -0
  176. package/src/mcp-tools-core.ts +633 -0
  177. package/src/mcp-tools-governance.ts +475 -0
  178. package/src/memory-boundaries.ts +3 -1
  179. package/src/memory-evolution.ts +73 -1
  180. package/src/memory-lint.ts +183 -10
  181. package/src/memory-output.ts +114 -3
  182. package/src/memory-promotion.ts +246 -0
  183. package/src/memory-schema.ts +25 -2
  184. package/src/memory-store-port.ts +43 -0
  185. package/src/multi-vector.ts +2 -1
  186. package/src/narrative-schema.ts +2 -1
  187. package/src/noise-filter.ts +57 -6
  188. package/src/pii-detector.ts +157 -5
  189. package/src/query-expander.ts +90 -9
  190. package/src/result-synthesizer.ts +2 -1
  191. package/src/retention-policy.ts +2 -1
  192. package/src/retriever.ts +259 -139
  193. package/src/runtime-config.ts +8 -1
  194. package/src/scope-suggester.ts +113 -0
  195. package/src/session-distiller.ts +23 -2
  196. package/src/session-engine.ts +2 -1
  197. package/src/session-schema.ts +1 -0
  198. package/src/session-store.ts +5 -0
  199. package/src/skill-engine.ts +78 -0
  200. package/src/skill-promotion.ts +247 -47
  201. package/src/skill-schema.ts +16 -3
  202. package/src/skill-verifier.ts +152 -0
  203. package/src/store.ts +267 -38
  204. package/src/tool-output-compressor.ts +3 -1
  205. package/src/ui-server.ts +2 -1
  206. package/src/usage-tracker.ts +242 -0
  207. package/src/version-manager.ts +3 -2
  208. package/src/workflow-observation-engine.ts +2 -1
  209. package/src/workflow-observation-schema.ts +2 -1
  210. package/src/workflow-observation-store.ts +6 -2
  211. package/.env +0 -4
  212. package/CLAUDE.md +0 -46
  213. package/config.json +0 -55
  214. package/daily-recall.sh +0 -110
  215. package/logs/daily-recall.error.log +0 -0
  216. package/logs/daily-recall.log +0 -60
  217. package/logs/ingest-2026-04-11.log +0 -110
  218. package/logs/ingest-2026-04-12.log +0 -90
  219. package/logs/ingest-2026-04-13.log +0 -87
  220. package/logs/launchagent-stderr.log +0 -0
  221. package/logs/launchagent-stdout.log +0 -0
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ buildGraph,
4
+ pprTraverse,
5
+ pprFromTriples,
6
+ edgeWeight,
7
+ DEFAULT_PPR_CONFIG,
8
+ } from "../ppr-traversal.js";
9
+ import type { KGTriple, NeighborhoodResult } from "../kg-store.js";
10
+
11
+ // ============================================================================
12
+ // Helpers
13
+ // ============================================================================
14
+
15
+ function makeTriple(
16
+ subject: string,
17
+ predicate: string,
18
+ object: string,
19
+ confidence = 0.9,
20
+ id?: string,
21
+ ): KGTriple {
22
+ return {
23
+ id: id ?? `${subject}-${predicate}-${object}`,
24
+ scope: "test",
25
+ subject,
26
+ predicate,
27
+ object,
28
+ confidence,
29
+ source_memory_id: `mem-${subject}-${object}`,
30
+ source_text: `${subject} ${predicate} ${object}`,
31
+ timestamp: Date.now(),
32
+ };
33
+ }
34
+
35
+ function makeNeighborhood(triples: KGTriple[]): NeighborhoodResult[] {
36
+ const entities = new Set<string>();
37
+ for (const t of triples) {
38
+ entities.add(t.subject);
39
+ entities.add(t.object);
40
+ }
41
+ return [...entities].map(entity => ({
42
+ entity,
43
+ triples: triples.filter(t => t.subject === entity || t.object === entity),
44
+ hops: 0,
45
+ }));
46
+ }
47
+
48
+ // ============================================================================
49
+ // edgeWeight
50
+ // ============================================================================
51
+
52
+ describe("edgeWeight", () => {
53
+ it("returns known weights for standard predicates", () => {
54
+ expect(edgeWeight("created_by")).toBe(0.95);
55
+ expect(edgeWeight("uses")).toBe(0.90);
56
+ expect(edgeWeight("related_to")).toBe(0.50);
57
+ });
58
+
59
+ it("returns default weight for unknown predicates", () => {
60
+ expect(edgeWeight("unknown_predicate")).toBe(0.60);
61
+ });
62
+ });
63
+
64
+ // ============================================================================
65
+ // buildGraph
66
+ // ============================================================================
67
+
68
+ describe("buildGraph", () => {
69
+ it("builds undirected graph from neighborhood", () => {
70
+ const triples = [
71
+ makeTriple("Alice", "uses", "Python"),
72
+ makeTriple("Alice", "works_with", "Bob"),
73
+ ];
74
+ const neighborhood = makeNeighborhood(triples);
75
+ const graph = buildGraph(neighborhood);
76
+
77
+ expect(graph.nodes.size).toBe(3); // Alice, Python, Bob
78
+ expect(graph.adj.get("Alice")?.length).toBe(2); // -> Python, -> Bob
79
+ expect(graph.adj.get("Python")?.length).toBe(1); // -> Alice
80
+ expect(graph.adj.get("Bob")?.length).toBe(1); // -> Alice
81
+ });
82
+
83
+ it("deduplicates triples by id", () => {
84
+ const t = makeTriple("Alice", "uses", "Python", 0.9, "same-id");
85
+ const neighborhood: NeighborhoodResult[] = [
86
+ { entity: "Alice", triples: [t], hops: 0 },
87
+ { entity: "Python", triples: [t], hops: 1 },
88
+ ];
89
+ const graph = buildGraph(neighborhood);
90
+
91
+ // Should only have 1 edge per direction despite appearing in 2 neighborhood entries
92
+ expect(graph.adj.get("Alice")?.length).toBe(1);
93
+ expect(graph.adj.get("Python")?.length).toBe(1);
94
+ });
95
+
96
+ it("handles empty neighborhood", () => {
97
+ const graph = buildGraph([]);
98
+ expect(graph.nodes.size).toBe(0);
99
+ expect(graph.adj.size).toBe(0);
100
+ });
101
+ });
102
+
103
+ // ============================================================================
104
+ // pprTraverse — convergence
105
+ // ============================================================================
106
+
107
+ describe("pprTraverse", () => {
108
+ it("converges on a simple chain A -> B -> C", () => {
109
+ const triples = [
110
+ makeTriple("A", "uses", "B"),
111
+ makeTriple("B", "uses", "C"),
112
+ ];
113
+ const graph = buildGraph(makeNeighborhood(triples));
114
+ const results = pprTraverse(graph, ["A"]);
115
+
116
+ // A should have highest score (it's the seed)
117
+ expect(results.length).toBeGreaterThan(0);
118
+ expect(results[0].entity).toBe("A");
119
+
120
+ // B should score higher than C (closer to seed)
121
+ const bResult = results.find(r => r.entity === "B");
122
+ const cResult = results.find(r => r.entity === "C");
123
+ expect(bResult).toBeDefined();
124
+ expect(cResult).toBeDefined();
125
+ expect(bResult!.score).toBeGreaterThan(cResult!.score);
126
+ });
127
+
128
+ it("respects hop limits", () => {
129
+ const triples = [
130
+ makeTriple("A", "uses", "B"),
131
+ makeTriple("B", "uses", "C"),
132
+ makeTriple("C", "uses", "D"),
133
+ ];
134
+ const graph = buildGraph(makeNeighborhood(triples));
135
+ const results = pprTraverse(graph, ["A"], { hopLimit: 1 });
136
+
137
+ // With hopLimit=1, only A and B should be reachable
138
+ const entities = results.map(r => r.entity);
139
+ expect(entities).toContain("A");
140
+ expect(entities).toContain("B");
141
+ expect(entities).not.toContain("C");
142
+ expect(entities).not.toContain("D");
143
+ });
144
+
145
+ it("handles multiple seed entities", () => {
146
+ const triples = [
147
+ makeTriple("A", "uses", "C"),
148
+ makeTriple("B", "uses", "C"),
149
+ ];
150
+ const graph = buildGraph(makeNeighborhood(triples));
151
+ const results = pprTraverse(graph, ["A", "B"]);
152
+
153
+ // C should be reachable from both seeds
154
+ const cResult = results.find(r => r.entity === "C");
155
+ expect(cResult).toBeDefined();
156
+ expect(cResult!.hops).toBe(1);
157
+ });
158
+
159
+ it("returns empty for empty graph", () => {
160
+ const graph = buildGraph([]);
161
+ const results = pprTraverse(graph, ["A"]);
162
+ expect(results).toEqual([]);
163
+ });
164
+
165
+ it("returns empty when seeds not in graph", () => {
166
+ const triples = [makeTriple("X", "uses", "Y")];
167
+ const graph = buildGraph(makeNeighborhood(triples));
168
+ const results = pprTraverse(graph, ["NotInGraph"]);
169
+ expect(results).toEqual([]);
170
+ });
171
+
172
+ it("respects topK limit", () => {
173
+ const triples = [
174
+ makeTriple("A", "uses", "B"),
175
+ makeTriple("A", "uses", "C"),
176
+ makeTriple("A", "uses", "D"),
177
+ makeTriple("A", "uses", "E"),
178
+ ];
179
+ const graph = buildGraph(makeNeighborhood(triples));
180
+ const results = pprTraverse(graph, ["A"], { topK: 2 });
181
+ expect(results.length).toBe(2);
182
+ });
183
+
184
+ it("computes shortest paths correctly", () => {
185
+ const triples = [
186
+ makeTriple("A", "uses", "B"),
187
+ makeTriple("B", "uses", "C"),
188
+ ];
189
+ const graph = buildGraph(makeNeighborhood(triples));
190
+ const results = pprTraverse(graph, ["A"]);
191
+
192
+ const aResult = results.find(r => r.entity === "A");
193
+ const bResult = results.find(r => r.entity === "B");
194
+ const cResult = results.find(r => r.entity === "C");
195
+
196
+ expect(aResult?.hops).toBe(0);
197
+ expect(aResult?.path).toEqual(["A"]);
198
+ expect(bResult?.hops).toBe(1);
199
+ expect(bResult?.path).toEqual(["A", "B"]);
200
+ expect(cResult?.hops).toBe(2);
201
+ expect(cResult?.path).toEqual(["A", "B", "C"]);
202
+ });
203
+
204
+ it("edge weights affect scores — stronger predicates yield higher scores", () => {
205
+ // Two separate subgraphs from same seed
206
+ const triples = [
207
+ makeTriple("A", "created_by", "B", 1.0), // weight 0.95
208
+ makeTriple("A", "related_to", "C", 1.0), // weight 0.50
209
+ ];
210
+ const graph = buildGraph(makeNeighborhood(triples));
211
+ const results = pprTraverse(graph, ["A"]);
212
+
213
+ const bResult = results.find(r => r.entity === "B");
214
+ const cResult = results.find(r => r.entity === "C");
215
+ expect(bResult).toBeDefined();
216
+ expect(cResult).toBeDefined();
217
+ // B should score higher due to stronger edge weight
218
+ expect(bResult!.score).toBeGreaterThan(cResult!.score);
219
+ });
220
+
221
+ it("all scores sum to approximately 1.0 (probability distribution)", () => {
222
+ const triples = [
223
+ makeTriple("A", "uses", "B"),
224
+ makeTriple("B", "uses", "C"),
225
+ makeTriple("C", "uses", "A"), // cycle
226
+ ];
227
+ const graph = buildGraph(makeNeighborhood(triples));
228
+ const results = pprTraverse(graph, ["A"], { maxIterations: 50 });
229
+
230
+ const totalScore = results.reduce((sum, r) => sum + r.score, 0);
231
+ // PPR scores approximate a probability distribution
232
+ expect(totalScore).toBeGreaterThan(0.8);
233
+ expect(totalScore).toBeLessThan(1.2);
234
+ });
235
+ });
236
+
237
+ // ============================================================================
238
+ // pprFromTriples (convenience)
239
+ // ============================================================================
240
+
241
+ describe("pprFromTriples", () => {
242
+ it("works with raw triples", () => {
243
+ const triples = [
244
+ makeTriple("Alice", "uses", "Python"),
245
+ makeTriple("Alice", "works_at", "Google"),
246
+ ];
247
+ const results = pprFromTriples(triples, ["Alice"]);
248
+
249
+ expect(results.length).toBeGreaterThan(0);
250
+ expect(results[0].entity).toBe("Alice");
251
+
252
+ const entities = results.map(r => r.entity);
253
+ expect(entities).toContain("Python");
254
+ expect(entities).toContain("Google");
255
+ });
256
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Tests for HP-predictive Phase 5: Prediction Engine
3
+ *
4
+ * Validates:
5
+ * 1. collectSignals() — each signal type is correctly extracted
6
+ * 2. scorePredictions() — scoring, filtering by threshold, ranking
7
+ * 3. Edge cases — empty inputs, boundary conditions
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ collectSignals,
12
+ scorePredictions,
13
+ _testConstants,
14
+ type PredictionContext,
15
+ type PredictionSignal,
16
+ } from "../prediction-engine.js";
17
+ import type { SessionCheckpointRecord } from "../session-schema.js";
18
+ import type { WorkflowObservationRecord } from "../workflow-observation-schema.js";
19
+
20
+ const { CONFIDENCE_THRESHOLD, STALE_OPEN_LOOP_HOURS, STALE_MEMORY_DAYS, MAX_PREDICTIONS, SIGNAL_WEIGHTS } = _testConstants;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function makeCheckpoint(overrides: Partial<SessionCheckpointRecord> & { openLoops?: string[]; updatedAt: string }): SessionCheckpointRecord {
27
+ return {
28
+ checkpointId: `cp-${Math.random().toString(36).slice(2, 8)}`,
29
+ sessionId: "test-session",
30
+ resolvedScope: "test",
31
+ scope: "test",
32
+ summary: "test checkpoint",
33
+ task: "test task",
34
+ decisions: [],
35
+ openLoops: overrides.openLoops ?? [],
36
+ nextActions: [],
37
+ entities: [],
38
+ files: [],
39
+ updatedAt: overrides.updatedAt,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeWorkflowObservation(overrides: Partial<WorkflowObservationRecord>): WorkflowObservationRecord {
45
+ return {
46
+ observationId: `obs-${Math.random().toString(36).slice(2, 8)}`,
47
+ workflowId: "test-workflow",
48
+ outcome: "corrected",
49
+ summary: "Fixed a workflow issue",
50
+ scope: "test",
51
+ resolvedScope: "test",
52
+ source: "manual",
53
+ tags: [],
54
+ tools: [],
55
+ recordedAt: new Date().toISOString(),
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ function emptyContext(overrides?: Partial<PredictionContext>): PredictionContext {
61
+ return {
62
+ checkpoints: [],
63
+ workflowObservations: [],
64
+ frequentMemories: [],
65
+ uncoveredTopics: [],
66
+ now: new Date("2026-04-11T12:00:00Z"),
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // collectSignals
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe("collectSignals", () => {
76
+ describe("stale open loop signals", () => {
77
+ it("detects stale open loops from checkpoints older than 24h", () => {
78
+ const now = new Date("2026-04-11T12:00:00Z");
79
+ const staleCheckpoint = makeCheckpoint({
80
+ openLoops: ["Fix CI pipeline", "Review PR #42"],
81
+ updatedAt: new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString(), // 48h ago
82
+ });
83
+
84
+ const signals = collectSignals(emptyContext({
85
+ checkpoints: [staleCheckpoint],
86
+ now,
87
+ }));
88
+
89
+ const openLoopSignals = signals.filter(s => s.type === "stale_open_loop");
90
+ expect(openLoopSignals.length).toBe(2);
91
+ expect(openLoopSignals[0].trigger).toBe("Fix CI pipeline");
92
+ expect(openLoopSignals[0].weight).toBeGreaterThan(0.6);
93
+ expect(openLoopSignals[0].evidence.length).toBeGreaterThan(0);
94
+ });
95
+
96
+ it("ignores fresh open loops (< 24h)", () => {
97
+ const now = new Date("2026-04-11T12:00:00Z");
98
+ const freshCheckpoint = makeCheckpoint({
99
+ openLoops: ["In-progress task"],
100
+ updatedAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(), // 12h ago
101
+ });
102
+
103
+ const signals = collectSignals(emptyContext({
104
+ checkpoints: [freshCheckpoint],
105
+ now,
106
+ }));
107
+
108
+ expect(signals.filter(s => s.type === "stale_open_loop").length).toBe(0);
109
+ });
110
+
111
+ it("deduplicates same open loop across checkpoints", () => {
112
+ const now = new Date("2026-04-11T12:00:00Z");
113
+ const cp1 = makeCheckpoint({
114
+ openLoops: ["Fix CI pipeline"],
115
+ updatedAt: new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString(),
116
+ });
117
+ const cp2 = makeCheckpoint({
118
+ openLoops: ["fix ci pipeline"], // same but different case
119
+ updatedAt: new Date(now.getTime() - 72 * 60 * 60 * 1000).toISOString(),
120
+ });
121
+
122
+ const signals = collectSignals(emptyContext({
123
+ checkpoints: [cp1, cp2],
124
+ now,
125
+ }));
126
+
127
+ expect(signals.filter(s => s.type === "stale_open_loop").length).toBe(1);
128
+ });
129
+
130
+ it("weights increase with staleness", () => {
131
+ const now = new Date("2026-04-11T12:00:00Z");
132
+ const cp24h = makeCheckpoint({
133
+ openLoops: ["Task A"],
134
+ updatedAt: new Date(now.getTime() - 25 * 60 * 60 * 1000).toISOString(),
135
+ });
136
+ const cp72h = makeCheckpoint({
137
+ openLoops: ["Task B"],
138
+ updatedAt: new Date(now.getTime() - 72 * 60 * 60 * 1000).toISOString(),
139
+ });
140
+
141
+ const signals = collectSignals(emptyContext({
142
+ checkpoints: [cp24h, cp72h],
143
+ now,
144
+ }));
145
+
146
+ const signalA = signals.find(s => s.trigger === "Task A")!;
147
+ const signalB = signals.find(s => s.trigger === "Task B")!;
148
+ expect(signalB.weight).toBeGreaterThan(signalA.weight);
149
+ });
150
+ });
151
+
152
+ describe("stale high-frequency memory signals", () => {
153
+ it("detects memories accessed frequently but not recently", () => {
154
+ const now = new Date("2026-04-11T12:00:00Z");
155
+ const signals = collectSignals(emptyContext({
156
+ frequentMemories: [{
157
+ text: "Auth migration plan",
158
+ topicTag: "auth",
159
+ lastAccessedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
160
+ accessCount: 5,
161
+ }],
162
+ now,
163
+ }));
164
+
165
+ const staleSignals = signals.filter(s => s.type === "stale_high_frequency");
166
+ expect(staleSignals.length).toBe(1);
167
+ expect(staleSignals[0].trigger).toBe("auth");
168
+ expect(staleSignals[0].evidence).toContain("Accessed 5 times but not in 30 days");
169
+ });
170
+
171
+ it("ignores recently accessed memories", () => {
172
+ const now = new Date("2026-04-11T12:00:00Z");
173
+ const signals = collectSignals(emptyContext({
174
+ frequentMemories: [{
175
+ text: "Recent memory",
176
+ lastAccessedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days ago
177
+ accessCount: 10,
178
+ }],
179
+ now,
180
+ }));
181
+
182
+ expect(signals.filter(s => s.type === "stale_high_frequency").length).toBe(0);
183
+ });
184
+
185
+ it("ignores infrequently accessed memories (< 3 accesses)", () => {
186
+ const now = new Date("2026-04-11T12:00:00Z");
187
+ const signals = collectSignals(emptyContext({
188
+ frequentMemories: [{
189
+ text: "Rarely accessed",
190
+ lastAccessedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
191
+ accessCount: 2,
192
+ }],
193
+ now,
194
+ }));
195
+
196
+ expect(signals.filter(s => s.type === "stale_high_frequency").length).toBe(0);
197
+ });
198
+ });
199
+
200
+ describe("workflow issue signals", () => {
201
+ it("detects corrected/missed workflow observations", () => {
202
+ const observations = [
203
+ makeWorkflowObservation({ workflowId: "deploy", outcome: "corrected", summary: "Forgot to run tests" }),
204
+ makeWorkflowObservation({ workflowId: "deploy", outcome: "missed", summary: "Missed the changelog update" }),
205
+ makeWorkflowObservation({ workflowId: "deploy", outcome: "success", summary: "All good" }),
206
+ ];
207
+
208
+ const signals = collectSignals(emptyContext({ workflowObservations: observations }));
209
+ const issueSignals = signals.filter(s => s.type === "workflow_issue");
210
+ expect(issueSignals.length).toBe(1);
211
+ expect(issueSignals[0].trigger).toBe("deploy");
212
+ expect(issueSignals[0].evidence[0]).toContain("2 corrected/missed");
213
+ });
214
+
215
+ it("ignores workflows with only successes", () => {
216
+ const observations = [
217
+ makeWorkflowObservation({ workflowId: "deploy", outcome: "success" }),
218
+ ];
219
+
220
+ const signals = collectSignals(emptyContext({ workflowObservations: observations }));
221
+ expect(signals.filter(s => s.type === "workflow_issue").length).toBe(0);
222
+ });
223
+ });
224
+
225
+ describe("uncovered topic signals", () => {
226
+ it("creates signals for uncovered topics", () => {
227
+ const signals = collectSignals(emptyContext({
228
+ uncoveredTopics: ["kubernetes", "migration"],
229
+ }));
230
+
231
+ const topicSignals = signals.filter(s => s.type === "uncovered_topic");
232
+ expect(topicSignals.length).toBe(2);
233
+ expect(topicSignals[0].trigger).toBe("kubernetes");
234
+ expect(topicSignals[0].weight).toBe(0.65);
235
+ });
236
+ });
237
+
238
+ describe("empty inputs", () => {
239
+ it("returns empty array for empty context", () => {
240
+ const signals = collectSignals(emptyContext());
241
+ expect(signals).toEqual([]);
242
+ });
243
+ });
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // scorePredictions
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe("scorePredictions", () => {
251
+ it("scores signals and filters by confidence threshold", () => {
252
+ const signals: PredictionSignal[] = [
253
+ {
254
+ type: "stale_open_loop",
255
+ trigger: "Fix CI",
256
+ action: "Unresolved: Fix CI",
257
+ weight: 0.9,
258
+ evidence: ["48h old"],
259
+ },
260
+ {
261
+ type: "uncovered_topic",
262
+ trigger: "obscure",
263
+ action: "No memories for obscure",
264
+ weight: 0.5, // Below threshold after scoring
265
+ evidence: ["No coverage"],
266
+ },
267
+ ];
268
+
269
+ const predictions = scorePredictions(signals);
270
+
271
+ // stale_open_loop: 0.9 * 0.9 = 0.81 (above 0.6)
272
+ // uncovered_topic: 0.5 * 0.65 = 0.325 (below 0.6)
273
+ expect(predictions.length).toBe(1);
274
+ expect(predictions[0].trigger).toBe("Fix CI");
275
+ expect(predictions[0].confidence).toBe(0.81);
276
+ });
277
+
278
+ it("sorts by confidence descending", () => {
279
+ const signals: PredictionSignal[] = [
280
+ { type: "uncovered_topic", trigger: "low", action: "low", weight: 1.0, evidence: [] },
281
+ { type: "stale_open_loop", trigger: "high", action: "high", weight: 1.0, evidence: [] },
282
+ { type: "workflow_issue", trigger: "mid", action: "mid", weight: 1.0, evidence: [] },
283
+ ];
284
+
285
+ const predictions = scorePredictions(signals);
286
+ // stale_open_loop: 1.0 * 0.9 = 0.9
287
+ // workflow_issue: 1.0 * 0.85 = 0.85
288
+ // uncovered_topic: 1.0 * 0.65 = 0.65
289
+ expect(predictions.length).toBe(3);
290
+ expect(predictions[0].trigger).toBe("high");
291
+ expect(predictions[1].trigger).toBe("mid");
292
+ expect(predictions[2].trigger).toBe("low");
293
+ });
294
+
295
+ it("limits to MAX_PREDICTIONS", () => {
296
+ const signals: PredictionSignal[] = Array.from({ length: 10 }, (_, i) => ({
297
+ type: "stale_open_loop" as const,
298
+ trigger: `task-${i}`,
299
+ action: `action-${i}`,
300
+ weight: 1.0,
301
+ evidence: [],
302
+ }));
303
+
304
+ const predictions = scorePredictions(signals);
305
+ expect(predictions.length).toBe(MAX_PREDICTIONS);
306
+ });
307
+
308
+ it("returns empty for all below threshold", () => {
309
+ const signals: PredictionSignal[] = [
310
+ { type: "uncovered_topic", trigger: "x", action: "x", weight: 0.3, evidence: [] },
311
+ ];
312
+
313
+ const predictions = scorePredictions(signals);
314
+ expect(predictions.length).toBe(0);
315
+ });
316
+
317
+ it("returns empty for empty signals", () => {
318
+ expect(scorePredictions([])).toEqual([]);
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Constants sanity
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe("prediction engine constants", () => {
327
+ it("confidence threshold is 0.6", () => {
328
+ expect(CONFIDENCE_THRESHOLD).toBe(0.6);
329
+ });
330
+
331
+ it("stale open loop hours is 24", () => {
332
+ expect(STALE_OPEN_LOOP_HOURS).toBe(24);
333
+ });
334
+
335
+ it("stale memory days is 14", () => {
336
+ expect(STALE_MEMORY_DAYS).toBe(14);
337
+ });
338
+
339
+ it("max predictions is 5", () => {
340
+ expect(MAX_PREDICTIONS).toBe(5);
341
+ });
342
+
343
+ it("signal weights are all between 0 and 1", () => {
344
+ for (const [, weight] of Object.entries(SIGNAL_WEIGHTS)) {
345
+ expect(weight).toBeGreaterThanOrEqual(0);
346
+ expect(weight).toBeLessThanOrEqual(1);
347
+ }
348
+ });
349
+ });