agent-working-memory 0.8.7 → 0.9.0

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 (57) hide show
  1. package/README.md +207 -46
  2. package/dist/api/routes.js +1 -1
  3. package/dist/cli/migrate.js +29 -29
  4. package/dist/cli.js +1 -1
  5. package/dist/coordination/circuit-breaker.js +23 -23
  6. package/dist/core/write-pipeline.d.ts.map +1 -1
  7. package/dist/core/write-pipeline.js +17 -0
  8. package/dist/core/write-pipeline.js.map +1 -1
  9. package/dist/engine/activation.d.ts +28 -0
  10. package/dist/engine/activation.d.ts.map +1 -1
  11. package/dist/engine/activation.js +341 -11
  12. package/dist/engine/activation.js.map +1 -1
  13. package/dist/engine/connections.d.ts +12 -0
  14. package/dist/engine/connections.d.ts.map +1 -1
  15. package/dist/engine/connections.js +95 -0
  16. package/dist/engine/connections.js.map +1 -1
  17. package/dist/mcp.js +2 -2
  18. package/dist/storage/pglite-schema.js +143 -143
  19. package/dist/storage/pglite.js +138 -138
  20. package/dist/types/engram.d.ts +1 -0
  21. package/dist/types/engram.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/api/index.ts +3 -3
  24. package/src/api/routes.ts +1 -1
  25. package/src/cli/migrate.ts +307 -307
  26. package/src/cli.ts +1 -1
  27. package/src/coordination/circuit-breaker.ts +83 -83
  28. package/src/coordination/failure-modes.ts +50 -50
  29. package/src/core/decay.ts +63 -63
  30. package/src/core/embeddings.ts +110 -110
  31. package/src/core/index.ts +5 -5
  32. package/src/core/logger.ts +36 -36
  33. package/src/core/ml-worker-entry.ts +194 -194
  34. package/src/core/ml-worker.ts +281 -281
  35. package/src/core/query-expander.ts +122 -122
  36. package/src/core/reranker.ts +119 -119
  37. package/src/core/write-pipeline.ts +15 -0
  38. package/src/engine/activation.ts +328 -11
  39. package/src/engine/confidence.ts +120 -120
  40. package/src/engine/connections.ts +94 -0
  41. package/src/engine/consolidation-scheduler.ts +242 -242
  42. package/src/engine/eval.ts +102 -102
  43. package/src/engine/eviction.ts +101 -101
  44. package/src/engine/index.ts +8 -8
  45. package/src/engine/retraction.ts +366 -366
  46. package/src/engine/staging.ts +74 -74
  47. package/src/mcp.ts +2 -2
  48. package/src/storage/factory.ts +147 -147
  49. package/src/storage/index.ts +3 -3
  50. package/src/storage/pglite-schema.ts +166 -166
  51. package/src/storage/pglite.ts +1363 -1363
  52. package/src/storage/store.ts +80 -80
  53. package/src/types/agent.ts +67 -67
  54. package/src/types/checkpoint.ts +46 -46
  55. package/src/types/engram.ts +1 -0
  56. package/src/types/eval.ts +100 -100
  57. package/src/types/index.ts +6 -6
@@ -1,366 +1,366 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Retraction Engine — negative memory / invalidation.
5
- *
6
- * Codex critique: "You need explicit anti-salience for wrong info.
7
- * Otherwise wrong memories persist and compound mistakes."
8
- *
9
- * When an agent discovers a memory is wrong:
10
- * 1. The original engram is marked retracted (not deleted — audit trail)
11
- * 2. An invalidation association is created
12
- * 3. Optionally, a counter-engram with correct info is created
13
- * 4. Confidence of associated engrams is reduced (contamination check)
14
- *
15
- * AWM 0.8.5 — Coherence-weighted propagation (2026-05-26)
16
- * --------------------------------------------------------
17
- * The Continued Influence Effect (Carrillo et al., ICCM 2025) shows that
18
- * misinformation persists in human cognition because it lives inside a
19
- * *coherent narrative*, not as an isolated fact. Correcting one chunk doesn't
20
- * displace the narrative unless the correction also propagates through the
21
- * connected structure.
22
- *
23
- * Translation: when we retract an engram, we compute a `cohesion` score for
24
- * its 2-hop neighborhood and use it to amplify or dampen the contamination
25
- * penalty on neighbors:
26
- *
27
- * - Dense narrative cluster (high internal-edge density + shared tags)
28
- * → penalty amplified (~1.5×). The whole cluster shares the wrong story.
29
- *
30
- * - Isolated engram (sparse edges, divergent tags)
31
- * → penalty dampened (~0.5×). No narrative to disrupt.
32
- *
33
- * - Cross-domain bridge (low cohesion across an edge)
34
- * → bridge weight reduced but far-side neighbors barely affected.
35
- *
36
- * Cohesion is computed at retract time (no schema/consolidation changes).
37
- * Cost: bounded by MAX_AFFECTED (20 nodes) × one batched `getAssociationsForBatch`
38
- * call — typically 10-25ms.
39
- */
40
-
41
- import type { IEngramStore as EngramStore } from '../storage/store.js';
42
- import type { Retraction, Association, Engram } from '../types/index.js';
43
-
44
- /** Result of cohesion analysis on a 2-hop neighborhood. */
45
- export interface NeighborhoodCohesion {
46
- /** internal_edges / (internal_edges + external_edges) across the subgraph. [0, 1] */
47
- graphDensity: number;
48
- /** mean jaccard(source.tags, neighbor.tags) across non-source subgraph members. [0, 1] */
49
- tagOverlap: number;
50
- /** Combined cohesion in [0, 1] — graphDensity blended with tagOverlap bonus. */
51
- score: number;
52
- /** How many engrams the cohesion was computed over (excluding the source). */
53
- subgraphSize: number;
54
- }
55
-
56
- export class RetractionEngine {
57
- private store: EngramStore;
58
-
59
- constructor(store: EngramStore) {
60
- this.store = store;
61
- }
62
-
63
- /**
64
- * Retract a memory — mark it invalid and optionally create a correction.
65
- */
66
- async retract(retraction: Retraction): Promise<{
67
- retractedId: string;
68
- correctionId: string | null;
69
- associatesAffected: number;
70
- cohesion: NeighborhoodCohesion;
71
- narrativeEdgesInherited: number;
72
- }> {
73
- const target = await this.store.getEngram(retraction.targetEngramId);
74
- if (!target) {
75
- throw new Error(`Engram ${retraction.targetEngramId} not found`);
76
- }
77
-
78
- // Mark the original as retracted
79
- await this.store.retractEngram(target.id, null);
80
-
81
- let correctionId: string | null = null;
82
- let narrativeEdgesInherited = 0;
83
-
84
- // Create counter-engram if correction content provided
85
- if (retraction.counterContent) {
86
- const correction = await this.store.createEngram({
87
- agentId: retraction.agentId,
88
- concept: `correction:${target.concept}`,
89
- content: retraction.counterContent,
90
- tags: [...target.tags, 'correction', 'retraction'],
91
- salience: Math.max(target.salience, 0.6), // Corrections are at least moderately salient
92
- confidence: 0.7,
93
- reasonCodes: ['retraction_correction', `invalidates:${target.id}`],
94
- });
95
-
96
- correctionId = correction.id;
97
-
98
- // Create invalidation link
99
- await this.store.upsertAssociation(
100
- correction.id, target.id, 1.0, 'invalidation', 1.0
101
- );
102
-
103
- // Update retracted_by to point to correction
104
- await this.store.retractEngram(target.id, correction.id);
105
-
106
- // Counter-narrative replacement (Carrillo et al ICCM 2025):
107
- // The correction must take over the retracted memory's narrative position,
108
- // not just contradict it. Inherit edges from the retracted's strong
109
- // neighbors so the correction lives in the same context that the wrong
110
- // memory occupied.
111
- narrativeEdgesInherited = await this.inheritNarrativeEdges(target.id, correction.id);
112
- }
113
-
114
- // Compute cohesion of the target's neighborhood. A dense/cohesive
115
- // neighborhood means the retracted memory was part of a coherent
116
- // narrative; we propagate the contamination penalty more aggressively.
117
- const cohesion = await this.computeNeighborhoodCohesion(target, 2);
118
-
119
- // Reduce confidence of associated engrams (contamination spread).
120
- // Depth 2 with cohesion-weighted penalty, capped at MAX_AFFECTED nodes.
121
- const associatesAffected = await this.propagateConfidenceReduction(
122
- target.id, 0.1, 2, cohesion.score,
123
- );
124
-
125
- return { retractedId: target.id, correctionId, associatesAffected, cohesion, narrativeEdgesInherited };
126
- }
127
-
128
- /**
129
- * Counter-narrative replacement helper.
130
- *
131
- * When a correction engram is created to replace a retracted memory, the
132
- * correction inherits the retracted's strong-edge neighbors. This implements
133
- * the "narrative replacement" mechanism the Continued Influence Effect paper
134
- * argues is necessary for corrections to take hold: people don't drop
135
- * misinformation when only one chunk is corrected; the surrounding context
136
- * has to be reconnected to the new correct fact.
137
- *
138
- * Rules:
139
- * - Inherit only edges where weight >= NARRATIVE_INHERIT_MIN (0.4). Weak
140
- * edges aren't really part of the narrative.
141
- * - Skip edge types with special semantics: `invalidation` (the link
142
- * between retracted and correction itself), `causal` (specific cause
143
- * relationships, not narrative cohesion), `temporal` (chronological
144
- * sequence, not story structure).
145
- * - Skip neighbors that are themselves retracted — no point connecting
146
- * a correction to other wrong memories.
147
- * - Inherited edge weight = original × 0.7 (reduced — the correction
148
- * experienced this context only indirectly, through the retracted memory).
149
- * - Cap at NARRATIVE_INHERIT_MAX (10) edges, sorted by weight desc, to
150
- * bound the blast radius on highly-connected hubs.
151
- *
152
- * Returns the number of edges actually inherited.
153
- */
154
- private static readonly NARRATIVE_INHERIT_MIN = 0.4;
155
- private static readonly NARRATIVE_INHERIT_MAX = 10;
156
- private static readonly NARRATIVE_INHERIT_WEIGHT_SCALE = 0.7;
157
- private static readonly NARRATIVE_INHERIT_SKIP_TYPES = new Set([
158
- 'invalidation', 'causal', 'temporal',
159
- ]);
160
-
161
- async inheritNarrativeEdges(retractedId: string, correctionId: string): Promise<number> {
162
- const associations = await this.store.getAssociationsFor(retractedId);
163
-
164
- // Filter to inheritable edges
165
- const candidates: Array<{ neighborId: string; weight: number; confidence: number; type: string }> = [];
166
- for (const assoc of associations) {
167
- if (RetractionEngine.NARRATIVE_INHERIT_SKIP_TYPES.has(assoc.type)) continue;
168
- if (assoc.weight < RetractionEngine.NARRATIVE_INHERIT_MIN) continue;
169
- const neighborId = assoc.fromEngramId === retractedId ? assoc.toEngramId : assoc.fromEngramId;
170
- if (neighborId === correctionId) continue; // don't self-loop
171
- candidates.push({ neighborId, weight: assoc.weight, confidence: assoc.confidence, type: assoc.type });
172
- }
173
-
174
- // Sort by weight desc, take top N
175
- candidates.sort((a, b) => b.weight - a.weight);
176
- const top = candidates.slice(0, RetractionEngine.NARRATIVE_INHERIT_MAX);
177
- if (top.length === 0) return 0;
178
-
179
- // Filter out retracted neighbors (would inherit edges to wrong memories)
180
- const neighborEngrams = await this.store.getEngramsByIds(top.map(c => c.neighborId));
181
- const neighborMap = new Map(neighborEngrams.map(e => [e.id, e]));
182
-
183
- let inherited = 0;
184
- for (const c of top) {
185
- const neighbor = neighborMap.get(c.neighborId);
186
- if (!neighbor || neighbor.retracted) continue;
187
- const newWeight = c.weight * RetractionEngine.NARRATIVE_INHERIT_WEIGHT_SCALE;
188
- await this.store.upsertAssociation(
189
- correctionId,
190
- c.neighborId,
191
- newWeight,
192
- 'connection', // Reuse existing type; semantically these ARE connections from the correction's perspective.
193
- c.confidence,
194
- );
195
- inherited++;
196
- }
197
- return inherited;
198
- }
199
-
200
- /**
201
- * Compute the narrative cohesion of `source`'s neighborhood within `depth` hops.
202
- *
203
- * Cohesion combines two signals:
204
- * - **Graph density**: How tightly interconnected are the neighbors?
205
- * internal_edges / (internal_edges + external_edges) across the subgraph.
206
- * - **Tag overlap**: How much do the neighbors share semantic tags with the source?
207
- * mean jaccard(source.tags, neighbor.tags).
208
- *
209
- * Both signals contribute on a [0, 1] scale; the combined score is
210
- * `density × (1 + tagOverlap)` clamped to [0, 1]. A score of 0.5 is the
211
- * "neutral" point where retraction penalty is unchanged from the legacy
212
- * formula.
213
- *
214
- * Cost: O(subgraphSize × avg_degree) BFS + 1 batched fetch. Bounded by
215
- * MAX_AFFECTED (20). Typical: ~10-25ms.
216
- */
217
- async computeNeighborhoodCohesion(
218
- source: Engram,
219
- depth: number = 2,
220
- ): Promise<NeighborhoodCohesion> {
221
- // BFS to collect the subgraph nodes
222
- const subgraphIds = new Set<string>([source.id]);
223
- let frontier: string[] = [source.id];
224
- for (let d = 0; d < depth; d++) {
225
- const next: string[] = [];
226
- // Batch-fetch associations for the current frontier
227
- const assocs = await this.store.getAssociationsForBatch(frontier);
228
- for (const id of frontier) {
229
- const list = assocs.get(id) ?? [];
230
- for (const a of list) {
231
- if (a.type === 'invalidation') continue;
232
- const neighbor = a.fromEngramId === id ? a.toEngramId : a.fromEngramId;
233
- if (subgraphIds.has(neighbor)) continue;
234
- if (subgraphIds.size >= RetractionEngine.MAX_AFFECTED) break;
235
- subgraphIds.add(neighbor);
236
- next.push(neighbor);
237
- }
238
- if (subgraphIds.size >= RetractionEngine.MAX_AFFECTED) break;
239
- }
240
- if (next.length === 0) break;
241
- frontier = next;
242
- }
243
-
244
- const subgraphSize = subgraphIds.size - 1; // exclude source
245
-
246
- if (subgraphSize === 0) {
247
- // No neighbors at all — fully isolated engram.
248
- return { graphDensity: 0, tagOverlap: 0, score: 0, subgraphSize: 0 };
249
- }
250
-
251
- // Fetch all subgraph members' associations in one batch.
252
- const allIds = Array.from(subgraphIds);
253
- const allAssocs = await this.store.getAssociationsForBatch(allIds);
254
-
255
- // Count internal vs external edges across the subgraph.
256
- let internalEdges = 0;
257
- let externalEdges = 0;
258
- const seenEdge = new Set<string>(); // de-dupe (each edge appears twice in BFS by endpoint)
259
- for (const id of allIds) {
260
- const list = allAssocs.get(id) ?? [];
261
- for (const a of list) {
262
- if (a.type === 'invalidation') continue;
263
- // Canonical edge key (alphabetical) for de-duping
264
- const key = a.fromEngramId < a.toEngramId
265
- ? `${a.fromEngramId}|${a.toEngramId}`
266
- : `${a.toEngramId}|${a.fromEngramId}`;
267
- if (seenEdge.has(key)) continue;
268
- seenEdge.add(key);
269
- const internal = subgraphIds.has(a.fromEngramId) && subgraphIds.has(a.toEngramId);
270
- if (internal) internalEdges++;
271
- else externalEdges++;
272
- }
273
- }
274
- const totalEdges = internalEdges + externalEdges;
275
- const graphDensity = totalEdges > 0 ? internalEdges / totalEdges : 0;
276
-
277
- // Compute tag overlap (source vs each subgraph member).
278
- const sourceTags = new Set(source.tags ?? []);
279
- let tagOverlapSum = 0;
280
- let tagOverlapCount = 0;
281
- if (sourceTags.size > 0) {
282
- // Fetch all subgraph engrams to read tags
283
- const neighborIds = allIds.filter(id => id !== source.id);
284
- const engrams = await this.store.getEngramsByIds(neighborIds);
285
- for (const e of engrams) {
286
- const neighborTags = new Set(e.tags ?? []);
287
- if (neighborTags.size === 0) continue;
288
- const intersection = [...sourceTags].filter(t => neighborTags.has(t)).length;
289
- const union = sourceTags.size + neighborTags.size - intersection;
290
- const jaccard = union > 0 ? intersection / union : 0;
291
- tagOverlapSum += jaccard;
292
- tagOverlapCount++;
293
- }
294
- }
295
- const tagOverlap = tagOverlapCount > 0 ? tagOverlapSum / tagOverlapCount : 0;
296
-
297
- // Combine: graph density × tag-modulated weight. Density alone saturates
298
- // quickly in small subgraphs (everything is internal); tag overlap is the
299
- // signal that distinguishes a genuine narrative from an incidental cluster.
300
- // score = density * (0.5 + 0.5 * tagOverlap)
301
- // - max (density=1, tagOverlap=1) → 1.0 (canonical narrative)
302
- // - hub (density=1, tagOverlap=0) → 0.5 (structurally connected but no shared story)
303
- // - half (density=0.5, tagOverlap=0.5) → 0.375
304
- // - zero (density=0) → 0 (no neighborhood)
305
- const rawScore = graphDensity * (0.5 + 0.5 * tagOverlap);
306
- const score = Math.max(0, Math.min(1, rawScore));
307
-
308
- return { graphDensity, tagOverlap, score, subgraphSize };
309
- }
310
-
311
- /**
312
- * Reduce confidence of engrams associated with a retracted engram.
313
- * Propagates up to maxDepth hops with decaying penalty (50% per hop) and
314
- * cohesion-weighted amplification.
315
- * Capped at MAX_AFFECTED to prevent cascading through the graph.
316
- */
317
- private static readonly MAX_AFFECTED = 20;
318
-
319
- private async propagateConfidenceReduction(
320
- engramId: string,
321
- penalty: number,
322
- maxDepth: number,
323
- cohesionScore: number,
324
- currentDepth: number = 0,
325
- visited: Set<string> = new Set(),
326
- ): Promise<number> {
327
- if (currentDepth >= maxDepth) return 0;
328
- if (visited.size >= RetractionEngine.MAX_AFFECTED) return 0;
329
- visited.add(engramId);
330
-
331
- // Cohesion multiplier: 0.5 (isolated) → 1.5 (densely coherent narrative).
332
- // At cohesion=0.5 (neutral), multiplier=1.0, preserving legacy behavior.
333
- const cohesionMultiplier = 0.5 + cohesionScore;
334
-
335
- let affected = 0;
336
- const associations = await this.store.getAssociationsFor(engramId);
337
- for (const assoc of associations) {
338
- if (assoc.type === 'invalidation') continue; // Don't penalize corrections
339
- if (visited.size >= RetractionEngine.MAX_AFFECTED) break;
340
-
341
- const neighborId = assoc.fromEngramId === engramId
342
- ? assoc.toEngramId
343
- : assoc.fromEngramId;
344
- if (visited.has(neighborId)) continue;
345
-
346
- const neighbor = await this.store.getEngram(neighborId);
347
- if (!neighbor || neighbor.retracted) continue;
348
-
349
- // Scale penalty by association weight, depth decay (50% per hop),
350
- // and cohesion multiplier (narrative-aware amplification).
351
- const depthDecay = Math.pow(0.5, currentDepth);
352
- const scaledPenalty = penalty * assoc.weight * depthDecay * cohesionMultiplier;
353
- const newConfidence = Math.max(0.1, neighbor.confidence - scaledPenalty);
354
- await this.store.updateConfidence(neighborId, newConfidence);
355
- affected++;
356
-
357
- // Recurse to next depth — cohesion is computed once at the source
358
- // and applied uniformly through the propagation. Recomputing per-hop
359
- // would be expensive and could cause oscillation.
360
- affected += await this.propagateConfidenceReduction(
361
- neighborId, penalty, maxDepth, cohesionScore, currentDepth + 1, visited,
362
- );
363
- }
364
- return affected;
365
- }
366
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Retraction Engine — negative memory / invalidation.
5
+ *
6
+ * Codex critique: "You need explicit anti-salience for wrong info.
7
+ * Otherwise wrong memories persist and compound mistakes."
8
+ *
9
+ * When an agent discovers a memory is wrong:
10
+ * 1. The original engram is marked retracted (not deleted — audit trail)
11
+ * 2. An invalidation association is created
12
+ * 3. Optionally, a counter-engram with correct info is created
13
+ * 4. Confidence of associated engrams is reduced (contamination check)
14
+ *
15
+ * AWM 0.8.5 — Coherence-weighted propagation (2026-05-26)
16
+ * --------------------------------------------------------
17
+ * The Continued Influence Effect (Carrillo et al., ICCM 2025) shows that
18
+ * misinformation persists in human cognition because it lives inside a
19
+ * *coherent narrative*, not as an isolated fact. Correcting one chunk doesn't
20
+ * displace the narrative unless the correction also propagates through the
21
+ * connected structure.
22
+ *
23
+ * Translation: when we retract an engram, we compute a `cohesion` score for
24
+ * its 2-hop neighborhood and use it to amplify or dampen the contamination
25
+ * penalty on neighbors:
26
+ *
27
+ * - Dense narrative cluster (high internal-edge density + shared tags)
28
+ * → penalty amplified (~1.5×). The whole cluster shares the wrong story.
29
+ *
30
+ * - Isolated engram (sparse edges, divergent tags)
31
+ * → penalty dampened (~0.5×). No narrative to disrupt.
32
+ *
33
+ * - Cross-domain bridge (low cohesion across an edge)
34
+ * → bridge weight reduced but far-side neighbors barely affected.
35
+ *
36
+ * Cohesion is computed at retract time (no schema/consolidation changes).
37
+ * Cost: bounded by MAX_AFFECTED (20 nodes) × one batched `getAssociationsForBatch`
38
+ * call — typically 10-25ms.
39
+ */
40
+
41
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
42
+ import type { Retraction, Association, Engram } from '../types/index.js';
43
+
44
+ /** Result of cohesion analysis on a 2-hop neighborhood. */
45
+ export interface NeighborhoodCohesion {
46
+ /** internal_edges / (internal_edges + external_edges) across the subgraph. [0, 1] */
47
+ graphDensity: number;
48
+ /** mean jaccard(source.tags, neighbor.tags) across non-source subgraph members. [0, 1] */
49
+ tagOverlap: number;
50
+ /** Combined cohesion in [0, 1] — graphDensity blended with tagOverlap bonus. */
51
+ score: number;
52
+ /** How many engrams the cohesion was computed over (excluding the source). */
53
+ subgraphSize: number;
54
+ }
55
+
56
+ export class RetractionEngine {
57
+ private store: EngramStore;
58
+
59
+ constructor(store: EngramStore) {
60
+ this.store = store;
61
+ }
62
+
63
+ /**
64
+ * Retract a memory — mark it invalid and optionally create a correction.
65
+ */
66
+ async retract(retraction: Retraction): Promise<{
67
+ retractedId: string;
68
+ correctionId: string | null;
69
+ associatesAffected: number;
70
+ cohesion: NeighborhoodCohesion;
71
+ narrativeEdgesInherited: number;
72
+ }> {
73
+ const target = await this.store.getEngram(retraction.targetEngramId);
74
+ if (!target) {
75
+ throw new Error(`Engram ${retraction.targetEngramId} not found`);
76
+ }
77
+
78
+ // Mark the original as retracted
79
+ await this.store.retractEngram(target.id, null);
80
+
81
+ let correctionId: string | null = null;
82
+ let narrativeEdgesInherited = 0;
83
+
84
+ // Create counter-engram if correction content provided
85
+ if (retraction.counterContent) {
86
+ const correction = await this.store.createEngram({
87
+ agentId: retraction.agentId,
88
+ concept: `correction:${target.concept}`,
89
+ content: retraction.counterContent,
90
+ tags: [...target.tags, 'correction', 'retraction'],
91
+ salience: Math.max(target.salience, 0.6), // Corrections are at least moderately salient
92
+ confidence: 0.7,
93
+ reasonCodes: ['retraction_correction', `invalidates:${target.id}`],
94
+ });
95
+
96
+ correctionId = correction.id;
97
+
98
+ // Create invalidation link
99
+ await this.store.upsertAssociation(
100
+ correction.id, target.id, 1.0, 'invalidation', 1.0
101
+ );
102
+
103
+ // Update retracted_by to point to correction
104
+ await this.store.retractEngram(target.id, correction.id);
105
+
106
+ // Counter-narrative replacement (Carrillo et al ICCM 2025):
107
+ // The correction must take over the retracted memory's narrative position,
108
+ // not just contradict it. Inherit edges from the retracted's strong
109
+ // neighbors so the correction lives in the same context that the wrong
110
+ // memory occupied.
111
+ narrativeEdgesInherited = await this.inheritNarrativeEdges(target.id, correction.id);
112
+ }
113
+
114
+ // Compute cohesion of the target's neighborhood. A dense/cohesive
115
+ // neighborhood means the retracted memory was part of a coherent
116
+ // narrative; we propagate the contamination penalty more aggressively.
117
+ const cohesion = await this.computeNeighborhoodCohesion(target, 2);
118
+
119
+ // Reduce confidence of associated engrams (contamination spread).
120
+ // Depth 2 with cohesion-weighted penalty, capped at MAX_AFFECTED nodes.
121
+ const associatesAffected = await this.propagateConfidenceReduction(
122
+ target.id, 0.1, 2, cohesion.score,
123
+ );
124
+
125
+ return { retractedId: target.id, correctionId, associatesAffected, cohesion, narrativeEdgesInherited };
126
+ }
127
+
128
+ /**
129
+ * Counter-narrative replacement helper.
130
+ *
131
+ * When a correction engram is created to replace a retracted memory, the
132
+ * correction inherits the retracted's strong-edge neighbors. This implements
133
+ * the "narrative replacement" mechanism the Continued Influence Effect paper
134
+ * argues is necessary for corrections to take hold: people don't drop
135
+ * misinformation when only one chunk is corrected; the surrounding context
136
+ * has to be reconnected to the new correct fact.
137
+ *
138
+ * Rules:
139
+ * - Inherit only edges where weight >= NARRATIVE_INHERIT_MIN (0.4). Weak
140
+ * edges aren't really part of the narrative.
141
+ * - Skip edge types with special semantics: `invalidation` (the link
142
+ * between retracted and correction itself), `causal` (specific cause
143
+ * relationships, not narrative cohesion), `temporal` (chronological
144
+ * sequence, not story structure).
145
+ * - Skip neighbors that are themselves retracted — no point connecting
146
+ * a correction to other wrong memories.
147
+ * - Inherited edge weight = original × 0.7 (reduced — the correction
148
+ * experienced this context only indirectly, through the retracted memory).
149
+ * - Cap at NARRATIVE_INHERIT_MAX (10) edges, sorted by weight desc, to
150
+ * bound the blast radius on highly-connected hubs.
151
+ *
152
+ * Returns the number of edges actually inherited.
153
+ */
154
+ private static readonly NARRATIVE_INHERIT_MIN = 0.4;
155
+ private static readonly NARRATIVE_INHERIT_MAX = 10;
156
+ private static readonly NARRATIVE_INHERIT_WEIGHT_SCALE = 0.7;
157
+ private static readonly NARRATIVE_INHERIT_SKIP_TYPES = new Set([
158
+ 'invalidation', 'causal', 'temporal',
159
+ ]);
160
+
161
+ async inheritNarrativeEdges(retractedId: string, correctionId: string): Promise<number> {
162
+ const associations = await this.store.getAssociationsFor(retractedId);
163
+
164
+ // Filter to inheritable edges
165
+ const candidates: Array<{ neighborId: string; weight: number; confidence: number; type: string }> = [];
166
+ for (const assoc of associations) {
167
+ if (RetractionEngine.NARRATIVE_INHERIT_SKIP_TYPES.has(assoc.type)) continue;
168
+ if (assoc.weight < RetractionEngine.NARRATIVE_INHERIT_MIN) continue;
169
+ const neighborId = assoc.fromEngramId === retractedId ? assoc.toEngramId : assoc.fromEngramId;
170
+ if (neighborId === correctionId) continue; // don't self-loop
171
+ candidates.push({ neighborId, weight: assoc.weight, confidence: assoc.confidence, type: assoc.type });
172
+ }
173
+
174
+ // Sort by weight desc, take top N
175
+ candidates.sort((a, b) => b.weight - a.weight);
176
+ const top = candidates.slice(0, RetractionEngine.NARRATIVE_INHERIT_MAX);
177
+ if (top.length === 0) return 0;
178
+
179
+ // Filter out retracted neighbors (would inherit edges to wrong memories)
180
+ const neighborEngrams = await this.store.getEngramsByIds(top.map(c => c.neighborId));
181
+ const neighborMap = new Map(neighborEngrams.map(e => [e.id, e]));
182
+
183
+ let inherited = 0;
184
+ for (const c of top) {
185
+ const neighbor = neighborMap.get(c.neighborId);
186
+ if (!neighbor || neighbor.retracted) continue;
187
+ const newWeight = c.weight * RetractionEngine.NARRATIVE_INHERIT_WEIGHT_SCALE;
188
+ await this.store.upsertAssociation(
189
+ correctionId,
190
+ c.neighborId,
191
+ newWeight,
192
+ 'connection', // Reuse existing type; semantically these ARE connections from the correction's perspective.
193
+ c.confidence,
194
+ );
195
+ inherited++;
196
+ }
197
+ return inherited;
198
+ }
199
+
200
+ /**
201
+ * Compute the narrative cohesion of `source`'s neighborhood within `depth` hops.
202
+ *
203
+ * Cohesion combines two signals:
204
+ * - **Graph density**: How tightly interconnected are the neighbors?
205
+ * internal_edges / (internal_edges + external_edges) across the subgraph.
206
+ * - **Tag overlap**: How much do the neighbors share semantic tags with the source?
207
+ * mean jaccard(source.tags, neighbor.tags).
208
+ *
209
+ * Both signals contribute on a [0, 1] scale; the combined score is
210
+ * `density × (1 + tagOverlap)` clamped to [0, 1]. A score of 0.5 is the
211
+ * "neutral" point where retraction penalty is unchanged from the legacy
212
+ * formula.
213
+ *
214
+ * Cost: O(subgraphSize × avg_degree) BFS + 1 batched fetch. Bounded by
215
+ * MAX_AFFECTED (20). Typical: ~10-25ms.
216
+ */
217
+ async computeNeighborhoodCohesion(
218
+ source: Engram,
219
+ depth: number = 2,
220
+ ): Promise<NeighborhoodCohesion> {
221
+ // BFS to collect the subgraph nodes
222
+ const subgraphIds = new Set<string>([source.id]);
223
+ let frontier: string[] = [source.id];
224
+ for (let d = 0; d < depth; d++) {
225
+ const next: string[] = [];
226
+ // Batch-fetch associations for the current frontier
227
+ const assocs = await this.store.getAssociationsForBatch(frontier);
228
+ for (const id of frontier) {
229
+ const list = assocs.get(id) ?? [];
230
+ for (const a of list) {
231
+ if (a.type === 'invalidation') continue;
232
+ const neighbor = a.fromEngramId === id ? a.toEngramId : a.fromEngramId;
233
+ if (subgraphIds.has(neighbor)) continue;
234
+ if (subgraphIds.size >= RetractionEngine.MAX_AFFECTED) break;
235
+ subgraphIds.add(neighbor);
236
+ next.push(neighbor);
237
+ }
238
+ if (subgraphIds.size >= RetractionEngine.MAX_AFFECTED) break;
239
+ }
240
+ if (next.length === 0) break;
241
+ frontier = next;
242
+ }
243
+
244
+ const subgraphSize = subgraphIds.size - 1; // exclude source
245
+
246
+ if (subgraphSize === 0) {
247
+ // No neighbors at all — fully isolated engram.
248
+ return { graphDensity: 0, tagOverlap: 0, score: 0, subgraphSize: 0 };
249
+ }
250
+
251
+ // Fetch all subgraph members' associations in one batch.
252
+ const allIds = Array.from(subgraphIds);
253
+ const allAssocs = await this.store.getAssociationsForBatch(allIds);
254
+
255
+ // Count internal vs external edges across the subgraph.
256
+ let internalEdges = 0;
257
+ let externalEdges = 0;
258
+ const seenEdge = new Set<string>(); // de-dupe (each edge appears twice in BFS by endpoint)
259
+ for (const id of allIds) {
260
+ const list = allAssocs.get(id) ?? [];
261
+ for (const a of list) {
262
+ if (a.type === 'invalidation') continue;
263
+ // Canonical edge key (alphabetical) for de-duping
264
+ const key = a.fromEngramId < a.toEngramId
265
+ ? `${a.fromEngramId}|${a.toEngramId}`
266
+ : `${a.toEngramId}|${a.fromEngramId}`;
267
+ if (seenEdge.has(key)) continue;
268
+ seenEdge.add(key);
269
+ const internal = subgraphIds.has(a.fromEngramId) && subgraphIds.has(a.toEngramId);
270
+ if (internal) internalEdges++;
271
+ else externalEdges++;
272
+ }
273
+ }
274
+ const totalEdges = internalEdges + externalEdges;
275
+ const graphDensity = totalEdges > 0 ? internalEdges / totalEdges : 0;
276
+
277
+ // Compute tag overlap (source vs each subgraph member).
278
+ const sourceTags = new Set(source.tags ?? []);
279
+ let tagOverlapSum = 0;
280
+ let tagOverlapCount = 0;
281
+ if (sourceTags.size > 0) {
282
+ // Fetch all subgraph engrams to read tags
283
+ const neighborIds = allIds.filter(id => id !== source.id);
284
+ const engrams = await this.store.getEngramsByIds(neighborIds);
285
+ for (const e of engrams) {
286
+ const neighborTags = new Set(e.tags ?? []);
287
+ if (neighborTags.size === 0) continue;
288
+ const intersection = [...sourceTags].filter(t => neighborTags.has(t)).length;
289
+ const union = sourceTags.size + neighborTags.size - intersection;
290
+ const jaccard = union > 0 ? intersection / union : 0;
291
+ tagOverlapSum += jaccard;
292
+ tagOverlapCount++;
293
+ }
294
+ }
295
+ const tagOverlap = tagOverlapCount > 0 ? tagOverlapSum / tagOverlapCount : 0;
296
+
297
+ // Combine: graph density × tag-modulated weight. Density alone saturates
298
+ // quickly in small subgraphs (everything is internal); tag overlap is the
299
+ // signal that distinguishes a genuine narrative from an incidental cluster.
300
+ // score = density * (0.5 + 0.5 * tagOverlap)
301
+ // - max (density=1, tagOverlap=1) → 1.0 (canonical narrative)
302
+ // - hub (density=1, tagOverlap=0) → 0.5 (structurally connected but no shared story)
303
+ // - half (density=0.5, tagOverlap=0.5) → 0.375
304
+ // - zero (density=0) → 0 (no neighborhood)
305
+ const rawScore = graphDensity * (0.5 + 0.5 * tagOverlap);
306
+ const score = Math.max(0, Math.min(1, rawScore));
307
+
308
+ return { graphDensity, tagOverlap, score, subgraphSize };
309
+ }
310
+
311
+ /**
312
+ * Reduce confidence of engrams associated with a retracted engram.
313
+ * Propagates up to maxDepth hops with decaying penalty (50% per hop) and
314
+ * cohesion-weighted amplification.
315
+ * Capped at MAX_AFFECTED to prevent cascading through the graph.
316
+ */
317
+ private static readonly MAX_AFFECTED = 20;
318
+
319
+ private async propagateConfidenceReduction(
320
+ engramId: string,
321
+ penalty: number,
322
+ maxDepth: number,
323
+ cohesionScore: number,
324
+ currentDepth: number = 0,
325
+ visited: Set<string> = new Set(),
326
+ ): Promise<number> {
327
+ if (currentDepth >= maxDepth) return 0;
328
+ if (visited.size >= RetractionEngine.MAX_AFFECTED) return 0;
329
+ visited.add(engramId);
330
+
331
+ // Cohesion multiplier: 0.5 (isolated) → 1.5 (densely coherent narrative).
332
+ // At cohesion=0.5 (neutral), multiplier=1.0, preserving legacy behavior.
333
+ const cohesionMultiplier = 0.5 + cohesionScore;
334
+
335
+ let affected = 0;
336
+ const associations = await this.store.getAssociationsFor(engramId);
337
+ for (const assoc of associations) {
338
+ if (assoc.type === 'invalidation') continue; // Don't penalize corrections
339
+ if (visited.size >= RetractionEngine.MAX_AFFECTED) break;
340
+
341
+ const neighborId = assoc.fromEngramId === engramId
342
+ ? assoc.toEngramId
343
+ : assoc.fromEngramId;
344
+ if (visited.has(neighborId)) continue;
345
+
346
+ const neighbor = await this.store.getEngram(neighborId);
347
+ if (!neighbor || neighbor.retracted) continue;
348
+
349
+ // Scale penalty by association weight, depth decay (50% per hop),
350
+ // and cohesion multiplier (narrative-aware amplification).
351
+ const depthDecay = Math.pow(0.5, currentDepth);
352
+ const scaledPenalty = penalty * assoc.weight * depthDecay * cohesionMultiplier;
353
+ const newConfidence = Math.max(0.1, neighbor.confidence - scaledPenalty);
354
+ await this.store.updateConfidence(neighborId, newConfidence);
355
+ affected++;
356
+
357
+ // Recurse to next depth — cohesion is computed once at the source
358
+ // and applied uniformly through the propagation. Recomputing per-hop
359
+ // would be expensive and could cause oscillation.
360
+ affected += await this.propagateConfidenceReduction(
361
+ neighborId, penalty, maxDepth, cohesionScore, currentDepth + 1, visited,
362
+ );
363
+ }
364
+ return affected;
365
+ }
366
+ }