recallnest 3.0.0 → 4.0.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.
- package/CHANGELOG.md +304 -0
- package/README.md +251 -258
- package/README_CN.md +226 -247
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +12 -3
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +459 -71
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer-stable.ts +5 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/embedder.ts +24 -0
- package/src/env-config.ts +136 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +294 -121
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-extractor.ts +17 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +311 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +55 -5
- package/src/pivot-evidence.ts +128 -0
- package/src/ppr-traversal.ts +25 -1
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +15 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-engine.ts +11 -4
- package/src/session-output.ts +20 -1
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +59 -12
- package/src/skill-promotion.ts +67 -1
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
- 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,252 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
CLUSTER_INSIGHT_PROMPT,
|
|
5
|
-
CLUSTER_PATTERN_PROMPT,
|
|
6
|
-
PROMPT_ECHO_MARKERS,
|
|
7
|
-
SYNTHESIS_MAX_LEN,
|
|
8
|
-
allocateClusterBudget,
|
|
9
|
-
numberClusterTexts,
|
|
10
|
-
parseSynthesisJson,
|
|
11
|
-
validateSynthesis,
|
|
12
|
-
} from "../synthesis-contract.js";
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// 输入预算分配(R3:老路径 slice(0,2000) 让 64.2% 的簇被截,p10 只有 13.8% 的源进得了模型)
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
describe("allocateClusterBudget", () => {
|
|
19
|
-
it("总量不超预算", () => {
|
|
20
|
-
const texts = Array.from({ length: 20 }, () => "x".repeat(5000));
|
|
21
|
-
const out = allocateClusterBudget(texts, 1000);
|
|
22
|
-
expect(out.reduce((s, t) => s + t.length, 0)).toBeLessThanOrEqual(1000);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("短成员整条保留,长成员才被截", () => {
|
|
26
|
-
const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
|
|
27
|
-
expect(out[0]).toBe("短");
|
|
28
|
-
expect(out[1].length).toBeLessThan(999);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("短成员用不完的份额让给长成员,而不是浪费掉", () => {
|
|
32
|
-
// 均分是 50/50;第一条只用 1 个字符,第二条应拿到远超 50 的份额。
|
|
33
|
-
const out = allocateClusterBudget(["短", "y".repeat(999)], 100);
|
|
34
|
-
expect(out[1].length).toBeGreaterThan(50);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("靠后的成员不会被整条丢掉——这正是老 slice(0,2000) 的失效形态", () => {
|
|
38
|
-
// 老路径把 168 条拼成一条长文本再从头切,排在后面的成员一个字都进不了模型。
|
|
39
|
-
const texts = Array.from({ length: 50 }, (_, i) => `第${i}条`.repeat(200));
|
|
40
|
-
const out = allocateClusterBudget(texts, 2000);
|
|
41
|
-
expect(out.length).toBe(50);
|
|
42
|
-
expect(out.every(t => t.length > 0)).toBe(true);
|
|
43
|
-
// 最后一条必须真的携带自己的内容,而不是空壳
|
|
44
|
-
expect(out[49]).toContain("第49条");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("空簇返回空数组", () => {
|
|
48
|
-
expect(allocateClusterBudget([], 100)).toEqual([]);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("numberClusterTexts", () => {
|
|
53
|
-
it("编号从 1 开始,与 evidence 的语义一致", () => {
|
|
54
|
-
expect(numberClusterTexts(["a", "b"])).toBe("[1] a\n[2] b");
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// JSON 解析
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
describe("parseSynthesisJson", () => {
|
|
63
|
-
it("裸 JSON", () => {
|
|
64
|
-
expect(parseSynthesisJson('{"has":false}')).toEqual({ has: false });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("markdown 围栏", () => {
|
|
68
|
-
expect(parseSynthesisJson('```json\n{"has":false}\n```')).toEqual({ has: false });
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("带前言的回复也能抠出对象", () => {
|
|
72
|
-
expect(parseSynthesisJson('好的,结果如下:\n{"has":false}\n以上。')).toEqual({ has: false });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("拿不到 JSON 时返回 null 而不是抛异常", () => {
|
|
76
|
-
expect(parseSynthesisJson("完全不是 JSON")).toBeNull();
|
|
77
|
-
expect(parseSynthesisJson(null)).toBeNull();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("顶层是数组不算合法(契约要的是对象)", () => {
|
|
81
|
-
expect(parseSynthesisJson("[1,2,3]")).toBeNull();
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
// 写库前校验 —— 每条 reject 分支都要有一条测试撑着,
|
|
87
|
-
// 因为这一层的全部意义就是「LLM 说了什么不算数」。
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
const base = { textField: "conclusion", memberCount: 5, minEvidence: 1 };
|
|
91
|
-
|
|
92
|
-
describe("validateSynthesis", () => {
|
|
93
|
-
it("合格输出:去重 + 升序", () => {
|
|
94
|
-
const v = validateSynthesis({
|
|
95
|
-
...base,
|
|
96
|
-
parsed: { has: true, conclusion: "必须先初始化再分派,否则读到的是初始化前的值", evidence: [3, 1, 3] },
|
|
97
|
-
});
|
|
98
|
-
expect(v.status).toBe("ok");
|
|
99
|
-
if (v.status === "ok") {
|
|
100
|
-
expect(v.output.evidence).toEqual([1, 3]);
|
|
101
|
-
expect(v.output.text).toContain("必须先初始化");
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("has:false 是弃权,不是失败——这条路径老 generateL0 根本没有", () => {
|
|
106
|
-
expect(validateSynthesis({ ...base, parsed: { has: false } }).status).toBe("abstained");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("解析不出 JSON → unparsable", () => {
|
|
110
|
-
const v = validateSynthesis({ ...base, parsed: null });
|
|
111
|
-
expect(v).toEqual({ status: "rejected", reason: "unparsable" });
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("has 不是 boolean → 拒绝(不猜它的意图)", () => {
|
|
115
|
-
for (const has of ["true", 1, null, undefined]) {
|
|
116
|
-
const v = validateSynthesis({ ...base, parsed: { has, conclusion: "随便什么", evidence: [1] } });
|
|
117
|
-
expect(v).toEqual({ status: "rejected", reason: "flag-not-boolean" });
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("说有结论却没给正文 → empty-text", () => {
|
|
122
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, evidence: [1] } }))
|
|
123
|
-
.toEqual({ status: "rejected", reason: "empty-text" });
|
|
124
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: " ", evidence: [1] } }))
|
|
125
|
-
.toEqual({ status: "rejected", reason: "empty-text" });
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("长度越界两侧都拒", () => {
|
|
129
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "太短", evidence: [1] } }))
|
|
130
|
-
.toEqual({ status: "rejected", reason: "too-short" });
|
|
131
|
-
expect(validateSynthesis({
|
|
132
|
-
...base,
|
|
133
|
-
parsed: { has: true, conclusion: "长".repeat(SYNTHESIS_MAX_LEN + 1), evidence: [1] },
|
|
134
|
-
})).toEqual({ status: "rejected", reason: "too-long" });
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("evidence 缺失或不是数组 → evidence-missing", () => {
|
|
138
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本" } }))
|
|
139
|
-
.toEqual({ status: "rejected", reason: "evidence-missing" });
|
|
140
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: "1,2" } }))
|
|
141
|
-
.toEqual({ status: "rejected", reason: "evidence-missing" });
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("编号全部越界 → evidence-out-of-range(模型在编号,不是证据不够)", () => {
|
|
145
|
-
const v = validateSynthesis({
|
|
146
|
-
...base,
|
|
147
|
-
parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [99, 0, -1] },
|
|
148
|
-
});
|
|
149
|
-
expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("pattern 侧只给 1 个合法编号 → evidence-too-few(跨条目按定义要 2 条)", () => {
|
|
153
|
-
const v = validateSynthesis({
|
|
154
|
-
parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2] },
|
|
155
|
-
textField: "pattern",
|
|
156
|
-
memberCount: 5,
|
|
157
|
-
minEvidence: 2,
|
|
158
|
-
});
|
|
159
|
-
expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("重复编号不算两条证据([2,2] 去重后只剩 1 个)", () => {
|
|
163
|
-
const v = validateSynthesis({
|
|
164
|
-
parsed: { has: true, pattern: "一条长度足够越过下限的跨条目结论文本", evidence: [2, 2] },
|
|
165
|
-
textField: "pattern",
|
|
166
|
-
memberCount: 5,
|
|
167
|
-
minEvidence: 2,
|
|
168
|
-
});
|
|
169
|
-
expect(v).toEqual({ status: "rejected", reason: "evidence-too-few" });
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("数字字符串编号可接受(模型常把编号写成字符串)", () => {
|
|
173
|
-
const v = validateSynthesis({
|
|
174
|
-
...base,
|
|
175
|
-
parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: ["2", 3] },
|
|
176
|
-
});
|
|
177
|
-
expect(v.status).toBe("ok");
|
|
178
|
-
if (v.status === "ok") expect(v.output.evidence).toEqual([2, 3]);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("非整数编号不算合法", () => {
|
|
182
|
-
const v = validateSynthesis({
|
|
183
|
-
...base,
|
|
184
|
-
parsed: { has: true, conclusion: "一条长度足够越过下限的结论文本", evidence: [1.5, "abc"] },
|
|
185
|
-
});
|
|
186
|
-
expect(v).toEqual({ status: "rejected", reason: "evidence-out-of-range" });
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// ---------------------------------------------------------------------------
|
|
191
|
-
// 提示词回声 —— R4 的直接回归:库里曾有 28 条记忆的正文就是系统提示词原文
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
|
|
194
|
-
describe("提示词回声拦截(R4 回归)", () => {
|
|
195
|
-
it("拦下那 28 条真实泄漏的原文形态", () => {
|
|
196
|
-
const leaked = "保真规则:端口号/IP/URL/文件路径/API名称 → 原样保留;函数名/事件名/配置项 → 逐项保留不概括。";
|
|
197
|
-
const v = validateSynthesis({ ...base, parsed: { has: true, conclusion: leaked, evidence: [1, 2] } });
|
|
198
|
-
expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("新提示词自己的句子被回声也拦得住", () => {
|
|
202
|
-
const v = validateSynthesis({
|
|
203
|
-
...base,
|
|
204
|
-
parsed: { has: true, conclusion: "提炼不出来就说没有。宁可不产出,也不要硬凑。", evidence: [1] },
|
|
205
|
-
});
|
|
206
|
-
expect(v).toEqual({ status: "rejected", reason: "prompt-echo" });
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("正常结论不被误杀(标记表宁可漏也不误杀真结论)", () => {
|
|
210
|
-
const normal = "批量删记忆要显式关掉 cascade,否则同 scope 内相似度高的条目会被连带降权。";
|
|
211
|
-
expect(validateSynthesis({ ...base, parsed: { has: true, conclusion: normal, evidence: [1] } }).status).toBe("ok");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("标记全部真的出现在提示词里——否则这道闸是摆设", () => {
|
|
215
|
-
// 反向断言:防有人改提示词时把某条标记的原文改掉,闸门静默失效而测试照绿。
|
|
216
|
-
const corpus = CLUSTER_INSIGHT_PROMPT + "\n" + CLUSTER_PATTERN_PROMPT;
|
|
217
|
-
const legacyOnly = new Set(["保真规则", "端口号/IP/URL/文件路径", "记忆索引助手", "记忆模式发现助手"]);
|
|
218
|
-
for (const marker of PROMPT_ECHO_MARKERS) {
|
|
219
|
-
if (legacyOnly.has(marker)) continue; // 老提示词的标记,本仓已无原文,保留是为了拦历史形态
|
|
220
|
-
expect(corpus).toContain(marker);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// 提示词本身的契约:三件必须写死的事
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
|
|
229
|
-
describe("提示词契约", () => {
|
|
230
|
-
it("两个提示词都必须给出弃权出口", () => {
|
|
231
|
-
for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
|
|
232
|
-
expect(p).toContain('has": false');
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("两个提示词都必须显式禁掉性向归因措辞(56.1% 的直接来源)", () => {
|
|
237
|
-
for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
|
|
238
|
-
expect(p).toContain("倾向于");
|
|
239
|
-
expect(p).toContain("性格");
|
|
240
|
-
}
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("两个提示词都必须要求 evidence 编号", () => {
|
|
244
|
-
for (const p of [CLUSTER_INSIGHT_PROMPT, CLUSTER_PATTERN_PROMPT]) {
|
|
245
|
-
expect(p).toContain("evidence");
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("pattern 侧必须写死 ≥2 条支撑(旧提示词写了但实现不校验,等于没写)", () => {
|
|
250
|
-
expect(CLUSTER_PATTERN_PROMPT).toContain("至少 2 条");
|
|
251
|
-
});
|
|
252
|
-
});
|
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Evidence → durable promotion for dream-synthesized conclusions.
|
|
3
|
-
*
|
|
4
|
-
* The layering RecallNest works under is "evidence retained → current projection →
|
|
5
|
-
* eligible for recall → ranked result", and until now a synthesized conclusion could only
|
|
6
|
-
* ever occupy the first of those. `buildDerivedBoundary` stamps every cluster insight and
|
|
7
|
-
* cross-memory pattern `layer: "evidence"` on purpose — a model re-reading its own
|
|
8
|
-
* memories is a lead to its sources, not authority over them — but
|
|
9
|
-
* `shouldUseStableMemoryResult` rejects the evidence layer outright, so there was no path
|
|
10
|
-
* from a well-supported conclusion to anything downstream could lean on.
|
|
11
|
-
*
|
|
12
|
-
* These tests cover the road that closes that gap, and the guardrails on it: the synthesis
|
|
13
|
-
* row is never touched, re-running revises instead of duplicating, and anything whose
|
|
14
|
-
* support cannot be checked is abstained on rather than waved through.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, expect, it } from "bun:test";
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
DEFAULT_SYNTHESIS_PROMOTE_CONFIG,
|
|
21
|
-
MIN_PROMOTABLE_CONTRACT_VERSION,
|
|
22
|
-
formatSynthesisPromoteResult,
|
|
23
|
-
scanSynthesisPromotions,
|
|
24
|
-
type PromoteRequest,
|
|
25
|
-
type SynthesisPromoteScanDeps,
|
|
26
|
-
} from "../memory-promotion.js";
|
|
27
|
-
import { shouldUseStableMemoryResult } from "../memory-boundaries.js";
|
|
28
|
-
import type { MemoryEntry } from "../store.js";
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// Fixture
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
|
|
34
|
-
const SCOPE = "memory";
|
|
35
|
-
|
|
36
|
-
interface RowSpec {
|
|
37
|
-
id: string;
|
|
38
|
-
text: string;
|
|
39
|
-
category?: string;
|
|
40
|
-
importance?: number;
|
|
41
|
-
/** Omit for a plain (non-synthesis) row. */
|
|
42
|
-
synthesis?: {
|
|
43
|
-
contract?: number | null;
|
|
44
|
-
evidenceMemories?: unknown;
|
|
45
|
-
originalCategory?: string;
|
|
46
|
-
};
|
|
47
|
-
status?: string;
|
|
48
|
-
promotedTo?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function row(spec: RowSpec): MemoryEntry {
|
|
52
|
-
const metadata: Record<string, unknown> = {
|
|
53
|
-
evolution: { status: spec.status ?? "active", version: 1, accessCount: 0 },
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
if (spec.synthesis) {
|
|
57
|
-
metadata.boundary = {
|
|
58
|
-
layer: "evidence",
|
|
59
|
-
authority: "distillation",
|
|
60
|
-
conflictPolicy: "append-only",
|
|
61
|
-
originalCategory: spec.synthesis.originalCategory ?? "cases",
|
|
62
|
-
note: "Dream-synthesized derivative: a lead to its sources, never authority over them.",
|
|
63
|
-
};
|
|
64
|
-
if (spec.synthesis.contract !== null) {
|
|
65
|
-
metadata.synthesis_contract = spec.synthesis.contract ?? MIN_PROMOTABLE_CONTRACT_VERSION;
|
|
66
|
-
}
|
|
67
|
-
if (spec.synthesis.evidenceMemories !== undefined) {
|
|
68
|
-
metadata.evidenceMemories = spec.synthesis.evidenceMemories;
|
|
69
|
-
}
|
|
70
|
-
} else {
|
|
71
|
-
metadata.boundary = {
|
|
72
|
-
layer: "durable",
|
|
73
|
-
authority: "structured-memory",
|
|
74
|
-
conflictPolicy: "latest-wins",
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (spec.promotedTo) metadata.promotedTo = spec.promotedTo;
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
id: spec.id,
|
|
82
|
-
text: spec.text,
|
|
83
|
-
vector: [],
|
|
84
|
-
category: (spec.category ?? "cases") as MemoryEntry["category"],
|
|
85
|
-
scope: SCOPE,
|
|
86
|
-
importance: spec.importance ?? 0.8,
|
|
87
|
-
timestamp: Date.parse("2026-08-20T00:00:00.000Z"),
|
|
88
|
-
metadata: JSON.stringify(metadata),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Two live evidence rows every well-formed synthesis in these tests points at. */
|
|
93
|
-
const EVIDENCE = [
|
|
94
|
-
row({ id: "ev-alpha", text: "第一次踩坑:换机没 push,第二天分叉" }),
|
|
95
|
-
row({ id: "ev-beta", text: "第二次:hooks symlink 化之后工作区常态脏" }),
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
function buildDeps(rows: MemoryEntry[]): {
|
|
99
|
-
deps: SynthesisPromoteScanDeps;
|
|
100
|
-
promoted: PromoteRequest[];
|
|
101
|
-
} {
|
|
102
|
-
const all = [...EVIDENCE, ...rows];
|
|
103
|
-
const byId = new Map(all.map((e) => [e.id, e]));
|
|
104
|
-
const promoted: PromoteRequest[] = [];
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
promoted,
|
|
108
|
-
deps: {
|
|
109
|
-
store: {
|
|
110
|
-
async list() {
|
|
111
|
-
return all;
|
|
112
|
-
},
|
|
113
|
-
async getById(id: string) {
|
|
114
|
-
return byId.get(id) ?? null;
|
|
115
|
-
},
|
|
116
|
-
} as any,
|
|
117
|
-
async promote(req) {
|
|
118
|
-
promoted.push(req);
|
|
119
|
-
return {
|
|
120
|
-
id: `durable-${req.memoryId}`,
|
|
121
|
-
text: byId.get(req.memoryId)?.text ?? "",
|
|
122
|
-
category: req.category,
|
|
123
|
-
importance: req.importance,
|
|
124
|
-
scope: req.scope,
|
|
125
|
-
resolvedScope: req.scope,
|
|
126
|
-
canonicalKey: `key:${req.memoryId}`,
|
|
127
|
-
storedAt: new Date().toISOString(),
|
|
128
|
-
disposition: "stored",
|
|
129
|
-
sourceMemoryId: req.memoryId,
|
|
130
|
-
sourceCategory: "cases",
|
|
131
|
-
} as any;
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function goodSynthesis(over: Partial<RowSpec> = {}): MemoryEntry {
|
|
138
|
-
return row({
|
|
139
|
-
id: "syn-good",
|
|
140
|
-
text: "换机交接的失败都同一个形状:脏工作区被当成临时状态,真相源迁走后它变成常态",
|
|
141
|
-
importance: 0.85,
|
|
142
|
-
...over,
|
|
143
|
-
synthesis: {
|
|
144
|
-
contract: MIN_PROMOTABLE_CONTRACT_VERSION,
|
|
145
|
-
evidenceMemories: ["ev-alpha", "ev-beta"],
|
|
146
|
-
originalCategory: "patterns",
|
|
147
|
-
...(over.synthesis ?? {}),
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
describe("synthesis promotion: what qualifies", () => {
|
|
155
|
-
it("promotes a contract-compliant conclusion with enough live evidence", async () => {
|
|
156
|
-
const { deps, promoted } = buildDeps([goodSynthesis()]);
|
|
157
|
-
|
|
158
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
159
|
-
|
|
160
|
-
expect(result.candidates).toHaveLength(1);
|
|
161
|
-
expect(result.promoted).toBe(1);
|
|
162
|
-
expect(result.candidates[0]).toMatchObject({
|
|
163
|
-
seedId: "syn-good",
|
|
164
|
-
category: "patterns",
|
|
165
|
-
contractVersion: MIN_PROMOTABLE_CONTRACT_VERSION,
|
|
166
|
-
evidenceIds: ["ev-alpha", "ev-beta"],
|
|
167
|
-
});
|
|
168
|
-
// Promotion goes through the shared promoteMemory path, not a private writer.
|
|
169
|
-
expect(promoted).toEqual([
|
|
170
|
-
{ memoryId: "syn-good", category: "patterns", scope: SCOPE, importance: 0.85 },
|
|
171
|
-
]);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("defaults to dry-run and writes nothing", async () => {
|
|
175
|
-
const { deps, promoted } = buildDeps([goodSynthesis()]);
|
|
176
|
-
|
|
177
|
-
const result = await scanSynthesisPromotions(deps, SCOPE);
|
|
178
|
-
|
|
179
|
-
expect(DEFAULT_SYNTHESIS_PROMOTE_CONFIG.dryRun).toBe(true);
|
|
180
|
-
expect(result.dryRun).toBe(true);
|
|
181
|
-
expect(result.candidates).toHaveLength(1);
|
|
182
|
-
expect(result.promoted).toBe(0);
|
|
183
|
-
expect(promoted).toEqual([]);
|
|
184
|
-
expect(result.candidates[0]?.promoted).toBeNull();
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("ignores rows that are not synthesized derivatives", async () => {
|
|
188
|
-
// A hand-written durable memory is already on the durable layer; promoting it would be
|
|
189
|
-
// meaningless, and promoteMemory itself rejects non-evidence sources.
|
|
190
|
-
const { deps } = buildDeps([
|
|
191
|
-
row({ id: "handwritten", text: "Alice 用 Bun,不用 npm" }),
|
|
192
|
-
goodSynthesis(),
|
|
193
|
-
]);
|
|
194
|
-
|
|
195
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
196
|
-
|
|
197
|
-
expect(result.scannedSyntheses).toBe(1);
|
|
198
|
-
expect(result.candidates.map((c) => c.seedId)).toEqual(["syn-good"]);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("takes the durable category from the synthesis boundary", async () => {
|
|
202
|
-
const { deps, promoted } = buildDeps([
|
|
203
|
-
goodSynthesis({ synthesis: { originalCategory: "preferences" } as any }),
|
|
204
|
-
]);
|
|
205
|
-
|
|
206
|
-
await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
207
|
-
|
|
208
|
-
expect(promoted[0]?.category).toBe("preferences");
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
describe("synthesis promotion: what it refuses, and says so", () => {
|
|
213
|
-
it("skips derivatives written before the synthesis contract", async () => {
|
|
214
|
-
// The 4,749 rows already in the database predate validation entirely — they were never
|
|
215
|
-
// checked for abstention, evidence, or prompt echo. Promoting them would launder
|
|
216
|
-
// unvalidated output into durable memory.
|
|
217
|
-
const { deps, promoted } = buildDeps([
|
|
218
|
-
goodSynthesis({ id: "syn-legacy", synthesis: { contract: null } as any }),
|
|
219
|
-
goodSynthesis({ id: "syn-v1", synthesis: { contract: 1 } as any }),
|
|
220
|
-
]);
|
|
221
|
-
|
|
222
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
223
|
-
|
|
224
|
-
expect(result.skipped.pre_contract).toBe(2);
|
|
225
|
-
expect(result.candidates).toHaveLength(0);
|
|
226
|
-
expect(promoted).toEqual([]);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("abstains when the declared evidence cannot be resolved at all", async () => {
|
|
230
|
-
// Not judging is the right answer when the provenance needed for the judgement is
|
|
231
|
-
// missing — the same abstention rule the cross-source promotion check uses.
|
|
232
|
-
const { deps, promoted } = buildDeps([
|
|
233
|
-
goodSynthesis({ id: "syn-dangling", synthesis: { evidenceMemories: ["gone-1", "gone-2"] } as any }),
|
|
234
|
-
]);
|
|
235
|
-
|
|
236
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
237
|
-
|
|
238
|
-
expect(result.skipped.evidence_unresolvable).toBe(1);
|
|
239
|
-
expect(promoted).toEqual([]);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("abstains when the evidence field is missing or malformed", async () => {
|
|
243
|
-
const { deps } = buildDeps([
|
|
244
|
-
goodSynthesis({ id: "syn-noevidence", synthesis: { evidenceMemories: undefined } as any }),
|
|
245
|
-
goodSynthesis({ id: "syn-badevidence", synthesis: { evidenceMemories: "not-an-array" } as any }),
|
|
246
|
-
]);
|
|
247
|
-
|
|
248
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
249
|
-
|
|
250
|
-
expect(result.skipped.evidence_unresolvable).toBe(2);
|
|
251
|
-
expect(result.candidates).toHaveLength(0);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
it("counts duplicate evidence ids once", async () => {
|
|
255
|
-
// "Three similar cases" and "three distinct episodes" differ in dimension, not degree;
|
|
256
|
-
// the same source listed twice is one source.
|
|
257
|
-
const { deps } = buildDeps([
|
|
258
|
-
goodSynthesis({ id: "syn-dup", synthesis: { evidenceMemories: ["ev-alpha", "ev-alpha"] } as any }),
|
|
259
|
-
]);
|
|
260
|
-
|
|
261
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
262
|
-
|
|
263
|
-
expect(result.skipped.too_few_distinct_evidence).toBe(1);
|
|
264
|
-
expect(result.candidates).toHaveLength(0);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it("does not count evidence that is no longer active", async () => {
|
|
268
|
-
const superseded = row({ id: "ev-gone", text: "已被推翻的旧结论", status: "superseded" });
|
|
269
|
-
const { deps } = buildDeps([
|
|
270
|
-
superseded,
|
|
271
|
-
goodSynthesis({ id: "syn-stale", synthesis: { evidenceMemories: ["ev-alpha", "ev-gone"] } as any }),
|
|
272
|
-
]);
|
|
273
|
-
|
|
274
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
275
|
-
|
|
276
|
-
// One live source left, below the floor of two.
|
|
277
|
-
expect(result.skipped.too_few_distinct_evidence).toBe(1);
|
|
278
|
-
expect(result.candidates).toHaveLength(0);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("skips a conclusion below the importance floor", async () => {
|
|
282
|
-
const { deps } = buildDeps([goodSynthesis({ id: "syn-weak", importance: 0.4 })]);
|
|
283
|
-
|
|
284
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
285
|
-
|
|
286
|
-
expect(result.skipped.below_importance).toBe(1);
|
|
287
|
-
expect(result.candidates).toHaveLength(0);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("skips a conclusion already promoted", async () => {
|
|
291
|
-
const { deps } = buildDeps([goodSynthesis({ id: "syn-done", promotedTo: "durable-syn-done" })]);
|
|
292
|
-
|
|
293
|
-
const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
294
|
-
|
|
295
|
-
expect(result.skipped.already_promoted).toBe(1);
|
|
296
|
-
expect(result.candidates).toHaveLength(0);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("reports the rejection breakdown even when nothing is promoted", async () => {
|
|
300
|
-
// Otherwise "found nothing" and "filtered everything out" read identically.
|
|
301
|
-
const { deps } = buildDeps([
|
|
302
|
-
goodSynthesis({ id: "syn-legacy", synthesis: { contract: 1 } as any }),
|
|
303
|
-
goodSynthesis({ id: "syn-weak", importance: 0.2 }),
|
|
304
|
-
]);
|
|
305
|
-
|
|
306
|
-
const text = formatSynthesisPromoteResult(await scanSynthesisPromotions(deps, SCOPE));
|
|
307
|
-
|
|
308
|
-
expect(text).toContain("2 synthesized conclusion(s) examined");
|
|
309
|
-
expect(text).toContain("pre_contract=1");
|
|
310
|
-
expect(text).toContain("below_importance=1");
|
|
311
|
-
expect(text).toContain("No promotion candidates found");
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
describe("synthesis promotion: the guarantees around it", () => {
|
|
316
|
-
it("never modifies the synthesized row — evidence stays readable as evidence", async () => {
|
|
317
|
-
const synthesis = goodSynthesis();
|
|
318
|
-
const before = synthesis.metadata;
|
|
319
|
-
const { deps } = buildDeps([synthesis]);
|
|
320
|
-
|
|
321
|
-
await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
322
|
-
|
|
323
|
-
// Archive-first: the conclusion keeps its evidence boundary and its support set, so a
|
|
324
|
-
// later reader can still tell what the durable copy was derived from.
|
|
325
|
-
expect(synthesis.metadata).toBe(before);
|
|
326
|
-
expect(JSON.parse(synthesis.metadata!).boundary.layer).toBe("evidence");
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it("re-scanning promotes the same seed through the same idempotent path", async () => {
|
|
330
|
-
// Duplicate suppression lives in promoteMemory -> writeDurableEntry's canonicalKey
|
|
331
|
-
// dedup, so a second scan revises rather than piling up a parallel memory. What this
|
|
332
|
-
// asserts is that the scan keeps routing through it instead of growing its own writer.
|
|
333
|
-
const { deps, promoted } = buildDeps([goodSynthesis()]);
|
|
334
|
-
|
|
335
|
-
await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
336
|
-
await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
|
|
337
|
-
|
|
338
|
-
expect(promoted).toHaveLength(2);
|
|
339
|
-
expect(new Set(promoted.map((p) => p.memoryId)).size).toBe(1);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("makes the promoted copy eligible for stable memory while the source stays out", async () => {
|
|
343
|
-
// This is the whole point. The synthesized row is refused by
|
|
344
|
-
// shouldUseStableMemoryResult; the durable copy promoteMemory writes is not.
|
|
345
|
-
const synthesis = goodSynthesis({ synthesis: { originalCategory: "preferences" } as any });
|
|
346
|
-
|
|
347
|
-
expect(
|
|
348
|
-
shouldUseStableMemoryResult({
|
|
349
|
-
category: "preferences",
|
|
350
|
-
scope: SCOPE,
|
|
351
|
-
metadata: synthesis.metadata,
|
|
352
|
-
}),
|
|
353
|
-
).toBe(false);
|
|
354
|
-
|
|
355
|
-
const promotedMetadata = JSON.stringify({
|
|
356
|
-
evolution: { status: "active", version: 1 },
|
|
357
|
-
boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
|
|
358
|
-
promotedFrom: { memoryId: synthesis.id, scope: SCOPE, category: "cases" },
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
expect(
|
|
362
|
-
shouldUseStableMemoryResult({
|
|
363
|
-
category: "preferences",
|
|
364
|
-
scope: SCOPE,
|
|
365
|
-
metadata: promotedMetadata,
|
|
366
|
-
}),
|
|
367
|
-
).toBe(true);
|
|
368
|
-
});
|
|
369
|
-
});
|