dsh-memu-memory 1.1.3 → 1.2.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 (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,1302 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 主动记忆金标 A/B 评测管线(F 包)与独立 worker/快照凭证契约(C 包接口侧)。
4
+ *
5
+ * 这个 harness 只做三件**确定性**的事:
6
+ * 1. 把固定快照(fixture 内 events + throughSeq + sha256)展开并校验摘要;
7
+ * 2. 用真实生产纯函数 lib/active-memory.js 复算触发计划(旧行为 / guide / guide+weighted);
8
+ * 3. 通过**可注入的 worker 适配器**跑检查,校验 §13.6 的凭证与游标规则,再算指标。
9
+ *
10
+ * 它**不调用任何模型、不联网、不读用户真实会话**。所有标注都是
11
+ * annotationSource = "synthetic-proxy":合成代理标签,不是人工金标。
12
+ * 报告里 coverage 是适配器给的上界/下界区间,不是真实模型效果。
13
+ * 真实模型调用必须由用户另行明确选择,本脚本没有实现该路径。
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import crypto from 'node:crypto'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
19
+ import { estTokens } from '../lib/budget.js'
20
+ import {
21
+ ACTIVE_MEMORY_POLICY,
22
+ assessWake,
23
+ collectEvidence,
24
+ scoreEvidence,
25
+ suppressionReason,
26
+ } from '../lib/active-memory.js'
27
+ import { parseReceipt, RECEIPT_FENCE } from '../lib/consolidation-receipt.js'
28
+
29
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
30
+ export const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'active-memory-ab')
31
+ export const MANIFEST_FILE = 'manifest.json'
32
+ export const SCHEMA_VERSION = 1
33
+ export const CASE_KIND = 'active-memory-ab-case'
34
+ export const MANIFEST_KIND = 'active-memory-ab-manifest'
35
+ export const DATASET_STATUS = 'synthetic-proxy-not-gold'
36
+ export const REVIEW_STATUSES = Object.freeze(['pending', 'in-review', 'human-reviewed'])
37
+ export const ANNOTATION_SOURCES = Object.freeze(['synthetic-proxy', 'human'])
38
+ export const CONDITIONS = Object.freeze(['legacy', 'guide', 'guide-weighted'])
39
+ export const INTERFERENCE_LEVELS = Object.freeze(['off', 'relevant', 'irrelevant-conflict'])
40
+ export const LABEL_CLASSES = Object.freeze(['decision', 'preference', 'root-cause', 'promise', 'fiction-fact'])
41
+ export const CREDENTIAL_STATUSES = Object.freeze(['completed', 'skipped', 'deferred', 'failed'])
42
+ export const CREDENTIAL_OUTCOMES = Object.freeze(['created', 'updated', 'mixed', 'no_value'])
43
+ export const PADDING_GENERATOR = 'ab-polling-padding-v1'
44
+ export const LEGACY_IDLE_MINUTES = 3
45
+ export const GUIDE_RULES_SALT = 'active-memory-ab-guide-v1'
46
+ export const MEMORY_TOOL_NAMES = Object.freeze(['memu_remember', 'memu_update', 'memu_archive', 'memu_recall', 'memu_list', 'memu_status'])
47
+ export const MEMORY_WRITE_TOOL_NAMES = Object.freeze(['memu_remember', 'memu_update', 'memu_archive'])
48
+ export const ROWS = Object.freeze([
49
+ Object.freeze({ id: 'legacy', condition: 'legacy', adapter: 'oracle', path: 'full-prefix-fork' }),
50
+ Object.freeze({ id: 'guide', condition: 'guide', adapter: 'oracle', path: 'full-prefix-fork' }),
51
+ Object.freeze({ id: 'guide-weighted:oracle', condition: 'guide-weighted', adapter: 'oracle', path: 'snapshot-worker' }),
52
+ Object.freeze({ id: 'guide-weighted:no-value', condition: 'guide-weighted', adapter: 'no-value', path: 'snapshot-worker' }),
53
+ // 第五行只走 C 包的回执格式:同样的调度,但凭证来自 parseReceipt,
54
+ // 用来证明"真 worker 的产出形状"能被 §13.6 的凭证规则接受。默认不跑(见 DEFAULT_ROW_IDS)。
55
+ Object.freeze({ id: 'guide-weighted:structural', condition: 'guide-weighted', adapter: 'worker-structural', path: 'snapshot-worker' }),
56
+ ])
57
+ export const DEFAULT_ROW_IDS = Object.freeze(['legacy', 'guide', 'guide-weighted:oracle', 'guide-weighted:no-value'])
58
+ export const REAL_MODEL_FLAGS = Object.freeze(['--real-model', '--live', '--model', '--network', '--gold'])
59
+
60
+ function fixtureError(message) {
61
+ throw new Error('active-memory-ab fixture: ' + message)
62
+ }
63
+
64
+ function sha256(value) {
65
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex')
66
+ }
67
+
68
+ function isPlainObject(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
70
+ }
71
+
72
+ function round(value, digits) {
73
+ const scale = Math.pow(10, digits === undefined ? 3 : digits)
74
+ return Math.round((Number(value) || 0) * scale) / scale
75
+ }
76
+
77
+ /** 稳定序列化:键名排序、数组保持顺序。摘要与快照身份都建在它上面。 */
78
+ export function canonicalJson(value) {
79
+ if (value === undefined) return 'null'
80
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
81
+ if (Array.isArray(value)) return '[' + value.map(item => canonicalJson(item)).join(',') + ']'
82
+ const keys = Object.keys(value).sort()
83
+ return '{' + keys.map(key => JSON.stringify(key) + ':' + canonicalJson(value[key])).join(',') + '}'
84
+ }
85
+
86
+ /** 快照摘要只覆盖 (fromSeq..throughSeq]:之后的事件是"尚未到达",不属于本次固定快照。 */
87
+ export function computeSnapshotDigest(input) {
88
+ const fromSeq = Number(input.fromSeq) || 0
89
+ const throughSeq = Number(input.throughSeq) || 0
90
+ const events = Array.isArray(input.events) ? input.events.slice(fromSeq, throughSeq + 1) : []
91
+ return sha256(canonicalJson({ schemaVersion: SCHEMA_VERSION, fromSeq, throughSeq, events }))
92
+ }
93
+
94
+ export function eventText(event) {
95
+ const type = event && event.type
96
+ if (type === 'user/message') {
97
+ const content = event.data && event.data.content
98
+ if (typeof content === 'string') return content
99
+ if (!Array.isArray(content)) return ''
100
+ return content.filter(block => block && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join(' ')
101
+ }
102
+ if (type === 'assistant/message') {
103
+ const blocks = event.data && event.data.message && event.data.message.content
104
+ if (!Array.isArray(blocks)) return ''
105
+ return blocks.filter(block => block && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join(' ')
106
+ }
107
+ if (type === 'tool/result') {
108
+ const content = event.data && event.data.message && event.data.message.content
109
+ if (!Array.isArray(content)) return ''
110
+ return content.filter(block => block && block.type === 'text' && typeof block.text === 'string').map(block => block.text).join(' ')
111
+ }
112
+ return ''
113
+ }
114
+
115
+ function normalizeEvent(event, index, caseId, turnHint) {
116
+ if (!isPlainObject(event)) fixtureError(caseId + ': events[' + index + '] must be an object')
117
+ if (typeof event.type !== 'string' || !event.type.trim()) fixtureError(caseId + ': events[' + index + '].type must be a non-empty string')
118
+ const at = Date.parse(event.at || '')
119
+ if (!Number.isFinite(at)) fixtureError(caseId + ': events[' + index + '].at must be an ISO timestamp')
120
+ if (Number.isInteger(event.seq) && event.seq !== index) fixtureError(caseId + ': events[' + index + '].seq must equal its assembled position')
121
+ const out = { ...event, seq: index, at: new Date(at).toISOString() }
122
+ if (turnHint !== undefined && out.turn === undefined) out.turn = turnHint
123
+ return out
124
+ }
125
+
126
+ /** 确定性工具轮询填充:文本完全相同,所以去重后只算 1 个工作单元(§13.5 去噪)。 */
127
+ function generatePadding(padding, caseId) {
128
+ const count = Number(padding.count)
129
+ if (!Number.isInteger(count) || count < 1 || count > 20000) fixtureError(caseId + ': padding.count must be an integer 1..20000')
130
+ const intervalMs = padding.intervalMs === undefined ? 1000 : Number(padding.intervalMs)
131
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) fixtureError(caseId + ': padding.intervalMs must be >= 0')
132
+ const startAt = Date.parse(padding.startAt || '')
133
+ if (!Number.isFinite(startAt)) fixtureError(caseId + ': padding.startAt must be an ISO timestamp')
134
+ const text = typeof padding.text === 'string' && padding.text.trim() ? padding.text : 'polling: no change'
135
+ const tool = typeof padding.tool === 'string' && padding.tool.trim() ? padding.tool : 'read'
136
+ const out = []
137
+ for (let index = 0; index < count; index += 1) {
138
+ const at = new Date(startAt + index * intervalMs).toISOString()
139
+ const toolCallId = 'pad-' + index
140
+ out.push({ type: 'step/start', turn: 1, at })
141
+ out.push({ type: 'tool/call', turn: 1, at, toolCallId, name: tool, arguments: { path: 'logs/run-' + index + '.log' } })
142
+ out.push({ type: 'tool/result', turn: 1, at, toolCallId, data: { message: { isError: false, content: [{ type: 'text', text }] } } })
143
+ out.push({ type: 'step/end', turn: 1, at })
144
+ }
145
+ return out
146
+ }
147
+
148
+ /**
149
+ * 展开一个 case 的快照:把内联 padding 占位替换成确定性生成的事件,重新编 seq,
150
+ * 再算摘要。摘要只覆盖 fromSeq..throughSeq,之后的事件属于"尚未到达"。
151
+ */
152
+ export function expandCaseSnapshot(caseDef) {
153
+ const caseId = caseDef && caseDef.id ? caseDef.id : 'case'
154
+ const declared = caseDef && caseDef.snapshot && caseDef.snapshot.events
155
+ if (!Array.isArray(declared) || declared.length === 0) fixtureError(caseId + ': snapshot.events must be a non-empty array')
156
+ const assembled = []
157
+ for (const event of declared) {
158
+ if (isPlainObject(event) && event.type === 'padding') {
159
+ for (const generated of generatePadding(event, caseId)) assembled.push(generated)
160
+ continue
161
+ }
162
+ assembled.push(event)
163
+ }
164
+ const events = assembled.map((event, index) => normalizeEvent(event, index, caseId))
165
+ const snapshotDecl = caseDef.snapshot
166
+ const fromSeq = snapshotDecl.fromSeq === undefined ? 0 : Number(snapshotDecl.fromSeq)
167
+ const throughSeq = snapshotDecl.throughSeq === undefined ? events.length - 1 : Number(snapshotDecl.throughSeq)
168
+ if (!Number.isInteger(fromSeq) || fromSeq !== 0) fixtureError(caseId + ': snapshot.fromSeq must be 0 (harness reads one fixed prefix)')
169
+ if (!Number.isInteger(throughSeq) || throughSeq < 0 || throughSeq >= events.length) {
170
+ fixtureError(caseId + ': snapshot.throughSeq must be an integer 0..events.length-1')
171
+ }
172
+ const idleAtSnapshotEndMinutes = snapshotDecl.idleAtSnapshotEndMinutes === undefined ? 0 : Number(snapshotDecl.idleAtSnapshotEndMinutes)
173
+ if (!Number.isFinite(idleAtSnapshotEndMinutes) || idleAtSnapshotEndMinutes < 0) fixtureError(caseId + ': snapshot.idleAtSnapshotEndMinutes must be >= 0')
174
+ const lastInjectedAt = snapshotDecl.lastInjectedAt === undefined ? null : snapshotDecl.lastInjectedAt
175
+ if (lastInjectedAt !== null && !Number.isFinite(Date.parse(lastInjectedAt))) fixtureError(caseId + ': snapshot.lastInjectedAt must be an ISO timestamp or null')
176
+ const globalEntries = Array.isArray(snapshotDecl.globalEntries) ? snapshotDecl.globalEntries : []
177
+ const conflicts = Array.isArray(snapshotDecl.conflicts) ? snapshotDecl.conflicts : []
178
+ const scopeGeneration = snapshotDecl.scopeGeneration === undefined ? 1 : Number(snapshotDecl.scopeGeneration)
179
+ if (!Number.isInteger(scopeGeneration) || scopeGeneration < 1) fixtureError(caseId + ': snapshot.scopeGeneration must be a positive integer')
180
+ const snapshot = {
181
+ caseId,
182
+ fromSeq,
183
+ throughSeq,
184
+ events,
185
+ tailEvents: events.length - throughSeq - 1,
186
+ idleAtSnapshotEndMinutes,
187
+ lastInjectedAt,
188
+ globalEntries,
189
+ conflicts,
190
+ scopeGeneration,
191
+ domain: typeof snapshotDecl.domain === 'string' ? snapshotDecl.domain : 'coding',
192
+ pendingCheck: snapshotDecl.pendingCheck === true,
193
+ // 默认 0:这些快照模拟「已经整理到区间起点、只有末尾这一点是新的」。
194
+ // 生产里待整理区间是从上次游标算起,首轮大会话会被积压上界强制整理一次;
195
+ // fixture 若也按「整段都未整理」算,每个 case 的第一次判定都会被积压放行,
196
+ // 守卫就测不出来了。
197
+ pendingEvents: Number.isFinite(Number(snapshotDecl.pendingEvents)) ? Math.max(0, Number(snapshotDecl.pendingEvents)) : 0,
198
+ digest: computeSnapshotDigest({ fromSeq, throughSeq, events }),
199
+ eventCount: throughSeq + 1,
200
+ }
201
+ snapshot.halfStep = detectHalfStep(events, throughSeq)
202
+ return snapshot
203
+ }
204
+
205
+ /**
206
+ * 工具调用 id 兼容两种形状:fixture 里是顶层 toolCallId;宿主转录里是
207
+ * event.data.callId(tool/call)与 event.data.message.callId(tool/result)。
208
+ * C 包的 lib/consolidation-snapshot.js 用的是宿主形状,这里同时认,方便以后接真转录。
209
+ */
210
+ export function toolCallIdOf(event) {
211
+ if (!event) return null
212
+ if (typeof event.toolCallId === 'string' && event.toolCallId) return event.toolCallId
213
+ const data = event.data
214
+ if (data && typeof data.callId === 'string' && data.callId) return data.callId
215
+ if (data && data.message && typeof data.message.callId === 'string' && data.message.callId) return data.message.callId
216
+ return null
217
+ }
218
+
219
+ function openToolCallIds(event, index) {
220
+ const ids = []
221
+ if (event && event.type === 'tool/call') {
222
+ const id = toolCallIdOf(event) || ('anon-' + index)
223
+ ids.push(id)
224
+ }
225
+ if (event && event.type === 'assistant/message') {
226
+ const blocks = event.data && event.data.message && event.data.message.content
227
+ if (Array.isArray(blocks)) {
228
+ for (const block of blocks) {
229
+ if (!block || block.type !== 'tool-call') continue
230
+ if (typeof block.id === 'string' && block.id) ids.push(block.id)
231
+ }
232
+ }
233
+ }
234
+ return ids
235
+ }
236
+
237
+ /** 事件尾部是否是半步:tool/call 没有配对的 tool/result(§13.6.1 不读半份转录)。 */
238
+ export function detectHalfStep(events, throughSeq) {
239
+ const open = new Set()
240
+ const limit = Math.min(Number(throughSeq) || 0, events.length - 1)
241
+ for (let index = 0; index <= limit; index += 1) {
242
+ const event = events[index]
243
+ if (!event) continue
244
+ for (const id of openToolCallIds(event, index)) open.add(id)
245
+ if (event.type === 'tool/result') {
246
+ const id = toolCallIdOf(event)
247
+ if (id) open.delete(id)
248
+ else if (open.size === 1) {
249
+ const only = open.values().next().value
250
+ open.delete(only)
251
+ }
252
+ }
253
+ }
254
+ return open.size > 0
255
+ }
256
+
257
+ function assertString(value, field, where) {
258
+ if (typeof value !== 'string' || !value.trim()) fixtureError(where + ': ' + field + ' must be a non-empty string')
259
+ return value
260
+ }
261
+
262
+ function assertStringArray(value, field, where) {
263
+ if (!Array.isArray(value)) fixtureError(where + ': ' + field + ' must be an array')
264
+ if (value.some(item => typeof item !== 'string' || !item.trim())) fixtureError(where + ': ' + field + ' must contain non-empty strings')
265
+ if (new Set(value).size !== value.length) fixtureError(where + ': ' + field + ' contains duplicates')
266
+ return value
267
+ }
268
+
269
+ export function validateManifest(manifest) {
270
+ if (!isPlainObject(manifest)) fixtureError('manifest: must be an object')
271
+ if (manifest.schemaVersion !== SCHEMA_VERSION) fixtureError('manifest: schemaVersion must be ' + SCHEMA_VERSION)
272
+ if (manifest.kind !== MANIFEST_KIND) fixtureError('manifest: kind must be ' + MANIFEST_KIND)
273
+ assertString(manifest.name, 'name', 'manifest')
274
+ if (manifest.status !== DATASET_STATUS) fixtureError('manifest: status must be ' + DATASET_STATUS + ' (synthetic proxies are not a gold set)')
275
+ if (manifest.draft !== true) fixtureError('manifest: draft must be true')
276
+ if (manifest.notGold !== true) fixtureError('manifest: notGold must be true')
277
+ if (manifest.gold !== false) fixtureError('manifest: gold must be false (no human review has happened)')
278
+ if (!REVIEW_STATUSES.includes(manifest.review)) fixtureError('manifest: unknown review status ' + manifest.review)
279
+ if (!Array.isArray(manifest.axes) || manifest.axes.length === 0) fixtureError('manifest: axes must be a non-empty array')
280
+ for (const level of INTERFERENCE_LEVELS) {
281
+ if (!manifest.axes.includes(level)) fixtureError('manifest: axes must declare interference level ' + level)
282
+ }
283
+ if (!Array.isArray(manifest.cases) || manifest.cases.length === 0) fixtureError('manifest: cases must be a non-empty array')
284
+ const seenId = new Set()
285
+ const seenFile = new Set()
286
+ for (let index = 0; index < manifest.cases.length; index += 1) {
287
+ const entry = manifest.cases[index]
288
+ const where = 'manifest.cases[' + index + ']'
289
+ if (!isPlainObject(entry)) fixtureError(where + ': must be an object')
290
+ assertString(entry.id, 'id', where)
291
+ if (!/^[a-z0-9][a-z0-9-]{2,80}$/.test(entry.id)) fixtureError(where + ': invalid case id ' + entry.id)
292
+ if (seenId.has(entry.id)) fixtureError(where + ': duplicate case id ' + entry.id)
293
+ seenId.add(entry.id)
294
+ assertString(entry.file, 'file', where)
295
+ if (seenFile.has(entry.file)) fixtureError(where + ': duplicate case file ' + entry.file)
296
+ seenFile.add(entry.file)
297
+ if (!/^[a-f0-9]{64}$/.test(String(entry.snapshotSha256 || ''))) fixtureError(where + ': snapshotSha256 must be a sha256 hex digest')
298
+ if (!Number.isInteger(entry.throughSeq) || entry.throughSeq < 0) fixtureError(where + ': throughSeq must be a non-negative integer')
299
+ if (!INTERFERENCE_LEVELS.includes(entry.interference)) fixtureError(where + ': unknown interference level ' + entry.interference)
300
+ if (entry.annotationSource === 'human' && entry.review !== 'human-reviewed') {
301
+ fixtureError(where + ': human annotation requires review = human-reviewed (human review and gold gating are separate)')
302
+ }
303
+ if (entry.gold === true && entry.review !== 'human-reviewed') {
304
+ fixtureError(where + ': gold = true requires human review; synthetic proxies can never be gold')
305
+ }
306
+ }
307
+ return manifest
308
+ }
309
+
310
+ export function validateCaseShape(caseDef, where) {
311
+ if (!isPlainObject(caseDef)) fixtureError(where + ': case must be an object')
312
+ if (caseDef.schemaVersion !== SCHEMA_VERSION) fixtureError(where + ': schemaVersion must be ' + SCHEMA_VERSION)
313
+ if (caseDef.kind !== CASE_KIND) fixtureError(where + ': kind must be ' + CASE_KIND)
314
+ assertString(caseDef.id, 'id', where)
315
+ if (!/^[a-z0-9][a-z0-9-]{2,80}$/.test(caseDef.id)) fixtureError(where + ': invalid case id ' + caseDef.id)
316
+ if (caseDef.draft !== true) fixtureError(where + ': draft must be true')
317
+ if (caseDef.notGold !== true) fixtureError(where + ': notGold must be true')
318
+ if (caseDef.gold !== false) fixtureError(where + ': gold must be false; only human review can change that, and not in this harness')
319
+ if (!REVIEW_STATUSES.includes(caseDef.review)) fixtureError(where + ': unknown review status ' + caseDef.review)
320
+ if (!ANNOTATION_SOURCES.includes(caseDef.annotationSource)) fixtureError(where + ': unknown annotationSource ' + caseDef.annotationSource)
321
+ if (caseDef.annotationSource === 'human' && caseDef.review !== 'human-reviewed') fixtureError(where + ': human annotation requires review = human-reviewed')
322
+ if (caseDef.annotationSource === 'synthetic-proxy' && caseDef.gold === true) fixtureError(where + ': synthetic proxy labels can never be gold')
323
+ if (!INTERFERENCE_LEVELS.includes(caseDef.interference)) fixtureError(where + ': interference must be one of ' + INTERFERENCE_LEVELS.join(','))
324
+ if (!isPlainObject(caseDef.snapshot)) fixtureError(where + ': snapshot must be an object')
325
+ if (!isPlainObject(caseDef.labels)) fixtureError(where + ': labels must be an object')
326
+ const expected = caseDef.labels.expected
327
+ if (!Array.isArray(expected)) fixtureError(where + ': labels.expected must be an array')
328
+ const seen = new Set()
329
+ for (let index = 0; index < expected.length; index += 1) {
330
+ const label = expected[index]
331
+ const labelWhere = where + ' labels.expected[' + index + ']'
332
+ if (!isPlainObject(label)) fixtureError(labelWhere + ': must be an object')
333
+ assertString(label.id, 'id', labelWhere)
334
+ if (!/^mem-ab-[a-z0-9-]{2,60}$/.test(label.id)) fixtureError(labelWhere + ': invalid label id ' + label.id)
335
+ if (seen.has(label.id)) fixtureError(labelWhere + ': duplicate label id ' + label.id)
336
+ seen.add(label.id)
337
+ if (!LABEL_CLASSES.includes(label.class)) fixtureError(labelWhere + ': unknown class ' + label.class)
338
+ if (!Number.isInteger(label.sourceSeq) || label.sourceSeq < 0) fixtureError(labelWhere + ': sourceSeq must be a non-negative integer')
339
+ assertString(label.note, 'note', labelWhere)
340
+ }
341
+ const mainProactive = caseDef.labels.mainProactive === undefined ? [] : caseDef.labels.mainProactive
342
+ if (!Array.isArray(mainProactive)) fixtureError(where + ': labels.mainProactive must be an array of label ids')
343
+ for (const id of assertStringArray(mainProactive, 'labels.mainProactive', where)) {
344
+ if (!seen.has(id)) fixtureError(where + ': labels.mainProactive references unknown label ' + id)
345
+ }
346
+ const reminders = caseDef.labels.userReminders === undefined ? [] : caseDef.labels.userReminders
347
+ if (!Array.isArray(reminders) || reminders.some(item => !Number.isInteger(item) || item < 0)) {
348
+ fixtureError(where + ': labels.userReminders must be an array of non-negative seq integers')
349
+ }
350
+ for (const seq of reminders) {
351
+ if (seq > (Number(caseDef.snapshot.throughSeq) || 0)) fixtureError(where + ': labels.userReminders seq ' + seq + ' is beyond the fixed snapshot')
352
+ }
353
+ if (caseDef.expect) {
354
+ if (!isPlainObject(caseDef.expect)) fixtureError(where + ': expect must be an object')
355
+ const allowedKeys = ['minBackgroundChecks', 'maxBackgroundChecks', 'deferredAtLeast', 'heldAtLeast', 'coveredLabelIds', 'uncoveredLabelIds', 'wakeReasons', 'blockedReasons', 'duplicatesReproposedAtLeast']
356
+ for (const key of Object.keys(caseDef.expect)) {
357
+ if (!allowedKeys.includes(key)) fixtureError(where + ': unknown expect key ' + key)
358
+ }
359
+ }
360
+ return caseDef
361
+ }
362
+
363
+ export function loadDataset(options) {
364
+ const settings = options || {}
365
+ const dir = settings.fixtureDir ? path.resolve(settings.fixtureDir) : FIXTURE_DIR
366
+ const manifestPath = path.join(dir, MANIFEST_FILE)
367
+ if (!fs.existsSync(manifestPath)) fixtureError('missing manifest at ' + manifestPath)
368
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
369
+ validateManifest(manifest)
370
+ const cases = []
371
+ const files = [{ file: MANIFEST_FILE, sha256: sha256(fs.readFileSync(manifestPath, 'utf8')) }]
372
+ for (const entry of manifest.cases) {
373
+ const casePath = path.join(dir, entry.file)
374
+ if (!fs.existsSync(casePath)) fixtureError('manifest references missing case file ' + entry.file)
375
+ const raw = fs.readFileSync(casePath, 'utf8')
376
+ files.push({ file: entry.file, sha256: sha256(raw) })
377
+ const caseDef = JSON.parse(raw)
378
+ validateCaseShape(caseDef, entry.file)
379
+ if (caseDef.id !== entry.id) fixtureError(entry.file + ': case id ' + caseDef.id + ' does not match the manifest entry ' + entry.id)
380
+ if (caseDef.interference !== entry.interference) fixtureError(entry.file + ': interference must match the manifest entry')
381
+ const snapshot = expandCaseSnapshot(caseDef)
382
+ if (settings.verifyPins !== false) {
383
+ if (snapshot.digest !== entry.snapshotSha256) {
384
+ fixtureError(entry.file + ': snapshot digest ' + snapshot.digest + ' does not match the manifest pin ' + entry.snapshotSha256)
385
+ }
386
+ if (snapshot.throughSeq !== entry.throughSeq) fixtureError(entry.file + ': throughSeq ' + snapshot.throughSeq + ' does not match the manifest pin ' + entry.throughSeq)
387
+ }
388
+ cases.push({ id: caseDef.id, def: caseDef, snapshot, file: entry.file, manifest: entry })
389
+ }
390
+ return {
391
+ dir,
392
+ name: manifest.name,
393
+ status: manifest.status,
394
+ draft: true,
395
+ notGold: true,
396
+ gold: false,
397
+ review: manifest.review,
398
+ axes: manifest.axes.slice(),
399
+ cases,
400
+ files,
401
+ digest: sha256(manifest.cases.map(entry => entry.snapshotSha256).join('|')),
402
+ manifest,
403
+ }
404
+ }
405
+
406
+ /* ------------------------------------------------------------------ */
407
+ /* 触发计划:生产纯函数 + 旧行为对照 */
408
+ /* ------------------------------------------------------------------ */
409
+
410
+ function indexOfLastEventAtOrBefore(events, seq, predicate) {
411
+ for (let index = Math.min(seq, events.length - 1); index >= 0; index -= 1) {
412
+ if (predicate(events[index], index)) return index
413
+ }
414
+ return -1
415
+ }
416
+
417
+ /** 该边界之后到下一个事件之间的空闲分钟数;快照末尾用 fixture 声明值。 */
418
+ export function idleMinutesAfter(events, boundary, throughSeq, snapshot) {
419
+ const current = Date.parse(events[boundary] && events[boundary].at)
420
+ if (!Number.isFinite(current)) return 0
421
+ for (let index = boundary + 1; index <= throughSeq; index += 1) {
422
+ const next = Date.parse(events[index].at)
423
+ if (Number.isFinite(next) && next >= current) return (next - current) / 60000
424
+ }
425
+ const tail = snapshot && Number.isFinite(Number(snapshot.idleAtSnapshotEndMinutes)) ? Number(snapshot.idleAtSnapshotEndMinutes) : 0
426
+ return tail
427
+ }
428
+
429
+ /** 主模型主动写:tool/call 带 memoryId,且后面跟着非错误结果。 */
430
+ export function mainDirectWrites(events, throughSeq) {
431
+ const writes = []
432
+ const limit = Math.min(Number(throughSeq) || 0, events.length - 1)
433
+ for (let index = 0; index <= limit; index += 1) {
434
+ const event = events[index]
435
+ if (!event || event.type !== 'tool/call') continue
436
+ const name = event.name || (event.data && event.data.name) || ''
437
+ if (!MEMORY_WRITE_TOOL_NAMES.includes(name)) continue
438
+ const args = event.arguments || (event.data && event.data.arguments) || {}
439
+ const memoryId = typeof args.memoryId === 'string' ? args.memoryId : null
440
+ let ok = false
441
+ const toolCallId = toolCallIdOf(event)
442
+ for (let cursor = index + 1; cursor <= limit; cursor += 1) {
443
+ const next = events[cursor]
444
+ if (!next) continue
445
+ if (next.type === 'tool/call') break
446
+ if (next.type !== 'tool/result') continue
447
+ const resultId = toolCallIdOf(next)
448
+ if (toolCallId && resultId && resultId !== toolCallId) continue
449
+ ok = !(next.data && next.data.message && next.data.message.isError === true)
450
+ break
451
+ }
452
+ writes.push({ seq: index, tool: name, memoryId, ok })
453
+ }
454
+ return writes
455
+ }
456
+
457
+ export function countMemoryToolCalls(events, throughSeq) {
458
+ const limit = Math.min(Number(throughSeq) || 0, events.length - 1)
459
+ let total = 0
460
+ let writes = 0
461
+ for (let index = 0; index <= limit; index += 1) {
462
+ const event = events[index]
463
+ if (!event || event.type !== 'tool/call') continue
464
+ const name = event.name || (event.data && event.data.name) || ''
465
+ if (!MEMORY_TOOL_NAMES.includes(name)) continue
466
+ total += 1
467
+ if (MEMORY_WRITE_TOOL_NAMES.includes(name)) writes += 1
468
+ }
469
+ return { total, writes }
470
+ }
471
+
472
+ export function countSteps(events, throughSeq) {
473
+ const limit = Math.min(Number(throughSeq) || 0, events.length - 1)
474
+ let steps = 0
475
+ for (let index = 0; index <= limit; index += 1) if (events[index].type === 'step/start') steps += 1
476
+ return steps
477
+ }
478
+
479
+ function humanTurnsBetween(events, fromSeq, toSeq) {
480
+ let count = 0
481
+ for (let index = fromSeq + 1; index <= toSeq && index < events.length; index += 1) {
482
+ const event = events[index]
483
+ if (event.type === 'user/message' && event.data && event.data.source && event.data.source.kind === 'user') count += 1
484
+ }
485
+ return count
486
+ }
487
+
488
+ /* ------------------------------------------------------------------ */
489
+ /* 全局干扰:独立维度,不进主表 */
490
+ /* ------------------------------------------------------------------ */
491
+
492
+ export function buildInterferenceInput(snapshot, caseDef) {
493
+ const level = caseDef.interference
494
+ const entries = snapshot.globalEntries
495
+ let selected = []
496
+ if (level === 'relevant') selected = entries.filter(entry => entry.relevant === true)
497
+ else if (level === 'irrelevant-conflict') selected = entries.filter(entry => entry.relevant !== true)
498
+ const ids = new Set(selected.map(entry => entry.id))
499
+ const conflictLabelIds = []
500
+ for (const conflict of snapshot.conflicts) {
501
+ if (!ids.has(conflict.globalId)) continue
502
+ if (!conflictLabelIds.includes(conflict.labelId)) conflictLabelIds.push(conflict.labelId)
503
+ }
504
+ const anchorText = selected.map(entry => String(entry.title || '') + ' ' + String(entry.content || '')).join(' ')
505
+ return {
506
+ level,
507
+ globalIds: selected.map(entry => entry.id),
508
+ conflictLabelIds,
509
+ anchorTokens: estTokens(anchorText),
510
+ anchorChars: anchorText.length,
511
+ }
512
+ }
513
+
514
+ /* ------------------------------------------------------------------ */
515
+ /* worker 适配器契约(C 包接口依赖,可注入) */
516
+ /* ------------------------------------------------------------------ */
517
+
518
+ /**
519
+ * 适配器输入 ctx:
520
+ * { caseId, rowId, condition, path, fromSeq, throughSeq, snapshotSha256, scopeGeneration,
521
+ * events(只含逻辑检查范围), seedEvents(从 0 到 boundary 的完整连续前缀),
522
+ * labels(全部), labelsInRange, existingIds, interferenceInput,
523
+ * halfStep, now, transcriptEvents, evidence }
524
+ * 适配器输出(凭证主体):
525
+ * { status, outcome, createdIds, updatedIds, heldIds, reasonCode, retryable }
526
+ * 边界身份(checked.*)由 harness 自己补,适配器不许自报。
527
+ */
528
+ export function oracleWorker(ctx) {
529
+ const created = []
530
+ const updated = []
531
+ const held = []
532
+ for (const label of ctx.labelsInRange) {
533
+ if (ctx.existingIds.includes(label.id)) continue
534
+ if (ctx.interferenceInput.conflictLabelIds.includes(label.id)) held.push(label.id)
535
+ else if (label.action === 'update') updated.push(label.id)
536
+ else created.push(label.id)
537
+ }
538
+ const writes = created.length + updated.length
539
+ let outcome = 'no_value'
540
+ if (writes > 0 && held.length > 0) outcome = 'mixed'
541
+ else if (created.length > 0 && updated.length > 0) outcome = 'mixed'
542
+ else if (created.length > 0) outcome = 'created'
543
+ else if (updated.length > 0) outcome = 'updated'
544
+ return { status: 'completed', outcome, createdIds: created, updatedIds: updated, heldIds: held, reasonCode: outcome, retryable: false }
545
+ }
546
+
547
+ export function noValueWorker() {
548
+ return { status: 'completed', outcome: 'no_value', createdIds: [], updatedIds: [], heldIds: [], reasonCode: 'no-candidate', retryable: false }
549
+ }
550
+
551
+ /* ------------------------------------------------------------------ */
552
+ /* 真 worker 结构行:回执 → 凭证 */
553
+ /* ------------------------------------------------------------------ */
554
+
555
+ /**
556
+ * C 包的 receipt 走 ````memu-consolidation-receipt` 围栏,harness 的凭证是扁平对象。
557
+ * 两者有三处语义差,这里一次性抹平,并把差异写成断言而不是悄悄吞掉:
558
+ * 1. 非 terminal 的 outcome 在 C 包可以是 null,harness 要求枚举内 → 兜底 no_value;
559
+ * 2. harness 规定非 completed 不得声称任何 id → 非 completed 一律清空三个桶;
560
+ * 3. C 包的完成语义只为 created+updated 记 mixed(`consolidation-receipt.js:125-128`),
561
+ * 而 oracle 适配器把 held 也算进 mixed。所以 held-only 的冲突会落成 created/updated,
562
+ * 两条行的 outcomeCounts 会不同,但 coverage 必须一致(测试里断言这一点)。
563
+ */
564
+ export function receiptToCredential(receipt) {
565
+ const record = receipt && typeof receipt === 'object' ? receipt : {}
566
+ const status = CREDENTIAL_STATUSES.includes(record.status) ? record.status : 'failed'
567
+ const outcome = CREDENTIAL_OUTCOMES.includes(record.outcome) ? record.outcome : 'no_value'
568
+ const reasonCode = typeof record.reasonCode === 'string' && record.reasonCode ? record.reasonCode : status
569
+ const retryable = record.retryable === true
570
+ const asIds = (value) => (Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [])
571
+ if (status !== 'completed') {
572
+ return { status, outcome, createdIds: [], updatedIds: [], heldIds: [], reasonCode, retryable }
573
+ }
574
+ return { status, outcome, createdIds: asIds(record.createdIds), updatedIds: asIds(record.updatedIds), heldIds: asIds(record.heldIds), reasonCode, retryable }
575
+ }
576
+
577
+ /**
578
+ * 脚本化的"worker 最终消息"。真实环境里这段文本来自
579
+ * `lib/private.js` 的 followup + whenIdle 之后的 assistant 文本;harness 里换成
580
+ * 确定性生成,好让回执解析与凭证校验这一段能被无网络地回归。
581
+ * 决策与 oracleWorker 完全同源,唯一区别是它必须走 C 包的围栏格式。
582
+ */
583
+ export function buildWorkerReceiptMessage(ctx, overrides = {}) {
584
+ const created = []
585
+ const updated = []
586
+ const held = []
587
+ for (const label of ctx.labelsInRange) {
588
+ if (ctx.existingIds.includes(label.id)) continue
589
+ if (ctx.interferenceInput.conflictLabelIds.includes(label.id)) held.push(label.id)
590
+ else if (label.action === 'update') updated.push(label.id)
591
+ else created.push(label.id)
592
+ }
593
+ // C 包的 mixed 只认 created + updated 同时非空,held 不参与。
594
+ let outcome = 'no_value'
595
+ if (created.length > 0 && updated.length > 0) outcome = 'mixed'
596
+ else if (created.length > 0) outcome = 'created'
597
+ else if (updated.length > 0) outcome = 'updated'
598
+ const receipt = {
599
+ version: 1,
600
+ jobId: 'ab-' + String(ctx.caseId || 'case') + '-' + String(ctx.throughSeq),
601
+ snapshotHash: ctx.snapshotSha256,
602
+ status: 'completed',
603
+ outcome,
604
+ checkedRange: { fromSeq: ctx.fromSeq, throughSeq: ctx.throughSeq },
605
+ createdIds: created,
606
+ updatedIds: updated,
607
+ heldIds: held,
608
+ retryable: false,
609
+ ...overrides,
610
+ }
611
+ return ['整理完成,回执如下。', RECEIPT_FENCE, JSON.stringify(receipt, null, 2), RECEIPT_FENCE].join('\n')
612
+ }
613
+
614
+ /** 真 worker 结构行:任意 worker 文本 → parseReceipt(生产纯函数)→ 凭证主体。 */
615
+ export function workerStructuralFromMessage(text) {
616
+ const parsed = parseReceipt(text)
617
+ if (!parsed.ok) {
618
+ // 三类失败原样透传:receipt-missing / receipt-malformed / receipt-invalid。
619
+ // 空输出不是 no_value:真实 worker 的空白/截断必须算失败并可重试。
620
+ return { status: 'failed', outcome: 'no_value', createdIds: [], updatedIds: [], heldIds: [], reasonCode: parsed.reasonCode, retryable: true }
621
+ }
622
+ return receiptToCredential(parsed.receipt)
623
+ }
624
+
625
+ export function workerStructuralWorker(ctx) {
626
+ return workerStructuralFromMessage(buildWorkerReceiptMessage(ctx))
627
+ }
628
+
629
+ export const WORKERS = Object.freeze({ oracle: oracleWorker, 'no-value': noValueWorker, 'worker-structural': workerStructuralWorker })
630
+
631
+ export function buildCredential(request, result) {
632
+ const payload = result || {}
633
+ return {
634
+ checked: {
635
+ fromSeq: request.fromSeq,
636
+ throughSeq: request.throughSeq,
637
+ snapshotSha256: request.snapshotSha256,
638
+ scopeGeneration: request.scopeGeneration,
639
+ },
640
+ status: payload.status,
641
+ outcome: payload.outcome,
642
+ createdIds: Array.isArray(payload.createdIds) ? payload.createdIds.slice() : [],
643
+ updatedIds: Array.isArray(payload.updatedIds) ? payload.updatedIds.slice() : [],
644
+ heldIds: Array.isArray(payload.heldIds) ? payload.heldIds.slice() : [],
645
+ reasonCode: typeof payload.reasonCode === 'string' ? payload.reasonCode : null,
646
+ retryable: payload.retryable === true,
647
+ }
648
+ }
649
+
650
+ /** 凭证校验:边界身份必须与请求一致,内部计数不能自相矛盾。 */
651
+ export function validateCredential(credential, request) {
652
+ const problems = []
653
+ if (!isPlainObject(credential)) return ['credential must be an object']
654
+ const checked = credential.checked
655
+ if (!isPlainObject(checked)) problems.push('credential.checked is missing')
656
+ else {
657
+ if (checked.fromSeq !== request.fromSeq) problems.push('checked.fromSeq ' + checked.fromSeq + ' != ' + request.fromSeq)
658
+ if (checked.throughSeq !== request.throughSeq) problems.push('checked.throughSeq ' + checked.throughSeq + ' != ' + request.throughSeq)
659
+ if (checked.snapshotSha256 !== request.snapshotSha256) problems.push('checked.snapshotSha256 does not match the fixed snapshot')
660
+ if (checked.scopeGeneration !== request.scopeGeneration) problems.push('checked.scopeGeneration does not match')
661
+ }
662
+ if (!CREDENTIAL_STATUSES.includes(credential.status)) problems.push('unknown status ' + credential.status)
663
+ if (!CREDENTIAL_OUTCOMES.includes(credential.outcome)) problems.push('unknown outcome ' + credential.outcome)
664
+ for (const field of ['createdIds', 'updatedIds', 'heldIds']) {
665
+ const value = credential[field]
666
+ if (!Array.isArray(value)) problems.push(field + ' must be an array')
667
+ else if (value.some(id => typeof id !== 'string' || !id.trim())) problems.push(field + ' must contain non-empty ids')
668
+ }
669
+ const created = Array.isArray(credential.createdIds) ? credential.createdIds : []
670
+ const updated = Array.isArray(credential.updatedIds) ? credential.updatedIds : []
671
+ const held = Array.isArray(credential.heldIds) ? credential.heldIds : []
672
+ const overlap = created.filter(id => updated.includes(id) || held.includes(id)).concat(updated.filter(id => held.includes(id)))
673
+ if (overlap.length > 0) problems.push('ids reported in more than one bucket: ' + overlap.join(','))
674
+ if (new Set(created).size !== created.length) problems.push('createdIds contains duplicates')
675
+ if (credential.status !== 'completed' && (created.length + updated.length + held.length) > 0) {
676
+ problems.push('a ' + credential.status + ' credential must not claim written ids')
677
+ }
678
+ if (credential.status === 'completed' && credential.outcome === 'no_value' && (created.length + updated.length) > 0) {
679
+ problems.push('outcome no_value with created/updated ids')
680
+ }
681
+ if (credential.status === 'completed' && credential.outcome !== 'no_value') {
682
+ if ((created.length + updated.length) === 0 && held.length === 0) problems.push('outcome ' + credential.outcome + ' without any ids')
683
+ }
684
+ return problems
685
+ }
686
+
687
+ /** 只有带凭证的 completed(含 no_value)才推进游标(§13.6 游标与恢复)。 */
688
+ export function canAdvance(credential) {
689
+ return isPlainObject(credential) && credential.status === 'completed' && CREDENTIAL_OUTCOMES.includes(credential.outcome)
690
+ }
691
+
692
+ /* ------------------------------------------------------------------ */
693
+ /* 条件执行 */
694
+ /* ------------------------------------------------------------------ */
695
+
696
+ async function executeRow(input) {
697
+ const { caseDef, snapshot, row, worker } = input
698
+ const policy = ACTIVE_MEMORY_POLICY
699
+ const events = snapshot.events
700
+ const limit = snapshot.throughSeq
701
+ const fullPrefix = row.path === 'full-prefix-fork'
702
+ const guideDelivered = row.condition !== 'legacy'
703
+ const interferenceInput = buildInterferenceInput(snapshot, caseDef)
704
+ const interferenceIds = new Set(interferenceInput.globalIds)
705
+ const labels = caseDef.labels.expected.map(label => ({ ...label }))
706
+ const labelById = new Map(labels.map(label => [label.id, label]))
707
+
708
+ const written = new Map()
709
+ const mainWrites = mainDirectWrites(events, limit)
710
+ for (const write of mainWrites) {
711
+ if (write.ok && write.memoryId && labelById.has(write.memoryId)) written.set(write.memoryId, { by: 'main', seq: write.seq })
712
+ }
713
+
714
+ const boundaries = []
715
+ for (let index = 0; index <= limit; index += 1) if (events[index].type === 'turn/end') boundaries.push(index)
716
+ if (snapshot.pendingCheck && row.path === 'snapshot-worker' && (boundaries.length === 0 || boundaries[boundaries.length - 1] < limit)) boundaries.push(limit)
717
+
718
+ const checks = []
719
+ const blocked = []
720
+ let cursor = snapshot.fromSeq
721
+ let lastSleepAt = null
722
+ let measuredAdapterMs = 0
723
+ let leaks = 0
724
+ let deferredCount = 0
725
+ let skippedCount = 0
726
+ let failedCount = 0
727
+ let invalidCount = 0
728
+ const deferredReasons = []
729
+
730
+ for (const boundary of boundaries) {
731
+ const fromSeq = fullPrefix ? snapshot.fromSeq : cursor
732
+ if (!fullPrefix && fromSeq > boundary) continue
733
+ const idle = idleMinutesAfter(events, boundary, limit, snapshot)
734
+ const windowEvents = events.slice(fromSeq, boundary + 1)
735
+ const evidence = collectEvidence(windowEvents, policy)
736
+ const transcriptEvents = boundary + 1
737
+ const now = Date.parse(events[boundary].at)
738
+ const scored = scoreEvidence(evidence, policy)
739
+
740
+ let trigger = null
741
+ let blockedReason = null
742
+ let verdictReason = null
743
+ if (fullPrefix) {
744
+ if (idle >= LEGACY_IDLE_MINUTES) trigger = 'legacy-idle'
745
+ else blockedReason = 'no-idle-gap'
746
+ } else {
747
+ const suppressed = suppressionReason({
748
+ now,
749
+ lastSleepAt,
750
+ lastInjectedAt: snapshot.lastInjectedAt,
751
+ consolidatedCooldownMinutes: 0,
752
+ recallCooldownMinutes: policy.recallCooldownMinutes,
753
+ })
754
+ if (suppressed) blockedReason = suppressed
755
+ else {
756
+ const verdict = assessWake({ evidence, starved: idle >= policy.starvationMinutes, transcriptEvents, pendingEvents: snapshot.pendingEvents, policy })
757
+ verdictReason = verdict.reason
758
+ if (verdict.wake) trigger = verdict.reason
759
+ else blockedReason = verdict.reason
760
+ }
761
+ }
762
+ if (!trigger) {
763
+ blocked.push({
764
+ throughSeq: boundary,
765
+ fromSeq,
766
+ reason: blockedReason,
767
+ idleMinutes: round(idle, 3),
768
+ transcriptEvents,
769
+ humanTurns: evidence.humanTurns,
770
+ score: scored.score,
771
+ })
772
+ continue
773
+ }
774
+
775
+ const labelsInRange = labels.filter(label => label.sourceSeq >= fromSeq && label.sourceSeq <= boundary)
776
+ const halfStep = !fullPrefix && detectHalfStep(events, boundary)
777
+ const seedEvents = events.slice(0, boundary + 1)
778
+ const labelIdsInRange = new Set(labelsInRange.map(label => label.id))
779
+ const existingIds = [...written.keys()].filter(id => labelIdsInRange.has(id))
780
+ const ctx = {
781
+ caseId: snapshot.caseId,
782
+ rowId: row.id,
783
+ condition: row.condition,
784
+ path: row.path,
785
+ fromSeq,
786
+ throughSeq: boundary,
787
+ snapshotSha256: snapshot.digest,
788
+ scopeGeneration: snapshot.scopeGeneration,
789
+ events: windowEvents.slice(),
790
+ seedEvents: seedEvents.slice(),
791
+ labels,
792
+ labelsInRange,
793
+ existingIds,
794
+ interferenceInput,
795
+ halfStep,
796
+ now,
797
+ transcriptEvents,
798
+ evidence,
799
+ }
800
+ const request = { fromSeq, throughSeq: boundary, snapshotSha256: snapshot.digest, scopeGeneration: snapshot.scopeGeneration }
801
+ const startedAt = process.hrtime.bigint()
802
+ let adapterResult
803
+ if (halfStep) adapterResult = { status: 'deferred', outcome: 'no_value', createdIds: [], updatedIds: [], heldIds: [], reasonCode: 'half-step', retryable: true }
804
+ else adapterResult = await worker(ctx)
805
+ measuredAdapterMs += Number(process.hrtime.bigint() - startedAt) / 1e6
806
+
807
+ const credential = buildCredential(request, adapterResult)
808
+ const problems = validateCredential(credential, request)
809
+ const advanced = problems.length === 0 && canAdvance(credential)
810
+ if (problems.length > 0) invalidCount += 1
811
+ if (credential.status === 'deferred') deferredCount += 1
812
+ if (credential.status === 'skipped') skippedCount += 1
813
+ if (credential.status === 'failed') failedCount += 1
814
+ if (credential.status === 'deferred') deferredReasons.push(credential.reasonCode || 'deferred')
815
+
816
+ const proposed = labelsInRange.map(label => label.id)
817
+ let reproposed = 0
818
+ for (const id of proposed) if (written.has(id)) reproposed += 1
819
+ let alreadyPresent = 0
820
+ if (advanced) {
821
+ for (const id of credential.createdIds) {
822
+ if (!labelById.has(id)) continue
823
+ written.set(id, { by: 'background', seq: boundary })
824
+ }
825
+ for (const id of credential.updatedIds) {
826
+ if (written.has(id)) alreadyPresent += 1
827
+ written.set(id, { by: 'background', seq: boundary })
828
+ }
829
+ for (const id of credential.heldIds) {
830
+ if (!written.has(id)) written.set(id, { by: 'background-held', seq: boundary })
831
+ }
832
+ for (const id of [...credential.createdIds, ...credential.updatedIds, ...credential.heldIds]) {
833
+ if (interferenceIds.has(id)) leaks += 1
834
+ }
835
+ cursor = boundary + 1
836
+ lastSleepAt = new Date(now).toISOString()
837
+ }
838
+
839
+ // Eligibility stays on the logical cursor window, but production fork seeds are
840
+ // always the complete contiguous prefix through this boundary. Do not model a
841
+ // windowed seed: private.js buildForkSeed(events, throughSeq) has no such mode.
842
+ const seedText = seedEvents.map(event => eventText(event)).join(' ')
843
+ checks.push({
844
+ fromSeq,
845
+ throughSeq: boundary,
846
+ trigger,
847
+ blockedReason,
848
+ verdictReason,
849
+ idleMinutes: round(idle, 3),
850
+ transcriptEvents,
851
+ starved: idle >= policy.starvationMinutes,
852
+ score: scored.score,
853
+ scoreParts: scored.parts,
854
+ evidence,
855
+ halfStep,
856
+ status: credential.status,
857
+ outcome: credential.outcome,
858
+ reasonCode: credential.reasonCode,
859
+ retryable: credential.retryable,
860
+ createdIds: credential.createdIds,
861
+ updatedIds: credential.updatedIds,
862
+ heldIds: credential.heldIds,
863
+ credentialValid: problems.length === 0,
864
+ problems,
865
+ cursorAdvanced: advanced,
866
+ reproposed,
867
+ alreadyPresent,
868
+ seedTokens: estTokens(seedText),
869
+ seedEvents: seedEvents.length,
870
+ })
871
+ }
872
+
873
+ const labelsOut = labels.map(label => {
874
+ const record = written.get(label.id)
875
+ const coveredBy = record ? record.by : null
876
+ let latency = null
877
+ if (coveredBy && coveredBy !== 'background-held') {
878
+ const writeSeq = record.seq
879
+ latency = {
880
+ humanTurns: humanTurnsBetween(events, label.sourceSeq, writeSeq),
881
+ steps: 0,
882
+ }
883
+ for (let index = label.sourceSeq + 1; index <= writeSeq && index < events.length; index += 1) {
884
+ if (events[index].type === 'step/end') latency.steps += 1
885
+ }
886
+ }
887
+ return {
888
+ id: label.id,
889
+ class: label.class,
890
+ sourceSeq: label.sourceSeq,
891
+ covered: coveredBy === 'main' || coveredBy === 'background',
892
+ coveredBy,
893
+ latency,
894
+ }
895
+ })
896
+
897
+ const guideTurnCount = boundaries.length === 0 ? 0 : (() => {
898
+ let turns = 0
899
+ for (let index = 0; index <= limit; index += 1) if (events[index].type === 'turn/start') turns += 1
900
+ return turns
901
+ })()
902
+
903
+ const checksWithWrites = checks.filter(check => check.createdIds.length + check.updatedIds.length > 0).length
904
+ const noValue = checks.filter(check => check.outcome === 'no_value' && check.status === 'completed').length
905
+ const seedTokensTotal = checks.reduce((sum, check) => sum + check.seedTokens, 0)
906
+ const toolCalls = countMemoryToolCalls(events, limit)
907
+ return {
908
+ caseId: snapshot.caseId,
909
+ rowId: row.id,
910
+ condition: row.condition,
911
+ path: row.path,
912
+ adapter: row.adapter,
913
+ interference: interferenceInput.level,
914
+ snapshot: {
915
+ throughSeq: snapshot.throughSeq,
916
+ eventCount: snapshot.eventCount,
917
+ tailEvents: snapshot.tailEvents,
918
+ sha256: snapshot.digest,
919
+ scopeGeneration: snapshot.scopeGeneration,
920
+ halfStep: snapshot.halfStep,
921
+ },
922
+ guide: {
923
+ delivered: guideDelivered,
924
+ deliveries: guideDelivered ? guideTurnCount : 0,
925
+ rulesKey: guideDelivered ? sha256(GUIDE_RULES_SALT + '|' + snapshot.domain + '|' + caseDef.id).slice(0, 16) : null,
926
+ note: guideDelivered ? 'rulesKey 是投放契约占位符,不是生产 guide 正文' : 'legacy 条件不投放 guide 消息',
927
+ },
928
+ checks,
929
+ blocked,
930
+ labels: labelsOut,
931
+ labelsTotal: labelsOut.length,
932
+ cursor: {
933
+ mode: fullPrefix ? 'lastSleepSeq-full-prefix' : 'snapshot-credit-contiguous',
934
+ startedAtSeq: snapshot.fromSeq,
935
+ finalSeq: fullPrefix ? null : cursor,
936
+ advanced: checks.some(check => check.cursorAdvanced),
937
+ },
938
+ calls: {
939
+ memoryToolCalls: toolCalls.total,
940
+ memoryWriteCalls: toolCalls.writes,
941
+ backgroundChecks: checks.length,
942
+ checksWithWrites,
943
+ steps: countSteps(events, limit),
944
+ },
945
+ evidence: {
946
+ humanTurns: collectEvidence(events.slice(snapshot.fromSeq, limit + 1), policy).humanTurns,
947
+ },
948
+ cost: {
949
+ seedTokensTotal,
950
+ seedTokensPerCheck: checks.length === 0 ? 0 : round(seedTokensTotal / checks.length, 1),
951
+ estimateOnly: true,
952
+ },
953
+ blocking: {
954
+ structural: 'parent-blocking-not-measured',
955
+ mainSessionWaitForCheck: null,
956
+ measuredAdapterMs: round(measuredAdapterMs, 3),
957
+ note: 'both fork paths are represented structurally; parent-session blocking and wait duration were not measured',
958
+ },
959
+ outcomeCounts: { noValue, deferred: deferredCount, skipped: skippedCount, failed: failedCount, invalidCredential: invalidCount, deferredReasons },
960
+ interferenceReport: {
961
+ level: interferenceInput.level,
962
+ globalIds: interferenceInput.globalIds,
963
+ conflictLabelIds: interferenceInput.conflictLabelIds,
964
+ anchorTokens: interferenceInput.anchorTokens,
965
+ anchorChars: interferenceInput.anchorChars,
966
+ leaks,
967
+ heldIds: checks.flatMap(check => check.heldIds),
968
+ },
969
+ }
970
+ }
971
+
972
+ /* ------------------------------------------------------------------ */
973
+ /* 汇总 */
974
+ /* ------------------------------------------------------------------ */
975
+
976
+ function summarizeRow(runs) {
977
+ const cases = runs.length
978
+ let labelsTotal = 0
979
+ let mainProactive = 0
980
+ let background = 0
981
+ let covered = 0
982
+ let held = 0
983
+ let uncovered = 0
984
+ const latencies = []
985
+ let checks = 0
986
+ let noValue = 0
987
+ let deferred = 0
988
+ let failed = 0
989
+ let invalid = 0
990
+ let reproposed = 0
991
+ let alreadyPresent = 0
992
+ let seedTokens = 0
993
+ let memoryToolCalls = 0
994
+ let writeCalls = 0
995
+ let steps = 0
996
+ let humanTurns = 0
997
+ let heldIds = []
998
+ let leaks = 0
999
+ let anchorTokens = 0
1000
+ const blockedByReason = {}
1001
+ const wakeTriggers = {}
1002
+ for (const run of runs) {
1003
+ for (const item of run.blocked) blockedByReason[item.reason] = (blockedByReason[item.reason] || 0) + 1
1004
+ for (const check of run.checks) wakeTriggers[check.trigger] = (wakeTriggers[check.trigger] || 0) + 1
1005
+ labelsTotal += run.labelsTotal
1006
+ for (const label of run.labels) {
1007
+ if (label.coveredBy === 'main') mainProactive += 1
1008
+ if (label.coveredBy === 'background') background += 1
1009
+ if (label.covered) covered += 1
1010
+ else if (label.coveredBy === 'background-held') held += 1
1011
+ else uncovered += 1
1012
+ if (label.latency) latencies.push(label.latency)
1013
+ }
1014
+ heldIds = heldIds.concat(run.interferenceReport.heldIds)
1015
+ checks += run.checks.length
1016
+ noValue += run.outcomeCounts.noValue
1017
+ deferred += run.outcomeCounts.deferred
1018
+ failed += run.outcomeCounts.failed
1019
+ invalid += run.outcomeCounts.invalidCredential
1020
+ seedTokens += run.cost.seedTokensTotal
1021
+ memoryToolCalls += run.calls.memoryToolCalls
1022
+ writeCalls += run.calls.memoryWriteCalls
1023
+ steps += run.calls.steps
1024
+ humanTurns += run.evidence.humanTurns
1025
+ leaks += run.interferenceReport.leaks
1026
+ anchorTokens += run.interferenceReport.anchorTokens
1027
+ for (const check of run.checks) {
1028
+ reproposed += check.reproposed
1029
+ alreadyPresent += check.alreadyPresent
1030
+ }
1031
+ }
1032
+ const humanTurnLatencies = latencies.map(item => item.humanTurns)
1033
+ const stepLatencies = latencies.map(item => item.steps)
1034
+ const sortedTurns = humanTurnLatencies.slice().sort((left, right) => left - right)
1035
+ return {
1036
+ row: runs.length > 0 ? runs[0].rowId : null,
1037
+ condition: runs.length > 0 ? runs[0].condition : null,
1038
+ adapter: runs.length > 0 ? runs[0].adapter : null,
1039
+ path: runs.length > 0 ? runs[0].path : null,
1040
+ cases,
1041
+ coverage: {
1042
+ labelsTotal,
1043
+ mainProactive,
1044
+ background,
1045
+ covered,
1046
+ held: heldIds.length,
1047
+ uncovered,
1048
+ rate: labelsTotal === 0 ? null : round(covered / labelsTotal, 4),
1049
+ interpretation: 'oracle 适配器给的是管线可行上界;no-value 适配器给下界。两者都不是模型实测。',
1050
+ },
1051
+ latency: {
1052
+ measuredOnLabels: latencies.length,
1053
+ meanHumanTurns: humanTurnLatencies.length === 0 ? null : round(humanTurnLatencies.reduce((sum, value) => sum + value, 0) / humanTurnLatencies.length, 3),
1054
+ maxHumanTurns: sortedTurns.length === 0 ? null : sortedTurns[sortedTurns.length - 1],
1055
+ medianHumanTurns: sortedTurns.length === 0 ? null : sortedTurns[Math.floor(sortedTurns.length / 2)],
1056
+ meanSteps: stepLatencies.length === 0 ? null : round(stepLatencies.reduce((sum, value) => sum + value, 0) / stepLatencies.length, 3),
1057
+ unit: 'human-turns / step-end(已完成步骤)between the labelled seq and the write',
1058
+ },
1059
+ noValue: { checks, noValueOutcomes: noValue, rate: checks === 0 ? null : round(noValue / checks, 4) },
1060
+ duplicates: { reproposed, alreadyPresent },
1061
+ held: { count: heldIds.length, ids: heldIds.slice(0, 40) },
1062
+ calls: {
1063
+ backgroundChecks: checks,
1064
+ memoryToolCalls,
1065
+ memoryWriteCalls: writeCalls,
1066
+ per1000Steps: steps === 0 ? null : round((checks * 1000) / steps, 3),
1067
+ steps,
1068
+ },
1069
+ evidence: { humanTurns },
1070
+ cost: {
1071
+ seedTokensTotal: seedTokens,
1072
+ seedTokensPerCheck: checks === 0 ? 0 : round(seedTokens / checks, 1),
1073
+ estimateOnly: true,
1074
+ method: 'lib/budget.js estTokens over all text in the complete contiguous seed prefix [0..throughSeq]',
1075
+ },
1076
+ blocking: {
1077
+ mainSessionWaitForCheck: null,
1078
+ note: 'parent-session blocking / wait is unknown and not measured; this harness has no duration observation',
1079
+ },
1080
+ scheduler: { blockedByReason, wakeTriggers },
1081
+ statuses: { deferred, failed, invalidCredential: invalid },
1082
+ interference: { anchorTokens, leaks },
1083
+ }
1084
+ }
1085
+
1086
+ function delta(baseline, candidate) {
1087
+ const out = {}
1088
+ const keys = ['covered', 'rate', 'uncovered']
1089
+ for (const key of keys) {
1090
+ const before = baseline.coverage[key]
1091
+ const after = candidate.coverage[key]
1092
+ out[key] = before === null || after === null ? null : round(after - before, 4)
1093
+ }
1094
+ out.backgroundChecks = candidate.calls.backgroundChecks - baseline.calls.backgroundChecks
1095
+ out.seedTokensTotal = candidate.cost.seedTokensTotal - baseline.cost.seedTokensTotal
1096
+ out.reproposed = candidate.duplicates.reproposed - baseline.duplicates.reproposed
1097
+ return out
1098
+ }
1099
+
1100
+ export async function runAb(options) {
1101
+ const settings = options || {}
1102
+ const startedAt = new Date().toISOString()
1103
+ const dataset = loadDataset({ fixtureDir: settings.fixtureDir })
1104
+ const rowIds = Array.isArray(settings.rowIds) && settings.rowIds.length > 0 ? settings.rowIds : DEFAULT_ROW_IDS
1105
+ const rows = ROWS.filter(row => rowIds.includes(row.id))
1106
+ for (const id of rowIds) if (!ROWS.some(row => row.id === id)) throw new Error('active-memory-ab: unknown row ' + id)
1107
+ const workers = { ...WORKERS, ...(settings.workers || {}) }
1108
+ const caseLimit = Number.isFinite(settings.caseLimit) ? Math.max(0, Math.min(settings.caseLimit, dataset.cases.length)) : dataset.cases.length
1109
+ const cases = dataset.cases.slice(0, caseLimit)
1110
+ const runs = []
1111
+ for (const entry of cases) {
1112
+ for (const row of rows) {
1113
+ const worker = workers[row.adapter]
1114
+ if (typeof worker !== 'function') throw new Error('active-memory-ab: unknown worker adapter ' + row.adapter)
1115
+ runs.push(await executeRow({ caseDef: entry.def, snapshot: entry.snapshot, row, worker }))
1116
+ }
1117
+ }
1118
+
1119
+ const cleanRuns = runs.filter(run => run.interference === 'off')
1120
+ const mainAxisRows = rowIds.map(id => summarizeRow(cleanRuns.filter(run => run.rowId === id).length > 0 ? cleanRuns.filter(run => run.rowId === id) : []))
1121
+ const baseline = mainAxisRows.find(item => item.row === 'legacy')
1122
+ const deltas = mainAxisRows.filter(item => item.row !== 'legacy').map(item => ({
1123
+ row: item.row,
1124
+ versus: 'legacy',
1125
+ delta: baseline ? delta(baseline, item) : null,
1126
+ }))
1127
+
1128
+ const interferenceRows = INTERFERENCE_LEVELS.map(level => {
1129
+ const levelRuns = runs.filter(run => run.interference === level && run.rowId === 'guide-weighted:oracle')
1130
+ const summary = summarizeRow(levelRuns)
1131
+ return {
1132
+ level,
1133
+ cases: summary.cases,
1134
+ backgroundChecks: summary.calls.backgroundChecks,
1135
+ anchorTokens: summary.interference.anchorTokens,
1136
+ leaks: summary.interference.leaks,
1137
+ held: summary.held.count,
1138
+ covered: summary.coverage.covered,
1139
+ uncovered: summary.coverage.uncovered,
1140
+ }
1141
+ })
1142
+
1143
+ const structureChecks = []
1144
+ for (const run of runs) {
1145
+ let previous = run.cursor.startedAtSeq
1146
+ for (const check of run.checks) {
1147
+ if (check.fromSeq !== previous) structureChecks.push({ caseId: run.caseId, rowId: run.rowId, problem: 'range is not contiguous with the previous cursor', fromSeq: check.fromSeq, expected: previous })
1148
+ if (check.cursorAdvanced) previous = check.throughSeq + 1
1149
+ }
1150
+ if (run.interferenceReport.leaks !== 0) structureChecks.push({ caseId: run.caseId, rowId: run.rowId, problem: 'global entry leaked into a private write', leaks: run.interferenceReport.leaks })
1151
+ for (const check of run.checks) {
1152
+ if (!check.credentialValid) structureChecks.push({ caseId: run.caseId, rowId: run.rowId, problem: 'invalid credential', problems: check.problems })
1153
+ }
1154
+ }
1155
+
1156
+ const reviewedCases = dataset.cases.filter(entry => entry.def.review === 'human-reviewed').length
1157
+ return {
1158
+ schemaVersion: SCHEMA_VERSION,
1159
+ kind: 'active-memory-ab',
1160
+ ok: structureChecks.length === 0,
1161
+ status: dataset.status,
1162
+ gold: false,
1163
+ goldGating: {
1164
+ humanReviewRequired: true,
1165
+ goldAllowed: false,
1166
+ reviewedCases,
1167
+ candidateCases: dataset.cases.length - reviewedCases,
1168
+ note: '合成代理标注一律 notGold。人审候选与 gold gating 分开:review 只有在人工逐条确认后才改,且本 harness 不改它。',
1169
+ },
1170
+ dataset: {
1171
+ name: dataset.name,
1172
+ status: dataset.status,
1173
+ draft: true,
1174
+ notGold: true,
1175
+ gold: false,
1176
+ review: dataset.review,
1177
+ caseCount: dataset.cases.length,
1178
+ fileCount: dataset.files.length,
1179
+ casesDigest: dataset.digest,
1180
+ files: dataset.files,
1181
+ },
1182
+ model: {
1183
+ executed: false,
1184
+ networkCalls: 0,
1185
+ realModelCalls: 0,
1186
+ requiresUserOptIn: true,
1187
+ note: '本轮不做真实模型调用;真实模型 A/B 必须由用户明确选择后另行实现。',
1188
+ },
1189
+ pipeline: {
1190
+ note: '这里只证明调度/管线与凭证规则;不证明真实模型的主功率或记忆质量。',
1191
+ productionFunctions: ['lib/active-memory.js collectEvidence/scoreEvidence/assessWake/suppressionReason', 'lib/budget.js estTokens'],
1192
+ },
1193
+ run: {
1194
+ startedAt,
1195
+ clock: '固定:每个 case 用自己事件里的时间戳,末尾空闲用 fixture 声明值',
1196
+ rowIds,
1197
+ caseLimit,
1198
+ },
1199
+ config: {
1200
+ conditions: CONDITIONS.slice(),
1201
+ rows: ROWS.map(row => ({ id: row.id, condition: row.condition, adapter: row.adapter, path: row.path })),
1202
+ legacyIdleMinutes: LEGACY_IDLE_MINUTES,
1203
+ policy: {
1204
+ version: ACTIVE_MEMORY_POLICY.version,
1205
+ minScore: ACTIVE_MEMORY_POLICY.minScore,
1206
+ starvationMinutes: ACTIVE_MEMORY_POLICY.starvationMinutes,
1207
+ largeTranscriptEvents: ACTIVE_MEMORY_POLICY.largeTranscriptEvents,
1208
+ recallCooldownMinutes: ACTIVE_MEMORY_POLICY.recallCooldownMinutes,
1209
+ weights: { ...ACTIVE_MEMORY_POLICY.weights },
1210
+ },
1211
+ },
1212
+ mainAxis: {
1213
+ subset: 'interference = off(全局干扰是独立维度,不进这张表)',
1214
+ note: 'legacy 与 guide 在这些 fixture 上的调度完全相同:guide 只改变投放的规则消息,不改变什么时候检查。两者在真实会话里的差别只能由真实模型调用测出,本 harness 不作结论。',
1215
+ rows: mainAxisRows,
1216
+ deltas,
1217
+ },
1218
+ interferenceAxis: {
1219
+ subset: 'guide-weighted:oracle 行,按全局干扰等级分组',
1220
+ rows: interferenceRows,
1221
+ },
1222
+ bounds: {
1223
+ coverageUpper: 'guide-weighted:oracle(适配器是注解预言机,给管线可行上界)',
1224
+ coverageLower: 'guide-weighted:no-value(检查总返回 no_value 时的下界)',
1225
+ note: '真实模型落在两者之间,本 harness 未测。',
1226
+ },
1227
+ unmeasured: [
1228
+ '主模型主动率(需要真实模型调用与用户明确授权)',
1229
+ 'guide 正文对模型遵从性的影响',
1230
+ '后台 worker 的语义判断质量(oracle 适配器是上界)',
1231
+ '真实 prompt 延迟与主任务阻塞耗时',
1232
+ '真实 token 消耗(这里只有 estimate)',
1233
+ ],
1234
+ structureChecks,
1235
+ cases: runs,
1236
+ }
1237
+ }
1238
+
1239
+ export function parseArgs(argv) {
1240
+ const options = {}
1241
+ for (let index = 0; index < argv.length; index += 1) {
1242
+ const arg = argv[index]
1243
+ if (REAL_MODEL_FLAGS.includes(arg)) {
1244
+ throw new Error('active-memory-ab: ' + arg + ' requires an explicit user decision and is not implemented; this harness never calls a model or the network')
1245
+ }
1246
+ if (arg === '--out') options.out = argv[++index]
1247
+ else if (arg === '--fixture-dir') {
1248
+ const dir = argv[++index]
1249
+ if (!dir || dir.startsWith('--')) throw new Error('active-memory-ab: --fixture-dir requires a directory')
1250
+ options.fixtureDir = dir
1251
+ }
1252
+ else if (arg === '--cases') {
1253
+ const limit = Number(argv[++index])
1254
+ if (!Number.isInteger(limit) || limit < 1) throw new Error('active-memory-ab: --cases requires a positive integer')
1255
+ options.caseLimit = limit
1256
+ }
1257
+ else if (arg === '--row') {
1258
+ const id = argv[++index]
1259
+ if (!ROWS.some(row => row.id === id)) throw new Error('active-memory-ab: unknown row ' + id)
1260
+ options.rowIds = (options.rowIds || []).concat([id])
1261
+ }
1262
+ else if (arg === '--digests') options.digests = true
1263
+ else if (arg === '--help' || arg === '-h') options.help = true
1264
+ else throw new Error('active-memory-ab: unknown argument ' + arg)
1265
+ }
1266
+ return options
1267
+ }
1268
+
1269
+ async function main() {
1270
+ const options = parseArgs(process.argv.slice(2))
1271
+ if (options.help) {
1272
+ console.log('Usage: node scripts/active-memory-ab.mjs [--fixture-dir DIR] [--out report.json] [--cases N] [--row ID]')
1273
+ console.log(' Deterministic replay only: no model calls, no network, no user conversation text.')
1274
+ console.log(' Rows: ' + ROWS.map(row => row.id).join(', '))
1275
+ console.log(' --digests 只打印每个 case 的 throughSeq 与快照摘要,供人工重新钉 manifest(不会自动改写)')
1276
+ return
1277
+ }
1278
+ if (options.digests) {
1279
+ const dataset = loadDataset({ fixtureDir: options.fixtureDir, verifyPins: false })
1280
+ const lines = dataset.cases.map(entry => entry.id + ' throughSeq=' + entry.snapshot.throughSeq + ' sha256=' + entry.snapshot.digest + ' (manifest pin ' + (entry.snapshot.digest === entry.manifest.snapshotSha256 ? 'matches' : 'DIFFERS: ' + entry.manifest.snapshotSha256) + ')')
1281
+ console.log(lines.join('\r\n'))
1282
+ return
1283
+ }
1284
+ const report = await runAb(options)
1285
+ const json = JSON.stringify(report, null, 2)
1286
+ if (options.out) {
1287
+ const target = path.resolve(options.out)
1288
+ fs.mkdirSync(path.dirname(target), { recursive: true })
1289
+ fs.writeFileSync(target, json.replace(/\r?\n/g, '\r\n'), 'utf8')
1290
+ console.log('active-memory-ab: wrote ' + target)
1291
+ } else {
1292
+ console.log(json)
1293
+ }
1294
+ }
1295
+
1296
+ const invoked = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url : false
1297
+ if (invoked) {
1298
+ main().catch(error => {
1299
+ console.error(JSON.stringify({ ok: false, kind: 'active-memory-ab', error: error && error.message ? error.message : String(error) }, null, 2))
1300
+ process.exitCode = 1
1301
+ })
1302
+ }