dsh-lite-memory 0.3.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
@@ -0,0 +1,772 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-learning-'));
7
+ const store = await import('../store.js');
8
+ const learning = await import('../learning.js');
9
+ const anchor = await import('../anchor.js');
10
+ const state = await import('../state.js');
11
+
12
+ let passed = 0;
13
+ const test = (name, fn) => {
14
+ try {
15
+ fn();
16
+ passed += 1;
17
+ } catch (error) {
18
+ console.error(`FAIL ${name}`);
19
+ throw error;
20
+ }
21
+ };
22
+
23
+ const fresh = (label) => {
24
+ const dir = path.join(os.tmpdir(), `lite-memory-learn-${label}-${Math.random().toString(16).slice(2, 8)}`);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ return store.scopeOf(dir);
27
+ };
28
+ const evolutionOf = (scope) => store.loadIndex(scope).__evolution;
29
+ const ruleRows = (scope) => store.loadIndex(scope).entries.filter((row) => row.origin === 'project_evolution');
30
+ const ruleOf = (scope, id) => ruleRows(scope).find((row) => row.id === id);
31
+
32
+ const enable = (scope, label = 'on') => {
33
+ const result = store.setEvolution(scope, true, { operationId: `on_${label}_${Math.random().toString(16).slice(2, 8)}` });
34
+ assert.equal(result.ok, true, result.reason);
35
+ return result;
36
+ };
37
+
38
+ /** 造一个已绑定作品,返回锚点与落盘后的作品版本。 */
39
+ function seedEpisode(scope, overrides = {}) {
40
+ const sessionId = overrides.sessionId || 's1';
41
+ const ids = overrides.messageIds || ['m1'];
42
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
43
+ sessionId,
44
+ projectId: 'proj',
45
+ query: overrides.query || '改一下这期口播开头',
46
+ messageIds: ids,
47
+ batchKey: anchor.requestBatchKey(ids),
48
+ defaults: { taskKind: 'video', seriesId: '口播' },
49
+ now: overrides.now || Date.now(),
50
+ });
51
+ const saved = store.saveTaskState(scope, routed.tasks, { operationId: `tasks_${sessionId}_${Math.random().toString(16).slice(2, 8)}` });
52
+ assert.equal(saved.ok, true, saved.reason);
53
+ return { anchor: routed.anchor, episode: routed.episode, sessionId, messageIds: ids };
54
+ }
55
+
56
+ function begin(scope, grown, overrides = {}) {
57
+ const result = store.beginLearningAttempt(scope, {
58
+ episodeId: grown.episode.episodeId,
59
+ operationId: overrides.operationId || `start_${Math.random().toString(16).slice(2, 10)}`,
60
+ now: overrides.now,
61
+ });
62
+ return result;
63
+ }
64
+
65
+ const NEW_CHANGE = {
66
+ target: 'new',
67
+ feedbackType: 'spec_change',
68
+ topicKey: '口播开头',
69
+ currentText: '开头三句内给结论。',
70
+ applicability: 'conditional',
71
+ conditions: [{ key: 'seriesId', value: '口播' }],
72
+ evidence: ['u0'],
73
+ persistence: 'project_candidate',
74
+ };
75
+
76
+ const RULES = [];
77
+ const REFS = { feedback: { u0: 'm1' }, rules: {} };
78
+ const RANGES = { taskKinds: ['video'], series: ['口播'] };
79
+
80
+ function parsed(change, extra = {}) {
81
+ return learning.parseOutput(JSON.stringify({ changes: [change] }), {
82
+ finish: { kind: 'stop' },
83
+ refs: extra.refs || REFS,
84
+ rules: extra.rules || RULES,
85
+ ranges: extra.ranges || RANGES,
86
+ });
87
+ }
88
+
89
+ function commitNew(scope, grown, started, change = NEW_CHANGE, overrides = {}) {
90
+ const check = parsed(change, overrides);
91
+ assert.equal(check.ok, true, check.reason);
92
+ return store.commitLearning(scope, {
93
+ operationId: overrides.operationId || `learn_${Math.random().toString(16).slice(2, 10)}`,
94
+ epoch: started.epoch,
95
+ episodeId: grown.episode.episodeId,
96
+ episodeUpdatedAt: grown.episode.updatedAt,
97
+ projectId: 'proj',
98
+ sessionId: grown.sessionId,
99
+ messageIds: grown.messageIds,
100
+ changes: check.changes,
101
+ preparedTargets: [],
102
+ usage: overrides.usage,
103
+ explicitLongTerm: Boolean(overrides.explicitLongTerm),
104
+ now: overrides.now,
105
+ });
106
+ }
107
+
108
+ /** 对已有规则提交一次样例或检查点变化。 */
109
+ function commitExisting(scope, grown, started, id, change, extra = {}) {
110
+ const flat = ruleOf(scope, id);
111
+ const rules = [{ ref: 'r0', id, revision: flat.revision, conditions: flat.conditions }];
112
+ const refs = { feedback: { u0: grown.messageIds[0] }, rules: { r0: id } };
113
+ const check = parsed(change, { rules, refs, ranges: { taskKinds: ['video'], series: ['口播', '字幕'] } });
114
+ assert.equal(check.ok, true, check.reason);
115
+ return store.commitLearning(scope, {
116
+ operationId: extra.operationId || `learn_${Math.random().toString(16).slice(2, 10)}`,
117
+ epoch: started.epoch,
118
+ episodeId: grown.episode.episodeId,
119
+ episodeUpdatedAt: grown.episode.updatedAt,
120
+ projectId: 'proj',
121
+ sessionId: grown.sessionId,
122
+ messageIds: grown.messageIds,
123
+ feedbackText: extra.feedbackText || '',
124
+ dimension: extra.dimension || '',
125
+ changes: check.changes,
126
+ preparedTargets: [{ id, revision: flat.revision, specDigest: flat.learning.specDigest }],
127
+ usage: extra.usage,
128
+ });
129
+ }
130
+
131
+ test('P00 初始任务与致谢不算反馈', () => {
132
+ assert.equal(learning.classifyFeedback('帮我做一期口播,开头要抓人').worth, false);
133
+ assert.equal(learning.classifyFeedback('谢谢').worth, false);
134
+ assert.equal(learning.classifyFeedback('').worth, false);
135
+ });
136
+
137
+ test('P00 明确长期要求分类为规范变化', () => {
138
+ const cue = learning.classifyFeedback('以后口播开头都先给结论,别再铺垫');
139
+ assert.equal(cue.worth, true);
140
+ assert.equal(cue.type, 'spec_change');
141
+ assert.equal(cue.explicitLongTerm, true);
142
+ assert.equal(learning.isLongTerm('这次就先这样'), false);
143
+ });
144
+
145
+ test('P00 作品验收不直接形成长期规则', () => {
146
+ const cue = learning.classifyFeedback('这版可以');
147
+ assert.equal(cue.type, 'artifact_accept');
148
+ assert.equal(parsed({ ...NEW_CHANGE, feedbackType: 'artifact_accept' }).ok, false);
149
+ });
150
+
151
+ test('P00 只有已绑定作品的人类消息进入候选', () => {
152
+ const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
153
+ const base = { episodeId: 'ep_x', status: 'active', updatedAt: '2026-01-01T00:00:00.000Z' };
154
+ assert.equal(learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor: { ...base, status: 'unbound' } }), null);
155
+ assert.equal(learning.candidateFrom({ messages: [{ id: 'a1', source: { kind: 'assistant' }, content: '收到' }], anchor: base }), null);
156
+ const candidate = learning.candidateFrom({ messages: [human('m1', '以后开头都先给结论,别再铺垫')], anchor: base, sessionId: 's', projectId: 'p' });
157
+ assert.equal(candidate.episodeId, 'ep_x');
158
+ assert.equal(candidate.messageIds[0], 'm1');
159
+ assert.equal(candidate.cue.explicitLongTerm, true);
160
+ });
161
+
162
+ test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
163
+ const long = '口播开头先给结论,'.repeat(800);
164
+ const input = learning.buildCallInput({
165
+ projectId: 'p'.repeat(200),
166
+ episodeId: 'ep',
167
+ taskKind: 'video',
168
+ seriesId: '口播',
169
+ phase: 'script',
170
+ feedback: { text: long, messageIds: ['m1'], cue: { type: 'spec_change', explicitLongTerm: false } },
171
+ rules: Array.from({ length: 10 }, (_, index) => ({
172
+ id: `mem_${index}`,
173
+ revision: 3,
174
+ title: '标题'.repeat(50),
175
+ summary: '摘要'.repeat(80),
176
+ body: '正文'.repeat(300),
177
+ conditions: [{ key: 'seriesId', value: '口播' }],
178
+ learning: { state: 'trial' },
179
+ })),
180
+ ranges: { taskKinds: ['video'], series: ['口播'] },
181
+ });
182
+ assert.ok(input.bytes <= 8192, `bytes=${input.bytes}`);
183
+ assert.equal(input.rules.length >= 1, true);
184
+ assert.equal(input.rules.length <= 6, true);
185
+ assert.equal(input.prompt.includes(long), false);
186
+ assert.equal(input.refs.feedback.u0, 'm1');
187
+ assert.equal(input.refs.rules.r0, 'mem_0');
188
+ assert.equal(input.messages.length, 1);
189
+ assert.doesNotMatch(input.system, /工具调用/);
190
+ });
191
+
192
+ test('B01 截断、工具调用、额外字段整批拒绝', () => {
193
+ assert.equal(learning.parseOutput('{"changes":[{"target":"new"', { finish: { kind: 'stop' } }).ok, false);
194
+ assert.equal(learning.parseOutput('<tool>memory_save</tool>', { finish: { kind: 'stop' } }).ok, false);
195
+ assert.equal(learning.parseOutput('{"changes":[],"note":"x"}', { finish: { kind: 'stop' } }).ok, false);
196
+ assert.equal(learning.parseOutput('{"changes":[]}', { finish: { kind: 'length' } }).ok, false);
197
+ assert.equal(learning.parseOutput('{"changes":[]}', { finish: { kind: 'stop' } }).ok, true);
198
+ });
199
+
200
+ test('B01 未知字段和越界条件整批拒绝', () => {
201
+ assert.equal(parsed({ ...NEW_CHANGE, surprise: 1 }).ok, false);
202
+ assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'taskKind', value: 'podcast' }] }).ok, false);
203
+ assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'seriesId', value: '直播' }] }).ok, false);
204
+ assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'seriesId' }] }).ok, false);
205
+ const good = parsed(NEW_CHANGE);
206
+ assert.equal(good.ok, true);
207
+ assert.equal(good.changes[0].topicKey, '口播开头');
208
+ });
209
+
210
+ test('B01 证据必须来自本次反馈且不超过三条', () => {
211
+ assert.equal(parsed({ ...NEW_CHANGE, evidence: ['u9'] }).ok, false);
212
+ assert.equal(parsed({ ...NEW_CHANGE, evidence: [] }).ok, false);
213
+ const refs = { feedback: { u0: 'm1', u1: 'm2', u2: 'm3', u3: 'm4' }, rules: {} };
214
+ assert.equal(parsed({ ...NEW_CHANGE, evidence: ['u0', 'u1', 'u2', 'u3'] }, { refs }).ok, false);
215
+ });
216
+
217
+ test('B02 真实用量与缺失用量区分', () => {
218
+ assert.deepEqual(learning.usageOf([]), { known: false });
219
+ assert.deepEqual(learning.usageOf([{ type: 'text-delta', text: 'x' }]), { known: false });
220
+ const usage = learning.usageOf([{ type: 'usage', inputTokens: 120, outputTokens: 40, cachedInputTokens: 80 }]);
221
+ assert.equal(usage.known, true);
222
+ assert.equal(usage.inputTokens, 120);
223
+ assert.equal(usage.outputTokens, 40);
224
+ assert.equal(usage.cachedInputTokens, 80);
225
+ });
226
+
227
+ test('B05 频次:每作品两次、每项目 24 小时六次,失败也计数', () => {
228
+ const scope = fresh('quota');
229
+ enable(scope);
230
+ const grown = seedEpisode(scope);
231
+ const first = begin(scope, grown);
232
+ assert.equal(first.ok, true, first.reason);
233
+ const second = begin(scope, grown);
234
+ assert.equal(second.ok, true, second.reason);
235
+ const third = begin(scope, grown);
236
+ assert.equal(third.ok, false);
237
+ assert.equal(third.reason, 'episode_limit');
238
+ for (let index = 0; index < 4; index += 1) {
239
+ const other = seedEpisode(scope, { sessionId: `s${index + 2}`, messageIds: [`m${index + 2}`] });
240
+ assert.equal(begin(scope, other).ok, true);
241
+ }
242
+ const overflow = seedEpisode(scope, { sessionId: 's9', messageIds: ['m9'] });
243
+ const blocked = begin(scope, overflow);
244
+ assert.equal(blocked.ok, false);
245
+ assert.equal(blocked.reason, 'project_limit');
246
+ });
247
+
248
+ test('P10 执行没做到时只补检查点,不改规范正文', () => {
249
+ const scope = fresh('checkpoint');
250
+ enable(scope);
251
+ const grown = seedEpisode(scope);
252
+ const started = begin(scope, grown);
253
+ assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
254
+ const id = ruleRows(scope)[0].id;
255
+ const before = ruleOf(scope, id);
256
+ const next = begin(scope, grown);
257
+ const result = commitExisting(scope, grown, next, id, {
258
+ target: 'r0', feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'],
259
+ });
260
+ assert.equal(result.ok, true, result.reason);
261
+ const after = ruleOf(scope, id);
262
+ assert.equal(after.body, before.body);
263
+ assert.equal(after.revision, before.revision + 1);
264
+ assert.equal(after.learning.state, 'active');
265
+ assert.equal(after.learning.checkpoints.length, 1);
266
+ assert.match(after.learning.checkpoints[0].text, /核对/);
267
+ assert.equal(after.learning.evidence.length, before.learning.evidence.length);
268
+ assert.equal(after.learning.samples, undefined);
269
+ });
270
+
271
+ test('P11 认可作品只记有限样例,不抬规则证据也不改正文', () => {
272
+ const scope = fresh('sample');
273
+ enable(scope);
274
+ const grown = seedEpisode(scope);
275
+ const started = begin(scope, grown);
276
+ assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
277
+ const id = ruleRows(scope)[0].id;
278
+ const before = ruleOf(scope, id);
279
+ const next = begin(scope, grown);
280
+ const result = commitExisting(scope, grown, next, id, {
281
+ target: 'r0', feedbackType: 'artifact_accept', evidence: ['u0'],
282
+ }, { dimension: '开头' });
283
+ assert.equal(result.ok, true, result.reason);
284
+ const after = ruleOf(scope, id);
285
+ assert.equal(after.body, before.body);
286
+ assert.equal(after.revision, before.revision + 1);
287
+ assert.equal(after.learning.samples.length, 1);
288
+ assert.equal(after.learning.samples[0].dimension, '开头');
289
+ assert.equal(after.learning.samples[0].verified, false);
290
+ assert.equal(after.learning.samples[0].artifactVersion, grown.episode.updatedAt);
291
+ assert.equal(after.learning.evidence.length, before.learning.evidence.length);
292
+ assert.equal(after.learning.supportedEpisodeIds.length, before.learning.supportedEpisodeIds.length);
293
+ });
294
+
295
+ test('P12 样例只能挂已有规则,临时例外仍不形成规则', () => {
296
+ const scope = fresh('sample-new');
297
+ enable(scope);
298
+ const grown = seedEpisode(scope);
299
+ const started = begin(scope, grown);
300
+ const rejected = parsed({
301
+ target: 'new', feedbackType: 'artifact_accept', currentText: '这次就这版。', evidence: ['u0'],
302
+ });
303
+ assert.equal(rejected.ok, false);
304
+ assert.match(rejected.reason, /已有规则/);
305
+ assert.equal(parsed({ target: 'new', feedbackType: 'task_exception', currentText: '这次例外。', evidence: ['u0'] }).ok, false);
306
+ assert.equal(parsed({ target: 'new', feedbackType: 'unclear', currentText: '说不上。', evidence: ['u0'] }).ok, false);
307
+ assert.equal(commitNew(scope, grown, started, NEW_CHANGE).ok, true);
308
+ const id = ruleRows(scope)[0].id;
309
+ const second = begin(scope, grown);
310
+ const exception = parsed({ target: 'r0', feedbackType: 'task_exception', currentText: '这次例外。', evidence: ['u0'] }, {
311
+ rules: [{ ref: 'r0', id, revision: ruleOf(scope, id).revision, conditions: ruleOf(scope, id).conditions }],
312
+ refs: { feedback: { u0: grown.messageIds[0] }, rules: { r0: id } },
313
+ });
314
+ assert.equal(exception.ok, false);
315
+ assert.equal(second.ok, true);
316
+ });
317
+
318
+ test('P13 样例与检查点按上限保留', () => {
319
+ const scope = fresh('sample-caps');
320
+ enable(scope);
321
+ const grown = seedEpisode(scope);
322
+ const started = begin(scope, grown);
323
+ assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
324
+ const id = ruleRows(scope)[0].id;
325
+ for (const dimension of ['开头', '字幕', '节奏', '配乐', '镜头']) {
326
+ assert.equal(commitExisting(scope, grown, { epoch: evolutionOf(scope).epoch }, id, {
327
+ target: 'r0', feedbackType: 'artifact_accept', evidence: ['u0'],
328
+ }, { dimension }).ok, true);
329
+ }
330
+ assert.equal(ruleOf(scope, id).learning.samples.length, 4);
331
+ for (const text of ['检查一。', '检查二。', '检查三。', '检查四。']) {
332
+ assert.equal(commitExisting(scope, grown, { epoch: evolutionOf(scope).epoch }, id, {
333
+ target: 'r0', feedbackType: 'execution_miss', currentText: text, evidence: ['u0'],
334
+ }).ok, true);
335
+ }
336
+ assert.equal(ruleOf(scope, id).learning.checkpoints.length, 3);
337
+ });
338
+
339
+ test('P01 新规则落盘带来源、条件与待复核状态', () => {
340
+ const scope = fresh('commit-new');
341
+ enable(scope);
342
+ const grown = seedEpisode(scope);
343
+ const started = begin(scope, grown);
344
+ const result = commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true, usage: { known: true, inputTokens: 90, outputTokens: 20 } });
345
+ assert.equal(result.ok, true, result.reason);
346
+ assert.equal(result.touched, 1);
347
+ const rows = ruleRows(scope);
348
+ assert.equal(rows.length, 1);
349
+ const flat = store.loadIndex(scope).entries.find((row) => row.id === rows[0].id);
350
+ assert.equal(flat.conditions[0].value, '口播');
351
+ assert.equal(flat.learning.state, 'active');
352
+ assert.equal(flat.learning.evidence.at(-1).episodeId, grown.episode.episodeId);
353
+ assert.equal(flat.learning.evidence.at(-1).sourceType, 'human_feedback');
354
+ assert.equal(flat.learning.evidence.at(-1).evidenceRef, undefined);
355
+ const ledger = evolutionOf(scope).learning;
356
+ assert.equal(ledger.receipts.at(-1).status, 'applied');
357
+ assert.equal(ledger.receipts.at(-1).usage.inputTokens, 90);
358
+ });
359
+
360
+ test('P01 同一 operationId 重放返回原回执,不重复写', () => {
361
+ const scope = fresh('replay');
362
+ enable(scope);
363
+ const grown = seedEpisode(scope);
364
+ const started = begin(scope, grown);
365
+ const op = 'learn_replay_1';
366
+ const first = commitNew(scope, grown, started, NEW_CHANGE, { operationId: op });
367
+ assert.equal(first.ok, true, first.reason);
368
+ const again = commitNew(scope, grown, started, NEW_CHANGE, { operationId: op });
369
+ assert.equal(again.ok, true);
370
+ assert.equal(again.replayed, true);
371
+ assert.equal(again.receipt.status, 'applied');
372
+ assert.equal(ruleRows(scope).length, 1);
373
+ });
374
+
375
+ test('B06 目标规则版本变了整批拒绝', () => {
376
+ const scope = fresh('stale-target');
377
+ enable(scope);
378
+ const grown = seedEpisode(scope);
379
+ const started = begin(scope, grown);
380
+ assert.equal(commitNew(scope, grown, started, NEW_CHANGE).ok, true);
381
+ const id = ruleRows(scope)[0].id;
382
+ const version = ruleOf(scope, id).revision;
383
+ const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
384
+ const refs = { feedback: { u0: 'm1' }, rules: { r0: id } };
385
+ const update = { target: 'r0', feedbackType: 'spec_change', currentText: '开头两句内给结论。', evidence: ['u0'] };
386
+ const check = parsed(update, { rules, refs });
387
+ assert.equal(check.ok, true, check.reason);
388
+ const second = begin(scope, grown);
389
+ assert.equal(second.ok, true, second.reason);
390
+ // 另一次整理先改了同一条
391
+ const rival = parsed({ ...update, currentText: '开头一句内给结论。' }, { rules, refs });
392
+ const applied = store.commitLearning(scope, {
393
+ operationId: 'learn_rival',
394
+ epoch: second.epoch,
395
+ episodeId: grown.episode.episodeId,
396
+ episodeUpdatedAt: grown.episode.updatedAt,
397
+ projectId: 'proj',
398
+ sessionId: grown.sessionId,
399
+ messageIds: grown.messageIds,
400
+ changes: rival.changes,
401
+ preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
402
+ });
403
+ assert.equal(applied.ok, true, applied.reason);
404
+ const stale = store.commitLearning(scope, {
405
+ operationId: 'learn_stale',
406
+ epoch: second.epoch,
407
+ episodeId: grown.episode.episodeId,
408
+ episodeUpdatedAt: grown.episode.updatedAt,
409
+ projectId: 'proj',
410
+ sessionId: grown.sessionId,
411
+ messageIds: grown.messageIds,
412
+ changes: check.changes,
413
+ preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
414
+ });
415
+ assert.equal(stale.ok, false);
416
+ assert.match(stale.reason, /版本|改过/);
417
+ assert.match(ruleOf(scope, id).body, /一句/);
418
+ });
419
+
420
+ test('P06 作品版本变了旧整理整批作废', () => {
421
+ const scope = fresh('stale-episode');
422
+ enable(scope);
423
+ const grown = seedEpisode(scope);
424
+ const started = begin(scope, grown);
425
+ const later = anchor.routeTask(store.loadIndex(scope).__tasks, {
426
+ sessionId: grown.sessionId,
427
+ projectId: 'proj',
428
+ query: '这期改成另一条',
429
+ messageIds: ['m2'],
430
+ batchKey: anchor.requestBatchKey(['m2']),
431
+ now: Date.now() + 1000,
432
+ });
433
+ store.saveTaskState(scope, later.tasks, { operationId: 'tasks_later' });
434
+ const stale = commitNew(scope, grown, started, NEW_CHANGE, { now: Date.now() + 2000 });
435
+ assert.equal(stale.ok, false);
436
+ assert.match(stale.reason, /换到别的作品|改过这期|失效/);
437
+ assert.equal(ruleRows(scope).length, 0);
438
+ });
439
+
440
+ test('P05 关闭实验后旧提案不能写入', () => {
441
+ const scope = fresh('off-stale');
442
+ enable(scope);
443
+ const grown = seedEpisode(scope);
444
+ const started = begin(scope, grown);
445
+ store.setEvolution(scope, false, { operationId: 'off_1' });
446
+ const offline = commitNew(scope, grown, started);
447
+ assert.equal(offline.ok, false);
448
+ assert.equal(ruleRows(scope).length, 0);
449
+ });
450
+
451
+ test('P05 epoch 变了旧提案作废', () => {
452
+ const scope = fresh('epoch-stale');
453
+ enable(scope);
454
+ const grown = seedEpisode(scope);
455
+ const started = begin(scope, grown);
456
+ store.setEvolution(scope, false, { operationId: 'off_2' });
457
+ store.setEvolution(scope, true, { operationId: 'on_2' });
458
+ const stale = commitNew(scope, grown, started);
459
+ assert.equal(stale.ok, false);
460
+ assert.match(stale.reason, /重新开启|作废/);
461
+ });
462
+
463
+ test('P02 两个独立作品后升为已生效,单次只到试用', () => {
464
+ const scope = fresh('promote');
465
+ enable(scope);
466
+ const one = seedEpisode(scope, { sessionId: 'a', messageIds: ['a1'] });
467
+ const first = commitNew(scope, one, begin(scope, one), NEW_CHANGE);
468
+ assert.equal(first.ok, true, first.reason);
469
+ let id = ruleRows(scope)[0].id;
470
+ assert.equal(ruleOf(scope, id).learning.state, 'trial');
471
+ const two = seedEpisode(scope, { sessionId: 'b', messageIds: ['b1'] });
472
+ const version = ruleOf(scope, id).revision;
473
+ const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
474
+ const refs = { feedback: { u0: 'b1' }, rules: { r0: id } };
475
+ const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: NEW_CHANGE.currentText, evidence: ['u0'] }, { rules, refs });
476
+ const second = store.commitLearning(scope, {
477
+ operationId: 'learn_promote',
478
+ epoch: begin(scope, two).epoch,
479
+ episodeId: two.episode.episodeId,
480
+ episodeUpdatedAt: two.episode.updatedAt,
481
+ projectId: 'proj',
482
+ sessionId: two.sessionId,
483
+ messageIds: two.messageIds,
484
+ changes: check.changes,
485
+ preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
486
+ });
487
+ assert.equal(second.ok, true, second.reason);
488
+ id = ruleRows(scope)[0].id;
489
+ assert.equal(ruleOf(scope, id).learning.state, 'active');
490
+ assert.equal(ruleOf(scope, id).learning.supportedEpisodeIds.length, 2);
491
+ });
492
+
493
+ test('D01 撤回过且没有上一版时拒撤回', () => {
494
+ const scope = fresh('undo-empty');
495
+ enable(scope);
496
+ const grown = seedEpisode(scope);
497
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
498
+ const id = ruleRows(scope)[0].id;
499
+ const result = store.undoLearningChange(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'undo_1' });
500
+ assert.equal(result.ok, false);
501
+ assert.match(result.reason, /上一版/);
502
+ });
503
+
504
+ test('D02 版本对不上就拒绝撤回,旧正文不覆盖新版本', () => {
505
+ const scope = fresh('undo-stale');
506
+ enable(scope);
507
+ const grown = seedEpisode(scope);
508
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
509
+ const id = ruleRows(scope)[0].id;
510
+ const version = ruleOf(scope, id).revision;
511
+ const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
512
+ const refs = { feedback: { u0: 'm1' }, rules: { r0: id } };
513
+ const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: '开头两句内给结论。', evidence: ['u0'] }, { rules, refs });
514
+ const updated = store.commitLearning(scope, {
515
+ operationId: 'learn_undo_newer',
516
+ epoch: begin(scope, grown).epoch,
517
+ episodeId: grown.episode.episodeId,
518
+ episodeUpdatedAt: grown.episode.updatedAt,
519
+ projectId: 'proj',
520
+ sessionId: grown.sessionId,
521
+ messageIds: grown.messageIds,
522
+ changes: check.changes,
523
+ preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
524
+ });
525
+ assert.equal(updated.ok, true, updated.reason);
526
+ const stale = store.undoLearningChange(scope, id, { expectedRevision: version, operationId: 'undo_stale' });
527
+ assert.equal(stale.ok, false);
528
+ assert.match(stale.reason, /刷新/);
529
+ assert.match(ruleOf(scope, id).body, /两句/);
530
+ const undo = store.undoLearningChange(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'undo_ok' });
531
+ assert.equal(undo.ok, true, undo.reason);
532
+ assert.match(ruleOf(scope, id).body, /三句/);
533
+ assert.equal(ruleOf(scope, id).learning.state, 'paused');
534
+ });
535
+
536
+ test('D03 负反馈先本地暂停,恢复只回到试用', () => {
537
+ const scope = fresh('pause');
538
+ enable(scope);
539
+ const grown = seedEpisode(scope);
540
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
541
+ const id = ruleRows(scope)[0].id;
542
+ const paused = store.pauseLearningRules(scope, [id], { operationId: 'pause_1', by: 'feedback', reason: 'feedback_pending' });
543
+ assert.equal(paused.ok, true);
544
+ assert.equal(paused.changed, true);
545
+ assert.equal(ruleOf(scope, id).learning.state, 'paused');
546
+ const resumed = store.resumeLearningRule(scope, id, { operationId: 'resume_1' });
547
+ assert.equal(resumed.ok, true, resumed.reason);
548
+ assert.equal(ruleOf(scope, id).learning.state, 'trial');
549
+ assert.equal(ruleOf(scope, id).learning.blockedReason, undefined);
550
+ });
551
+
552
+ test('D05 候选缺值或未知键不写入', () => {
553
+ assert.equal(parsed({ ...NEW_CHANGE, topicKey: undefined }).ok, false);
554
+ assert.equal(parsed({ ...NEW_CHANGE, currentText: undefined }).ok, false);
555
+ assert.equal(parsed({ ...NEW_CHANGE, currentText: 'x'.repeat(241) }).ok, false);
556
+ assert.equal(parsed({ ...NEW_CHANGE, applicability: 'sometimes' }).ok, false);
557
+ assert.equal(parsed({ ...NEW_CHANGE, persistence: 'global' }).ok, false);
558
+ });
559
+
560
+ test('D06 同名规则条件不同就各存一条', () => {
561
+ const scope = fresh('no-merge');
562
+ enable(scope);
563
+ const one = seedEpisode(scope, { sessionId: 'c1', messageIds: ['c1'] });
564
+ assert.equal(commitNew(scope, one, begin(scope, one), NEW_CHANGE).ok, true);
565
+ const two = seedEpisode(scope, { sessionId: 'c2', messageIds: ['c2'] });
566
+ const refs = { feedback: { u0: 'c2' }, rules: {} };
567
+ const changed = { ...NEW_CHANGE, conditions: [{ key: 'phase', value: 'edit' }] };
568
+ const check = learning.parseOutput(JSON.stringify({ changes: [changed] }), { finish: { kind: 'stop' }, refs, rules: [], ranges: RANGES });
569
+ assert.equal(check.ok, true, check.reason);
570
+ const second = store.commitLearning(scope, {
571
+ operationId: 'learn_no_merge',
572
+ epoch: begin(scope, two).epoch,
573
+ episodeId: two.episode.episodeId,
574
+ episodeUpdatedAt: two.episode.updatedAt,
575
+ projectId: 'proj',
576
+ sessionId: two.sessionId,
577
+ messageIds: two.messageIds,
578
+ changes: check.changes,
579
+ preparedTargets: [],
580
+ });
581
+ assert.equal(second.ok, true, second.reason);
582
+ assert.equal(ruleRows(scope).length, 2);
583
+ });
584
+
585
+ test('B06 待确认候选到上限后拒绝新建', () => {
586
+ const scope = fresh('pending-cap');
587
+ enable(scope);
588
+ for (let index = 0; index < learning.LIMITS.pending; index += 1) {
589
+ const grown = seedEpisode(scope, { sessionId: `p${index}`, messageIds: [`pm${index}`] });
590
+ const change = { ...NEW_CHANGE, topicKey: `主题${index}`, applicability: 'unresolved', conditions: [] };
591
+ const result = commitNew(scope, grown, begin(scope, grown), change);
592
+ assert.equal(result.ok, true, `${index}:${result.reason}`);
593
+ }
594
+ const overflow = seedEpisode(scope, { sessionId: 'p99', messageIds: ['pm99'] });
595
+ const blocked = commitNew(scope, overflow, begin(scope, overflow), { ...NEW_CHANGE, topicKey: '主题溢出', applicability: 'unresolved', conditions: [] });
596
+ assert.equal(blocked.ok, false);
597
+ assert.match(blocked.reason, /上限/);
598
+ });
599
+
600
+ test('B06 失败调用只留回执不写正文', () => {
601
+ const scope = fresh('failure');
602
+ enable(scope);
603
+ const grown = seedEpisode(scope);
604
+ const started = begin(scope, grown);
605
+ const result = store.recordLearningFailure(scope, {
606
+ operationId: 'learn_fail_1',
607
+ episodeId: grown.episode.episodeId,
608
+ epoch: started.epoch,
609
+ reason: '整理没有返回内容',
610
+ usage: { known: false },
611
+ });
612
+ assert.equal(result.ok, true, result.reason);
613
+ assert.equal(ruleRows(scope).length, 0);
614
+ const receipt = evolutionOf(scope).learning.receipts.at(-1);
615
+ assert.equal(receipt.status, 'failed');
616
+ assert.equal(receipt.usage.known, false);
617
+ assert.equal(receipt.reason, '整理没有返回内容');
618
+ });
619
+
620
+ test('P07 两条同批变化一次写入', () => {
621
+ const scope = fresh('two-changes');
622
+ enable(scope);
623
+ const grown = seedEpisode(scope);
624
+ const check = learning.parseOutput(JSON.stringify({
625
+ changes: [NEW_CHANGE, { ...NEW_CHANGE, topicKey: '口播结尾', currentText: '结尾留一句行动提示。' }],
626
+ }), { finish: { kind: 'stop' }, refs: REFS, rules: [], ranges: RANGES });
627
+ assert.equal(check.ok, true, check.reason);
628
+ const result = store.commitLearning(scope, {
629
+ operationId: 'learn_two',
630
+ epoch: begin(scope, grown).epoch,
631
+ episodeId: grown.episode.episodeId,
632
+ episodeUpdatedAt: grown.episode.updatedAt,
633
+ projectId: 'proj',
634
+ sessionId: grown.sessionId,
635
+ messageIds: grown.messageIds,
636
+ changes: check.changes,
637
+ preparedTargets: [],
638
+ });
639
+ assert.equal(result.ok, true, result.reason);
640
+ assert.equal(result.touched, 2);
641
+ assert.equal(ruleRows(scope).length, 2);
642
+ });
643
+
644
+ test('P08 超过两条变化整批拒绝', () => {
645
+ const scope = fresh('too-many');
646
+ enable(scope);
647
+ const grown = seedEpisode(scope);
648
+ const check = learning.parseOutput(JSON.stringify({
649
+ changes: [NEW_CHANGE, { ...NEW_CHANGE, topicKey: 'b' }, { ...NEW_CHANGE, topicKey: 'c' }],
650
+ }), { finish: { kind: 'stop' }, refs: REFS, rules: [], ranges: RANGES });
651
+ assert.equal(check.ok, false);
652
+ const result = store.commitLearning(scope, {
653
+ operationId: 'learn_many',
654
+ epoch: begin(scope, grown).epoch,
655
+ episodeId: grown.episode.episodeId,
656
+ episodeUpdatedAt: grown.episode.updatedAt,
657
+ projectId: 'proj',
658
+ sessionId: grown.sessionId,
659
+ messageIds: grown.messageIds,
660
+ changes: [NEW_CHANGE, NEW_CHANGE, NEW_CHANGE],
661
+ preparedTargets: [],
662
+ });
663
+ assert.equal(result.ok, false);
664
+ assert.equal(ruleRows(scope).length, 0);
665
+ });
666
+
667
+ test('P09 生命周期清理过期试用但保留固定规则', () => {
668
+ const scope = fresh('lifecycle');
669
+ enable(scope);
670
+ const grown = seedEpisode(scope);
671
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
672
+ const id = ruleRows(scope)[0].id;
673
+ const now = Date.now() + learning.LIMITS.trialMs + 1000;
674
+ const report = store.expireLearning(scope, { operationId: 'expire_1', now });
675
+ assert.equal(report.ok, true, report.reason);
676
+ assert.equal(ruleOf(scope, id).learning.state, 'paused');
677
+ assert.equal(ruleOf(scope, id).learning.blockedReason, 'trial_expired');
678
+ const later = now + learning.LIMITS.pausedMs + 1000;
679
+ store.expireLearning(scope, { operationId: 'expire_2', now: later });
680
+ assert.equal(ruleOf(scope, id), undefined);
681
+ });
682
+
683
+ test('D04 新状态格式不抹掉实验标记', () => {
684
+ const scope = fresh('d04');
685
+ enable(scope);
686
+ const raw = fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
687
+ assert.equal(JSON.parse(raw).format, 'dsh-lite-memory/state@4');
688
+ const oldReader = (text) => {
689
+ const data = JSON.parse(text);
690
+ if (data.format !== 'dsh-lite-memory/state@2') throw new state.StateError('invalid', '旧版不认这个格式');
691
+ return data.payload;
692
+ };
693
+ assert.throws(() => oldReader(raw), /旧版不认/);
694
+ assert.equal(evolutionOf(scope).enabled, true);
695
+ assert.equal(evolutionOf(scope).policyVersion, learning.POLICY_VERSION);
696
+ });
697
+
698
+ test('D07 关闭后仍可查看、固定与删除成果', () => {
699
+ const scope = fresh('manage-off');
700
+ enable(scope);
701
+ const grown = seedEpisode(scope);
702
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
703
+ const id = ruleRows(scope)[0].id;
704
+ const off = store.setEvolution(scope, false, { operationId: 'off_manage' });
705
+ assert.equal(off.ok, true, off.reason);
706
+ const snapshot = store.learningSnapshot(scope);
707
+ assert.equal(snapshot.enabled, false);
708
+ assert.equal(snapshot.rules.some((rule) => rule.id === id), true);
709
+ const pinned = store.pinLearningRule(scope, id, { pinned: true, expectedRevision: ruleOf(scope, id).revision, operationId: 'pin_off' });
710
+ assert.equal(pinned.ok, true, pinned.reason);
711
+ assert.equal(ruleOf(scope, id).learning.pinned, true);
712
+ const removed = store.removeLearningRule(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'delete_off' });
713
+ assert.equal(removed.ok, true, removed.reason);
714
+ assert.equal(store.loadIndex(scope).archived.some((row) => row.id === id), true);
715
+ assert.equal(store.learningSnapshot(scope).rules.some((rule) => rule.id === id), false);
716
+ });
717
+
718
+ test('D08 删除按预期版本,旧版本不能误删', () => {
719
+ const scope = fresh('delete-stale');
720
+ enable(scope);
721
+ const grown = seedEpisode(scope);
722
+ assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
723
+ const id = ruleRows(scope)[0].id;
724
+ const before = ruleOf(scope, id).revision;
725
+ const stale = store.removeLearningRule(scope, id, { expectedRevision: before - 1, operationId: 'delete_stale' });
726
+ assert.equal(stale.ok, false);
727
+ assert.match(stale.reason, /刷新/);
728
+ const done = store.removeLearningRule(scope, id, { expectedRevision: before, operationId: 'delete_ok' });
729
+ assert.equal(done.ok, true, done.reason);
730
+ });
731
+
732
+ test('D09 用量缺失只报未知,不补零', () => {
733
+ const scope = fresh('economy-unknown');
734
+ enable(scope);
735
+ const grown = seedEpisode(scope);
736
+ assert.equal(store.recordLearningFailure(scope, {
737
+ operationId: 'learn_failed',
738
+ episodeId: grown.episode.episodeId,
739
+ reason: '模型超时',
740
+ }).ok, true);
741
+ const snapshot = store.learningSnapshot(scope);
742
+ assert.equal(snapshot.economy.calls, 1);
743
+ assert.equal(snapshot.economy.knownCalls, 0);
744
+ assert.equal(snapshot.economy.inputTokens, null);
745
+ assert.equal(snapshot.economy.outputTokens, null);
746
+ });
747
+
748
+ test('D10 效果摘要只算最近 64 条,未知用量不补零', () => {
749
+ const scope = fresh('effect-window');
750
+ enable(scope);
751
+ const grown = seedEpisode(scope);
752
+ assert.equal(commitNew(scope, grown, begin(scope, grown), NEW_CHANGE, {
753
+ usage: { known: true, inputTokens: 30, outputTokens: 7 },
754
+ }).ok, true);
755
+ assert.equal(store.recordLearningFailure(scope, { operationId: 'effect_fail', episodeId: grown.episode.episodeId, reason: '模型超时' }).ok, true);
756
+ const first = store.learningSnapshot(scope).effect;
757
+ assert.equal(first.window, 2);
758
+ assert.equal(first.applied, 1);
759
+ assert.equal(first.failed, 1);
760
+ assert.equal(first.knownCalls, 1);
761
+ assert.equal(first.inputTokens, 30);
762
+ for (let i = 0; i < 70; i += 1) {
763
+ assert.equal(store.recordLearningFailure(scope, { operationId: `effect_more_${i}`, episodeId: grown.episode.episodeId, reason: '超时' }).ok, true);
764
+ }
765
+ const after = store.learningSnapshot(scope).effect;
766
+ assert.equal(after.window, 64);
767
+ assert.equal(after.knownCalls, 0);
768
+ assert.equal(after.inputTokens, null);
769
+ assert.equal(after.outputTokens, null);
770
+ });
771
+
772
+ console.log(`learning.test.mjs: ${passed} passed`);