agent-working-memory 0.8.6 → 0.8.8
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.
- package/README.md +46 -2
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +13 -0
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.js +8 -8
- package/dist/cli/migrate.js +29 -29
- package/dist/cli.js +105 -105
- package/dist/coordination/circuit-breaker.js +23 -23
- package/dist/core/lite-compress.d.ts +26 -0
- package/dist/core/lite-compress.d.ts.map +1 -0
- package/dist/core/lite-compress.js +105 -0
- package/dist/core/lite-compress.js.map +1 -0
- package/dist/mcp.d.ts +5 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +138 -84
- package/dist/mcp.js.map +1 -1
- package/dist/storage/pglite-schema.js +143 -143
- package/dist/storage/pglite.js +138 -138
- package/package.json +4 -3
- package/src/adapters/common.ts +13 -0
- package/src/api/index.ts +3 -3
- package/src/api/routes.ts +1 -1
- package/src/cli/migrate.ts +307 -307
- package/src/cli.ts +1 -1
- package/src/coordination/circuit-breaker.ts +83 -83
- package/src/coordination/failure-modes.ts +50 -50
- package/src/core/decay.ts +63 -63
- package/src/core/embeddings.ts +110 -110
- package/src/core/index.ts +5 -5
- package/src/core/lite-compress.ts +129 -0
- package/src/core/logger.ts +36 -36
- package/src/core/ml-worker-entry.ts +194 -194
- package/src/core/ml-worker.ts +281 -281
- package/src/core/query-expander.ts +122 -122
- package/src/core/reranker.ts +119 -119
- package/src/engine/confidence.ts +120 -120
- package/src/engine/connections.ts +162 -162
- package/src/engine/consolidation-scheduler.ts +242 -242
- package/src/engine/eval.ts +102 -102
- package/src/engine/eviction.ts +101 -101
- package/src/engine/index.ts +8 -8
- package/src/engine/retraction.ts +366 -366
- package/src/engine/staging.ts +74 -74
- package/src/mcp.ts +70 -4
- package/src/storage/factory.ts +147 -147
- package/src/storage/index.ts +3 -3
- package/src/storage/pglite-schema.ts +166 -166
- package/src/storage/pglite.ts +1363 -1363
- package/src/storage/store.ts +80 -80
- package/src/types/agent.ts +67 -67
- package/src/types/checkpoint.ts +46 -46
- package/src/types/eval.ts +100 -100
- package/src/types/index.ts +6 -6
package/src/engine/retraction.ts
CHANGED
|
@@ -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
|
+
}
|