recallnest 3.0.0 → 3.0.1
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/CHANGELOG.md +101 -0
- package/README.md +222 -254
- package/README_CN.md +206 -243
- package/package.json +3 -2
- package/src/capture-engine.ts +26 -4
- package/src/context-composer-stable.ts +5 -1
- package/src/embedder.ts +24 -0
- package/src/env-config.ts +18 -0
- package/src/ingest.ts +225 -17
- package/src/kg-extractor.ts +17 -0
- package/src/memory-output.ts +35 -0
- package/src/pivot-apply.ts +52 -1
- package/src/pivot-evidence.ts +128 -0
- package/src/ppr-traversal.ts +25 -1
- package/src/runtime-config.ts +9 -0
- package/src/session-engine.ts +11 -4
- package/src/session-output.ts +9 -1
- package/src/session-store.ts +2 -3
- package/src/skill-promotion.ts +67 -1
- package/src/__tests__/access-tracker-novelty.test.ts +0 -324
- package/src/__tests__/activity-counter.test.ts +0 -125
- package/src/__tests__/adaptive-fidelity.test.ts +0 -102
- package/src/__tests__/admission-control.test.ts +0 -206
- package/src/__tests__/age-label.test.ts +0 -33
- package/src/__tests__/anchor-generator.test.ts +0 -67
- package/src/__tests__/api-server-security.test.ts +0 -102
- package/src/__tests__/auto-consolidation.test.ts +0 -168
- package/src/__tests__/auto-recall.test.ts +0 -131
- package/src/__tests__/babel-integration.test.ts +0 -144
- package/src/__tests__/batch-dedup.test.ts +0 -65
- package/src/__tests__/belief-history.test.ts +0 -220
- package/src/__tests__/canary-eval.test.ts +0 -114
- package/src/__tests__/capture-engine.test.ts +0 -1328
- package/src/__tests__/capture-heuristic.test.ts +0 -171
- package/src/__tests__/cascade-forget.test.ts +0 -210
- package/src/__tests__/category-min-scores.test.ts +0 -116
- package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
- package/src/__tests__/circuit-breaker.test.ts +0 -99
- package/src/__tests__/cluster-consolidation.test.ts +0 -1236
- package/src/__tests__/confidence-metadata.test.ts +0 -125
- package/src/__tests__/confidence-tracker.test.ts +0 -261
- package/src/__tests__/conflict-advisor.test.ts +0 -206
- package/src/__tests__/conflict-escalation.test.ts +0 -149
- package/src/__tests__/conflict-lifecycle.test.ts +0 -83
- package/src/__tests__/conflict-output.test.ts +0 -85
- package/src/__tests__/conflict-store.test.ts +0 -418
- package/src/__tests__/connector-v1.test.ts +0 -181
- package/src/__tests__/consolidation-engine.test.ts +0 -520
- package/src/__tests__/constructive-retrieval.test.ts +0 -509
- package/src/__tests__/context-collapse-renderer.test.ts +0 -214
- package/src/__tests__/context-composer-pins.test.ts +0 -64
- package/src/__tests__/context-composer-task-selection.test.ts +0 -24
- package/src/__tests__/context-reconstructor.test.ts +0 -65
- package/src/__tests__/context-renderer.test.ts +0 -91
- package/src/__tests__/continuity-eval.test.ts +0 -275
- package/src/__tests__/continuity-schema.test.ts +0 -97
- package/src/__tests__/contracts.test.ts +0 -262
- package/src/__tests__/conversation-importer.test.ts +0 -692
- package/src/__tests__/core-recall-eval.test.ts +0 -235
- package/src/__tests__/core-summary.test.ts +0 -109
- package/src/__tests__/data-checkup.test.ts +0 -304
- package/src/__tests__/decay-exemption.test.ts +0 -129
- package/src/__tests__/dedup-actions.test.ts +0 -58
- package/src/__tests__/dedup-guard.test.ts +0 -39
- package/src/__tests__/deterministic-id.test.ts +0 -39
- package/src/__tests__/distill-lock.test.ts +0 -305
- package/src/__tests__/distill-session-argshape.test.ts +0 -79
- package/src/__tests__/doctor.test.ts +0 -319
- package/src/__tests__/dream-pipeline.test.ts +0 -820
- package/src/__tests__/dream-scope-isolation.test.ts +0 -386
- package/src/__tests__/dream-write-batching.test.ts +0 -211
- package/src/__tests__/drill-down.test.ts +0 -108
- package/src/__tests__/embedder.test.ts +0 -122
- package/src/__tests__/emotion-decay.test.ts +0 -46
- package/src/__tests__/emotion-detector.test.ts +0 -56
- package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
- package/src/__tests__/emotion-retrieval.test.ts +0 -56
- package/src/__tests__/entity-resolver.test.ts +0 -58
- package/src/__tests__/env-config-invariant.test.ts +0 -80
- package/src/__tests__/env-config.test.ts +0 -211
- package/src/__tests__/error-signature-boost.test.ts +0 -87
- package/src/__tests__/error-signature.test.ts +0 -95
- package/src/__tests__/error-taxonomy.test.ts +0 -129
- package/src/__tests__/ethics-layer.test.ts +0 -476
- package/src/__tests__/event-segmenter.test.ts +0 -153
- package/src/__tests__/feature-flags-integration.test.ts +0 -54
- package/src/__tests__/frequency-tracker.test.ts +0 -113
- package/src/__tests__/freshness.test.ts +0 -186
- package/src/__tests__/graph-export.test.ts +0 -489
- package/src/__tests__/hotness-blend.test.ts +0 -283
- package/src/__tests__/ingest-chunking.test.ts +0 -44
- package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
- package/src/__tests__/ingest.test.ts +0 -360
- package/src/__tests__/interference-detector.test.ts +0 -203
- package/src/__tests__/kg-extractor.test.ts +0 -192
- package/src/__tests__/kg-store.test.ts +0 -300
- package/src/__tests__/llm-client-pivot.test.ts +0 -628
- package/src/__tests__/llm-consolidation.test.ts +0 -226
- package/src/__tests__/maintenance-locks.test.ts +0 -197
- package/src/__tests__/mcp-efficiency.test.ts +0 -288
- package/src/__tests__/mcp-related-scopes.test.ts +0 -151
- package/src/__tests__/memory-boundaries.test.ts +0 -282
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
- package/src/__tests__/memory-evolution.test.ts +0 -271
- package/src/__tests__/memory-health-rebalance.test.ts +0 -144
- package/src/__tests__/memory-lint.test.ts +0 -421
- package/src/__tests__/memory-output.test.ts +0 -224
- package/src/__tests__/memory-promotion.test.ts +0 -315
- package/src/__tests__/memory-utility.test.ts +0 -256
- package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
- package/src/__tests__/multi-vector.test.ts +0 -242
- package/src/__tests__/narrative-architecture.test.ts +0 -456
- package/src/__tests__/noise-filter.test.ts +0 -205
- package/src/__tests__/noise-prototype-bank.test.ts +0 -280
- package/src/__tests__/obsidian-connector.test.ts +0 -363
- package/src/__tests__/omitted-reporting.test.ts +0 -98
- package/src/__tests__/openai-contract.test.ts +0 -513
- package/src/__tests__/pending-review.test.ts +0 -77
- package/src/__tests__/pii-redact.test.ts +0 -333
- package/src/__tests__/pivot-apply.test.ts +0 -373
- package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
- package/src/__tests__/pivot-distill.test.ts +0 -1178
- package/src/__tests__/post-retrieval-filter.test.ts +0 -178
- package/src/__tests__/ppr-traversal.test.ts +0 -256
- package/src/__tests__/prediction-engine.test.ts +0 -349
- package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
- package/src/__tests__/preference-guard-smoke.test.ts +0 -211
- package/src/__tests__/preference-matcher.test.ts +0 -282
- package/src/__tests__/preference-slots.test.ts +0 -205
- package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
- package/src/__tests__/prospective-memory.test.ts +0 -283
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
- package/src/__tests__/query-entity-detector.test.ts +0 -171
- package/src/__tests__/query-expander-alias.test.ts +0 -146
- package/src/__tests__/query-expander.test.ts +0 -34
- package/src/__tests__/recall-gate.test.ts +0 -266
- package/src/__tests__/recall-governor.test.ts +0 -227
- package/src/__tests__/recall-mode.test.ts +0 -64
- package/src/__tests__/resolve-db-path.test.ts +0 -130
- package/src/__tests__/resolve-tier.test.ts +0 -64
- package/src/__tests__/result-synthesizer.test.ts +0 -198
- package/src/__tests__/resume-context.test.ts +0 -4898
- package/src/__tests__/retrieval-eval.test.ts +0 -163
- package/src/__tests__/retrieval-trace.test.ts +0 -213
- package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
- package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
- package/src/__tests__/retroactive-boost.test.ts +0 -223
- package/src/__tests__/salience-scoring.test.ts +0 -106
- package/src/__tests__/scope-inventory.test.ts +0 -200
- package/src/__tests__/scope-policy.test.ts +0 -69
- package/src/__tests__/scope-suggester.test.ts +0 -80
- package/src/__tests__/session-checkpoint.test.ts +0 -291
- package/src/__tests__/session-distiller.test.ts +0 -640
- package/src/__tests__/session-gc.test.ts +0 -215
- package/src/__tests__/skill-engine.test.ts +0 -552
- package/src/__tests__/skill-promotion.test.ts +0 -654
- package/src/__tests__/skill-verifier.test.ts +0 -122
- package/src/__tests__/source-heartbeat.test.ts +0 -254
- package/src/__tests__/stable-context-length-contract.test.ts +0 -62
- package/src/__tests__/store-patch-metadata.test.ts +0 -235
- package/src/__tests__/store-read-consistency.test.ts +0 -122
- package/src/__tests__/store-schema-migration.test.ts +0 -87
- package/src/__tests__/store-scope-audit.test.ts +0 -81
- package/src/__tests__/store-upsert.test.ts +0 -201
- package/src/__tests__/supersede-chain.test.ts +0 -154
- package/src/__tests__/synthesis-contract.test.ts +0 -252
- package/src/__tests__/synthesis-promotion.test.ts +0 -369
- package/src/__tests__/temporal-parser.test.ts +0 -241
- package/src/__tests__/temporal-validity.test.ts +0 -160
- package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
- package/src/__tests__/term-registry.test.ts +0 -121
- package/src/__tests__/tool-error-surfacing.test.ts +0 -98
- package/src/__tests__/tool-output-compressor.test.ts +0 -160
- package/src/__tests__/topic-tag.test.ts +0 -113
- package/src/__tests__/trusted-memory.test.ts +0 -867
- package/src/__tests__/ui-server-security.test.ts +0 -93
- package/src/__tests__/usage-tracker.test.ts +0 -287
- package/src/__tests__/utility-weight.test.ts +0 -57
- package/src/__tests__/verbatim-self-recall.test.ts +0 -279
- package/src/__tests__/version-manager.test.ts +0 -248
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
- package/src/__tests__/workflow-observation.test.ts +0 -433
- package/src/__tests__/write-verifier.test.ts +0 -122
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
scanMemoryPromotions,
|
|
5
|
-
type PromoteScanDeps,
|
|
6
|
-
type PromoteRequest,
|
|
7
|
-
} from "../memory-promotion.js";
|
|
8
|
-
import type { MemoryEntry } from "../store.js";
|
|
9
|
-
import type { StoredPromotedMemoryRecord } from "../memory-schema.js";
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Mock helpers
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
let idSeq = 0;
|
|
16
|
-
|
|
17
|
-
const ACTIVE_EVOLUTION = {
|
|
18
|
-
status: "active",
|
|
19
|
-
version: 1,
|
|
20
|
-
accessCount: 0,
|
|
21
|
-
lastAccessedAt: null,
|
|
22
|
-
supersededBy: null,
|
|
23
|
-
consolidatedInto: null,
|
|
24
|
-
sourceMemories: [],
|
|
25
|
-
validFrom: 1_700_000_000_000,
|
|
26
|
-
validUntil: null,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Build a transcript-downgraded evidence entry (category=events) with an EMPTY
|
|
31
|
-
* vector, faithfully reproducing store.list — which never returns vectors.
|
|
32
|
-
*/
|
|
33
|
-
function makeDowngradedEvent(
|
|
34
|
-
downgradedFrom: "profile" | "preferences",
|
|
35
|
-
overrides: Partial<MemoryEntry> = {},
|
|
36
|
-
): MemoryEntry {
|
|
37
|
-
idSeq += 1;
|
|
38
|
-
return {
|
|
39
|
-
id: `ev-${String(idSeq).padStart(4, "0")}`,
|
|
40
|
-
text: `${downgradedFrom} hint #${idSeq}`,
|
|
41
|
-
vector: [], // list() never returns vectors
|
|
42
|
-
category: "events",
|
|
43
|
-
scope: "cc:project:test",
|
|
44
|
-
importance: 0.7,
|
|
45
|
-
timestamp: 1_700_000_000_000 + idSeq,
|
|
46
|
-
metadata: JSON.stringify({
|
|
47
|
-
boundary: {
|
|
48
|
-
layer: "evidence",
|
|
49
|
-
authority: "transcript-ingest",
|
|
50
|
-
conflictPolicy: "append-only",
|
|
51
|
-
originalCategory: downgradedFrom,
|
|
52
|
-
downgradedFrom,
|
|
53
|
-
},
|
|
54
|
-
evolution: ACTIVE_EVOLUTION,
|
|
55
|
-
}),
|
|
56
|
-
...overrides,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface MockOptions {
|
|
61
|
-
/** Per-id vector overrides; ids absent from the map get no vector. */
|
|
62
|
-
vectorMap?: Map<string, number[]>;
|
|
63
|
-
/** Fallback vector handed to every listed id when vectorMap is omitted. */
|
|
64
|
-
defaultVector?: number[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function createScanDeps(entries: MemoryEntry[], options: MockOptions = {}) {
|
|
68
|
-
const promoteCalls: PromoteRequest[] = [];
|
|
69
|
-
const deps: PromoteScanDeps = {
|
|
70
|
-
store: {
|
|
71
|
-
async list(
|
|
72
|
-
_scopeFilter?: string[],
|
|
73
|
-
category?: string,
|
|
74
|
-
_limit?: number,
|
|
75
|
-
_offset?: number,
|
|
76
|
-
): Promise<MemoryEntry[]> {
|
|
77
|
-
return category ? entries.filter((e) => e.category === category) : entries;
|
|
78
|
-
},
|
|
79
|
-
async getVectors(ids: string[]): Promise<Map<string, number[]>> {
|
|
80
|
-
const map = new Map<string, number[]>();
|
|
81
|
-
for (const id of ids) {
|
|
82
|
-
if (options.vectorMap?.has(id)) {
|
|
83
|
-
map.set(id, options.vectorMap.get(id)!);
|
|
84
|
-
} else if (options.defaultVector && !options.vectorMap) {
|
|
85
|
-
map.set(id, options.defaultVector);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return map;
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
async promote(req: PromoteRequest): Promise<StoredPromotedMemoryRecord> {
|
|
92
|
-
promoteCalls.push(req);
|
|
93
|
-
return {
|
|
94
|
-
text: "promoted text",
|
|
95
|
-
category: req.category,
|
|
96
|
-
importance: req.importance,
|
|
97
|
-
scope: req.scope,
|
|
98
|
-
source: "agent",
|
|
99
|
-
tags: [],
|
|
100
|
-
canonicalKey: `${req.category}:k${promoteCalls.length}`,
|
|
101
|
-
id: `durable-${req.memoryId}`,
|
|
102
|
-
resolvedScope: req.scope,
|
|
103
|
-
storedAt: "2026-05-29T00:00:00.000Z",
|
|
104
|
-
disposition: "promoted",
|
|
105
|
-
sourceMemoryId: req.memoryId,
|
|
106
|
-
sourceCategory: "events",
|
|
107
|
-
};
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
return { deps, promoteCalls };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Tests
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
describe("scanMemoryPromotions", () => {
|
|
118
|
-
it("promotes a cluster once it has >= minOccurrences similar members", async () => {
|
|
119
|
-
const entries = [
|
|
120
|
-
makeDowngradedEvent("preferences"),
|
|
121
|
-
makeDowngradedEvent("preferences"),
|
|
122
|
-
makeDowngradedEvent("preferences"),
|
|
123
|
-
];
|
|
124
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
125
|
-
|
|
126
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
127
|
-
|
|
128
|
-
expect(result.candidates.length).toBe(1);
|
|
129
|
-
expect(result.candidates[0].occurrences).toBe(3);
|
|
130
|
-
expect(result.candidates[0].downgradedFrom).toBe("preferences");
|
|
131
|
-
expect(result.promoted).toBe(1);
|
|
132
|
-
expect(promoteCalls.length).toBe(1);
|
|
133
|
-
expect(promoteCalls[0].category).toBe("preferences");
|
|
134
|
-
expect(promoteCalls[0].scope).toBe("cc:project:test");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("does not promote when occurrences are below minOccurrences", async () => {
|
|
138
|
-
const entries = [makeDowngradedEvent("preferences"), makeDowngradedEvent("preferences")];
|
|
139
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
140
|
-
|
|
141
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
142
|
-
|
|
143
|
-
expect(result.candidates.length).toBe(0);
|
|
144
|
-
expect(promoteCalls.length).toBe(0);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("does not promote a cluster whose average importance is below minImportance", async () => {
|
|
148
|
-
const entries = [
|
|
149
|
-
makeDowngradedEvent("preferences", { importance: 0.4 }),
|
|
150
|
-
makeDowngradedEvent("preferences", { importance: 0.4 }),
|
|
151
|
-
makeDowngradedEvent("preferences", { importance: 0.4 }),
|
|
152
|
-
];
|
|
153
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
154
|
-
|
|
155
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
156
|
-
|
|
157
|
-
expect(result.candidates.length).toBe(0);
|
|
158
|
-
expect(promoteCalls.length).toBe(0);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("promotes with the target category equal to downgradedFrom (profile)", async () => {
|
|
162
|
-
const entries = [
|
|
163
|
-
makeDowngradedEvent("profile"),
|
|
164
|
-
makeDowngradedEvent("profile"),
|
|
165
|
-
makeDowngradedEvent("profile"),
|
|
166
|
-
];
|
|
167
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
168
|
-
|
|
169
|
-
await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
170
|
-
|
|
171
|
-
expect(promoteCalls.length).toBe(1);
|
|
172
|
-
expect(promoteCalls[0].category).toBe("profile");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("finds candidates but does NOT call promote in dryRun mode", async () => {
|
|
176
|
-
const entries = [
|
|
177
|
-
makeDowngradedEvent("preferences"),
|
|
178
|
-
makeDowngradedEvent("preferences"),
|
|
179
|
-
makeDowngradedEvent("preferences"),
|
|
180
|
-
];
|
|
181
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
182
|
-
|
|
183
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: true });
|
|
184
|
-
|
|
185
|
-
expect(result.candidates.length).toBe(1);
|
|
186
|
-
expect(result.dryRun).toBe(true);
|
|
187
|
-
expect(result.promoted).toBe(0);
|
|
188
|
-
expect(result.candidates[0].promoted).toBeNull();
|
|
189
|
-
expect(promoteCalls.length).toBe(0);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("keeps profile and preferences as separate candidates with their own category", async () => {
|
|
193
|
-
const entries = [
|
|
194
|
-
makeDowngradedEvent("preferences"),
|
|
195
|
-
makeDowngradedEvent("preferences"),
|
|
196
|
-
makeDowngradedEvent("preferences"),
|
|
197
|
-
makeDowngradedEvent("profile"),
|
|
198
|
-
makeDowngradedEvent("profile"),
|
|
199
|
-
makeDowngradedEvent("profile"),
|
|
200
|
-
];
|
|
201
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
202
|
-
|
|
203
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
204
|
-
|
|
205
|
-
expect(result.candidates.length).toBe(2);
|
|
206
|
-
expect(promoteCalls.length).toBe(2);
|
|
207
|
-
expect(promoteCalls.map((c) => c.category).sort()).toEqual(["preferences", "profile"]);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("skips entries that have no vector available", async () => {
|
|
211
|
-
const entries = [
|
|
212
|
-
makeDowngradedEvent("preferences"),
|
|
213
|
-
makeDowngradedEvent("preferences"),
|
|
214
|
-
makeDowngradedEvent("preferences"),
|
|
215
|
-
];
|
|
216
|
-
// Only 2 of 3 receive vectors -> the cluster has 2 members < minOccurrences.
|
|
217
|
-
const vectorMap = new Map<string, number[]>();
|
|
218
|
-
vectorMap.set(entries[0].id, [1, 0, 0]);
|
|
219
|
-
vectorMap.set(entries[1].id, [1, 0, 0]);
|
|
220
|
-
const { deps, promoteCalls } = createScanDeps(entries, { vectorMap });
|
|
221
|
-
|
|
222
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
223
|
-
|
|
224
|
-
expect(result.candidates.length).toBe(0);
|
|
225
|
-
expect(promoteCalls.length).toBe(0);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("ignores events that are not downgraded from profile/preferences", async () => {
|
|
229
|
-
const plain = (): MemoryEntry =>
|
|
230
|
-
makeDowngradedEvent("preferences", {
|
|
231
|
-
metadata: JSON.stringify({
|
|
232
|
-
boundary: {
|
|
233
|
-
layer: "evidence",
|
|
234
|
-
authority: "transcript-ingest",
|
|
235
|
-
conflictPolicy: "append-only",
|
|
236
|
-
},
|
|
237
|
-
evolution: ACTIVE_EVOLUTION,
|
|
238
|
-
}),
|
|
239
|
-
});
|
|
240
|
-
const { deps, promoteCalls } = createScanDeps([plain(), plain(), plain()], {
|
|
241
|
-
defaultVector: [1, 0, 0],
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
245
|
-
|
|
246
|
-
expect(result.candidates.length).toBe(0);
|
|
247
|
-
expect(promoteCalls.length).toBe(0);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("skips evidence already promoted (promotedTo marker present)", async () => {
|
|
251
|
-
const promotedAlready = (): MemoryEntry =>
|
|
252
|
-
makeDowngradedEvent("preferences", {
|
|
253
|
-
metadata: JSON.stringify({
|
|
254
|
-
boundary: {
|
|
255
|
-
layer: "evidence",
|
|
256
|
-
authority: "transcript-ingest",
|
|
257
|
-
conflictPolicy: "append-only",
|
|
258
|
-
originalCategory: "preferences",
|
|
259
|
-
downgradedFrom: "preferences",
|
|
260
|
-
},
|
|
261
|
-
promotedTo: "existing-durable-id",
|
|
262
|
-
evolution: ACTIVE_EVOLUTION,
|
|
263
|
-
}),
|
|
264
|
-
});
|
|
265
|
-
const { deps, promoteCalls } = createScanDeps(
|
|
266
|
-
[promotedAlready(), promotedAlready(), promotedAlready()],
|
|
267
|
-
{ defaultVector: [1, 0, 0] },
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
271
|
-
|
|
272
|
-
expect(result.candidates.length).toBe(0);
|
|
273
|
-
expect(promoteCalls.length).toBe(0);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("excludes inactive (superseded) evidence", async () => {
|
|
277
|
-
const superseded = (): MemoryEntry =>
|
|
278
|
-
makeDowngradedEvent("preferences", {
|
|
279
|
-
metadata: JSON.stringify({
|
|
280
|
-
boundary: {
|
|
281
|
-
layer: "evidence",
|
|
282
|
-
authority: "transcript-ingest",
|
|
283
|
-
conflictPolicy: "append-only",
|
|
284
|
-
originalCategory: "preferences",
|
|
285
|
-
downgradedFrom: "preferences",
|
|
286
|
-
},
|
|
287
|
-
evolution: { ...ACTIVE_EVOLUTION, status: "superseded", supersededBy: "x" },
|
|
288
|
-
}),
|
|
289
|
-
});
|
|
290
|
-
const { deps, promoteCalls } = createScanDeps(
|
|
291
|
-
[superseded(), superseded(), superseded()],
|
|
292
|
-
{ defaultVector: [1, 0, 0] },
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
296
|
-
|
|
297
|
-
expect(result.candidates.length).toBe(0);
|
|
298
|
-
expect(promoteCalls.length).toBe(0);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("excludes evidence whose text is a bridge-injected prompt (noise)", async () => {
|
|
302
|
-
const promptText = "# CC Reply Prompt\n\n你是 CC。你不是 Alice 的数字分身,也不是 E 化镜像。";
|
|
303
|
-
const entries = [
|
|
304
|
-
makeDowngradedEvent("preferences", { text: promptText }),
|
|
305
|
-
makeDowngradedEvent("preferences", { text: promptText }),
|
|
306
|
-
makeDowngradedEvent("preferences", { text: promptText }),
|
|
307
|
-
];
|
|
308
|
-
const { deps, promoteCalls } = createScanDeps(entries, { defaultVector: [1, 0, 0] });
|
|
309
|
-
|
|
310
|
-
const result = await scanMemoryPromotions(deps, "cc:project:test", { dryRun: false });
|
|
311
|
-
|
|
312
|
-
expect(result.candidates.length).toBe(0);
|
|
313
|
-
expect(promoteCalls.length).toBe(0);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, beforeEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
applyUtilityToMeta,
|
|
8
|
-
computeMemoryUtility,
|
|
9
|
-
formatMemoryUtility,
|
|
10
|
-
JOIN_WEIGHT,
|
|
11
|
-
linkMemoryToObservations,
|
|
12
|
-
OUTCOME_REWARD,
|
|
13
|
-
readUtility,
|
|
14
|
-
summarizeMemoryOutcomes,
|
|
15
|
-
UTILITY_FIELD,
|
|
16
|
-
} from "../memory-utility.js";
|
|
17
|
-
import { buildWorkflowObservationRecord } from "../workflow-observation-engine.js";
|
|
18
|
-
import { WorkflowObservationStore } from "../workflow-observation-store.js";
|
|
19
|
-
import {
|
|
20
|
-
PROCESS_READER_ID,
|
|
21
|
-
RECALLED_IDS_CAP,
|
|
22
|
-
recallLedgerSize,
|
|
23
|
-
recentRecallHits,
|
|
24
|
-
recordRecallHits,
|
|
25
|
-
resetRecallLedger,
|
|
26
|
-
} from "../recall-ledger.js";
|
|
27
|
-
import { AccessTracker } from "../access-tracker.js";
|
|
28
|
-
import { isDecayExempt, resolveTier } from "../decay-engine.js";
|
|
29
|
-
|
|
30
|
-
const MEM_A = "aaaaaaaa-0000-4000-8000-000000000001";
|
|
31
|
-
const MEM_B = "bbbbbbbb-0000-4000-8000-000000000002";
|
|
32
|
-
|
|
33
|
-
function obs(overrides: Record<string, unknown>) {
|
|
34
|
-
return buildWorkflowObservationRecord({
|
|
35
|
-
workflowId: "resume_context",
|
|
36
|
-
summary: "test observation",
|
|
37
|
-
...overrides,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe("recall ledger (P0 join key)", () => {
|
|
42
|
-
beforeEach(() => resetRecallLedger());
|
|
43
|
-
|
|
44
|
-
it("records hits and returns them newest-first within the window", () => {
|
|
45
|
-
const t0 = 1_000_000;
|
|
46
|
-
recordRecallHits([MEM_A], t0);
|
|
47
|
-
recordRecallHits([MEM_B], t0 + 1000);
|
|
48
|
-
expect(recentRecallHits(60_000, 10, t0 + 2000)).toEqual([MEM_B, MEM_A]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("drops hits older than the window", () => {
|
|
52
|
-
const t0 = 1_000_000;
|
|
53
|
-
recordRecallHits([MEM_A], t0);
|
|
54
|
-
recordRecallHits([MEM_B], t0 + 60 * 60_000);
|
|
55
|
-
expect(recentRecallHits(30 * 60_000, 10, t0 + 60 * 60_000)).toEqual([MEM_B]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("caps the returned list", () => {
|
|
59
|
-
const ids = Array.from({ length: RECALLED_IDS_CAP + 20 }, (_, i) => `id-${i}`);
|
|
60
|
-
recordRecallHits(ids, 5_000);
|
|
61
|
-
expect(recentRecallHits(60_000, RECALLED_IDS_CAP, 5_000).length).toBe(RECALLED_IDS_CAP);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("gives every AccessTracker the same process-level reader id", () => {
|
|
65
|
-
// 回归防线:readerId 曾是 per-instance 随机的,而 components 按 profile 缓存 —— 同一个
|
|
66
|
-
// 会话开两个 profile 就伪造出两个 reader,join 直接失效。
|
|
67
|
-
const fakeStore = {} as never;
|
|
68
|
-
expect(new AccessTracker(fakeStore).readerId).toBe(PROCESS_READER_ID);
|
|
69
|
-
expect(new AccessTracker(fakeStore).readerId).toBe(new AccessTracker(fakeStore).readerId);
|
|
70
|
-
expect(new AccessTracker(fakeStore, undefined, "r-explicit").readerId).toBe("r-explicit");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("records into the ledger before the novelty/cooldown gate", () => {
|
|
74
|
-
// 高相似度 + 严格 novelty 闸:强化会被挡下,但 agent 确实看到了这条 —— 账本必须记。
|
|
75
|
-
const fakeStore = {} as never;
|
|
76
|
-
const tracker = new AccessTracker(fakeStore, {
|
|
77
|
-
flushIntervalMs: 5000,
|
|
78
|
-
reinforcementFactor: 0.5,
|
|
79
|
-
maxMultiplier: 3,
|
|
80
|
-
accessFreshnessHalfLifeDays: 30,
|
|
81
|
-
noveltyThreshold: 0.9,
|
|
82
|
-
cooldownMs: 0,
|
|
83
|
-
});
|
|
84
|
-
tracker.recordAccess([MEM_A], [0.99]);
|
|
85
|
-
expect(tracker.pendingCount).toBe(0);
|
|
86
|
-
expect(recentRecallHits()).toContain(MEM_A);
|
|
87
|
-
tracker.destroy();
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe("memory ↔ observation join", () => {
|
|
92
|
-
it("joins exactly when the observation names the memory", () => {
|
|
93
|
-
const links = linkMemoryToObservations(MEM_A, "{}", [
|
|
94
|
-
obs({ recalledIds: [MEM_A, MEM_B], outcome: "success" }),
|
|
95
|
-
obs({ recalledIds: [MEM_B], outcome: "failure" }),
|
|
96
|
-
]);
|
|
97
|
-
expect(links.length).toBe(1);
|
|
98
|
-
expect(links[0].join).toBe("exact");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("falls back to session join through readerId ∩ metadata.readerIds", () => {
|
|
102
|
-
const meta = JSON.stringify({ readerIds: ["r-1234abcd"] });
|
|
103
|
-
const links = linkMemoryToObservations(MEM_A, meta, [
|
|
104
|
-
obs({ readerId: "r-1234abcd", outcome: "corrected" }),
|
|
105
|
-
obs({ readerId: "r-otherxx", outcome: "failure" }),
|
|
106
|
-
]);
|
|
107
|
-
expect(links.length).toBe(1);
|
|
108
|
-
expect(links[0].join).toBe("session");
|
|
109
|
-
expect(links[0].outcome).toBe("corrected");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("prefers exact over session for the same observation", () => {
|
|
113
|
-
const meta = JSON.stringify({ readerIds: ["r-1234abcd"] });
|
|
114
|
-
const links = linkMemoryToObservations(MEM_A, meta, [
|
|
115
|
-
obs({ readerId: "r-1234abcd", recalledIds: [MEM_A], outcome: "success" }),
|
|
116
|
-
]);
|
|
117
|
-
expect(links.length).toBe(1);
|
|
118
|
-
expect(links[0].join).toBe("exact");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("returns nothing when neither key is present (the pre-P0 state)", () => {
|
|
122
|
-
expect(linkMemoryToObservations(MEM_A, "{}", [obs({ outcome: "success" })])).toEqual([]);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe("utility computation", () => {
|
|
127
|
-
const now = Date.parse("2026-08-23T00:00:00.000Z");
|
|
128
|
-
const at = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
|
|
129
|
-
|
|
130
|
-
it("answers 「这条 memory 参与过的任务成功率是多少」", () => {
|
|
131
|
-
const summary = summarizeMemoryOutcomes(MEM_A, [
|
|
132
|
-
{ observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(1), join: "exact", summary: "" },
|
|
133
|
-
{ observationId: "o2", workflowId: "w", outcome: "success", recordedAt: at(2), join: "exact", summary: "" },
|
|
134
|
-
{ observationId: "o3", workflowId: "w", outcome: "failure", recordedAt: at(3), join: "exact", summary: "" },
|
|
135
|
-
], now);
|
|
136
|
-
expect(summary.total).toBe(3);
|
|
137
|
-
expect(summary.successRate).toBeCloseTo(2 / 3, 5);
|
|
138
|
-
expect(summary.exactSuccessRate).toBeCloseTo(2 / 3, 5);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("goes negative when the tasks mostly failed — importance could never hold this", () => {
|
|
142
|
-
const summary = summarizeMemoryOutcomes(MEM_A, [
|
|
143
|
-
{ observationId: "o1", workflowId: "w", outcome: "failure", recordedAt: at(0), join: "exact", summary: "" },
|
|
144
|
-
{ observationId: "o2", workflowId: "w", outcome: "failure", recordedAt: at(1), join: "exact", summary: "" },
|
|
145
|
-
{ observationId: "o3", workflowId: "w", outcome: "missed", recordedAt: at(2), join: "exact", summary: "" },
|
|
146
|
-
], now);
|
|
147
|
-
expect(summary.utility).not.toBeNull();
|
|
148
|
-
expect(summary.utility!).toBeLessThan(0);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("weights session joins below exact ones", () => {
|
|
152
|
-
const exact = summarizeMemoryOutcomes(MEM_A, [
|
|
153
|
-
{ observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "exact", summary: "" },
|
|
154
|
-
], now);
|
|
155
|
-
const session = summarizeMemoryOutcomes(MEM_A, [
|
|
156
|
-
{ observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "session", summary: "" },
|
|
157
|
-
], now);
|
|
158
|
-
expect(exact.effectiveSamples).toBeCloseTo(JOIN_WEIGHT.exact, 5);
|
|
159
|
-
expect(session.effectiveSamples).toBeCloseTo(JOIN_WEIGHT.session, 5);
|
|
160
|
-
// 同一个 outcome,加权平均的值一样,可信度不同 —— 差别落在 effectiveSamples 上。
|
|
161
|
-
expect(exact.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("decays old outcomes by the 30-day half-life", () => {
|
|
165
|
-
const fresh = summarizeMemoryOutcomes(MEM_A, [
|
|
166
|
-
{ observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(0), join: "exact", summary: "" },
|
|
167
|
-
], now);
|
|
168
|
-
const old = summarizeMemoryOutcomes(MEM_A, [
|
|
169
|
-
{ observationId: "o1", workflowId: "w", outcome: "success", recordedAt: at(30), join: "exact", summary: "" },
|
|
170
|
-
], now);
|
|
171
|
-
expect(old.effectiveSamples).toBeCloseTo(fresh.effectiveSamples * 0.5, 3);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("ignores session-join noise once exact samples exist", () => {
|
|
175
|
-
// 2026-08-23 落地当天的实测:同一会话跑多个任务时,readerId join 会把无关任务的
|
|
176
|
-
// 成败摊到每条被读过的记忆上——那次 5 条记忆的会话级成功率全是 44%(常数,零信息),
|
|
177
|
-
// 精确成功率却是 100%/33%/0%。所以有精确样本时不掺会话级。
|
|
178
|
-
const links = [
|
|
179
|
-
{ observationId: "e1", workflowId: "w", outcome: "success" as const, recordedAt: at(0), join: "exact" as const, summary: "" },
|
|
180
|
-
{ observationId: "e2", workflowId: "w", outcome: "success" as const, recordedAt: at(0), join: "exact" as const, summary: "" },
|
|
181
|
-
{ observationId: "s1", workflowId: "w", outcome: "failure" as const, recordedAt: at(0), join: "session" as const, summary: "" },
|
|
182
|
-
{ observationId: "s2", workflowId: "w", outcome: "failure" as const, recordedAt: at(0), join: "session" as const, summary: "" },
|
|
183
|
-
];
|
|
184
|
-
const preferred = summarizeMemoryOutcomes(MEM_A, links, now);
|
|
185
|
-
expect(preferred.utilityBasis).toBe("exact");
|
|
186
|
-
expect(preferred.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
|
|
187
|
-
|
|
188
|
-
const mixed = summarizeMemoryOutcomes(MEM_A, links, now, { preferExact: false });
|
|
189
|
-
expect(mixed.utility!).toBeLessThan(preferred.utility!);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("falls back to session join when nothing named this memory", () => {
|
|
193
|
-
const links = Array.from({ length: 4 }, (_, i) => ({
|
|
194
|
-
observationId: `s${i}`, workflowId: "w", outcome: "success" as const,
|
|
195
|
-
recordedAt: at(0), join: "session" as const, summary: "",
|
|
196
|
-
}));
|
|
197
|
-
const summary = summarizeMemoryOutcomes(MEM_A, links, now);
|
|
198
|
-
expect(summary.utilityBasis).toBe("session");
|
|
199
|
-
expect(summary.utility).toBeCloseTo(OUTCOME_REWARD.success, 5);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("returns null (not 0) when there is no evidence", () => {
|
|
203
|
-
const summary = summarizeMemoryOutcomes(MEM_A, [], now);
|
|
204
|
-
expect(summary.utility).toBeNull();
|
|
205
|
-
expect(summary.successRate).toBeNull();
|
|
206
|
-
expect(summary.utilityBasis).toBe("none");
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe("utility metadata write-back", () => {
|
|
211
|
-
it("writes utility without touching importance-derived behaviour", () => {
|
|
212
|
-
const summary = summarizeMemoryOutcomes(MEM_A, [
|
|
213
|
-
{ observationId: "o1", workflowId: "w", outcome: "failure", recordedAt: new Date().toISOString(), join: "exact", summary: "" },
|
|
214
|
-
]);
|
|
215
|
-
const before = { tier: "peripheral", accessCount: 2 };
|
|
216
|
-
const after = applyUtilityToMeta(before, summary);
|
|
217
|
-
expect(after[UTILITY_FIELD]).toBeLessThan(0);
|
|
218
|
-
// 硬约束:utility 不进 tier、不进 decay 豁免。
|
|
219
|
-
expect(resolveTier(JSON.stringify(after), 0.7)).toBe(resolveTier(JSON.stringify(before), 0.7));
|
|
220
|
-
expect(isDecayExempt(JSON.stringify(after), 0.7, "cases"))
|
|
221
|
-
.toBe(isDecayExempt(JSON.stringify(before), 0.7, "cases"));
|
|
222
|
-
expect((after as Record<string, unknown>).importance).toBeUndefined();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("clears utility instead of writing 0 when the evidence is gone", () => {
|
|
226
|
-
const meta = { utility: -0.5, utilitySamples: 1, utilityUpdatedAt: 1 };
|
|
227
|
-
const cleared = applyUtilityToMeta(meta, summarizeMemoryOutcomes(MEM_A, []));
|
|
228
|
-
expect(UTILITY_FIELD in cleared).toBe(false);
|
|
229
|
-
expect(readUtility(JSON.stringify(cleared))).toBeNull();
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe("end-to-end through the observation store", () => {
|
|
234
|
-
it("round-trips join keys and answers the success-rate query", async () => {
|
|
235
|
-
const dir = mkdtempSync(join(tmpdir(), "recallnest-utility-obs-"));
|
|
236
|
-
const store = new WorkflowObservationStore(dir);
|
|
237
|
-
await store.save(obs({ recalledIds: [MEM_A], outcome: "success", readerId: "r-sess0001" }));
|
|
238
|
-
await store.save(obs({ recalledIds: [MEM_A], outcome: "failure", readerId: "r-sess0001", workflowId: "checkpoint_session" }));
|
|
239
|
-
await store.save(obs({ recalledIds: [MEM_B], outcome: "success", readerId: "r-sess0001" }));
|
|
240
|
-
|
|
241
|
-
const records = await store.listRecent({ limit: 100 });
|
|
242
|
-
const summary = computeMemoryUtility({ id: MEM_A, metadata: "{}" }, records);
|
|
243
|
-
expect(summary.total).toBe(2);
|
|
244
|
-
expect(summary.successRate).toBeCloseTo(0.5, 5);
|
|
245
|
-
expect(formatMemoryUtility(summary, { showLinks: 2 })).toContain("成功率");
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("still parses observations written before the join-key fields existed", async () => {
|
|
249
|
-
const dir = mkdtempSync(join(tmpdir(), "recallnest-utility-legacy-"));
|
|
250
|
-
const store = new WorkflowObservationStore(dir);
|
|
251
|
-
const legacy = obs({ outcome: "success" });
|
|
252
|
-
expect((legacy as Record<string, unknown>).recalledIds).toBeUndefined();
|
|
253
|
-
await store.save(legacy);
|
|
254
|
-
expect((await store.listRecent({ limit: 10 })).length).toBe(1);
|
|
255
|
-
});
|
|
256
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { planDedup, type DedupRow } from "../../scripts/migrate-dedup-ids.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* P0-2 迁移脚本核心逻辑回归。LanceDB I/O 部分(main)由「在备份副本上实跑」做集成验证;
|
|
7
|
-
* 这里只测纯 keeper 选择:同 id 保留最新 timestamp(importance 破平)、每 id 唯一、不丢文本。
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
function row(id: string, ts: number, importance: number, text: string): DedupRow {
|
|
11
|
-
return { id, timestamp: ts, importance, text, vector: [0, 0, 0], scope: "s", category: "entities", metadata: "{}" };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("planDedup (dup-id migration keeper selection)", () => {
|
|
15
|
-
it("keeps the latest-timestamp row per dup id and counts extras", () => {
|
|
16
|
-
const rows = [
|
|
17
|
-
row("dup1", 100, 0.5, "A"),
|
|
18
|
-
row("dup1", 300, 0.6, "A"), // latest ts → keeper
|
|
19
|
-
row("dup1", 200, 0.9, "A"),
|
|
20
|
-
row("uniq", 150, 0.5, "B"),
|
|
21
|
-
];
|
|
22
|
-
const plan = planDedup(rows);
|
|
23
|
-
expect(plan.dupIds).toEqual(["dup1"]);
|
|
24
|
-
expect(plan.extraRows).toBe(2);
|
|
25
|
-
expect(plan.keepers.length).toBe(1);
|
|
26
|
-
expect(Number(plan.keepers[0].timestamp)).toBe(300);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("breaks timestamp ties by highest importance", () => {
|
|
30
|
-
const rows = [
|
|
31
|
-
row("d", 500, 0.4, "X"),
|
|
32
|
-
row("d", 500, 0.95, "X"), // same ts, higher importance → keeper
|
|
33
|
-
row("d", 500, 0.7, "X"),
|
|
34
|
-
];
|
|
35
|
-
const plan = planDedup(rows);
|
|
36
|
-
expect(plan.keepers.length).toBe(1);
|
|
37
|
-
expect(plan.keepers[0].importance).toBe(0.95);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("leaves unique ids untouched and preserves every distinct id's text", () => {
|
|
41
|
-
const rows = [
|
|
42
|
-
row("a", 1, 0.5, "textA"),
|
|
43
|
-
row("a", 2, 0.5, "textA"), // dup
|
|
44
|
-
row("b", 1, 0.5, "textB"), // unique
|
|
45
|
-
row("c", 1, 0.5, "textC"),
|
|
46
|
-
row("c", 5, 0.5, "textC"), // dup
|
|
47
|
-
];
|
|
48
|
-
const plan = planDedup(rows);
|
|
49
|
-
const dupSet = new Set(plan.dupIds);
|
|
50
|
-
// Simulate the migration result: untouched unique-id rows + one keeper per dup id.
|
|
51
|
-
const survivors = [...rows.filter((r) => !dupSet.has(r.id)), ...plan.keepers];
|
|
52
|
-
const ids = survivors.map((r) => r.id).sort();
|
|
53
|
-
expect(ids).toEqual(["a", "b", "c"]); // every distinct id survives
|
|
54
|
-
expect(new Set(ids).size).toBe(ids.length); // each id appears exactly once
|
|
55
|
-
expect(survivors.find((r) => r.id === "a")!.text).toBe("textA");
|
|
56
|
-
expect(survivors.find((r) => r.id === "c")!.text).toBe("textC");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("returns an empty plan when there are no duplicates", () => {
|
|
60
|
-
const plan = planDedup([row("x", 1, 0.5, "1"), row("y", 2, 0.5, "2")]);
|
|
61
|
-
expect(plan.dupIds).toEqual([]);
|
|
62
|
-
expect(plan.extraRows).toBe(0);
|
|
63
|
-
expect(plan.keepers).toEqual([]);
|
|
64
|
-
});
|
|
65
|
-
});
|