@twinsearth/dsh-master 3.2.9 → 3.3.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 (102) hide show
  1. package/README.md +39 -6
  2. package/client.js +51 -1
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +38 -11
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +89 -2
  27. package/dist-host/plugins/superagent-worker.js +1 -1
  28. package/dist-host/superagent/router.js +1 -1
  29. package/dist-host/superagent/types.js +8 -0
  30. package/dist-worker/agent-loop.js +449 -7
  31. package/dist-worker/baseline-fit.js +66 -0
  32. package/dist-worker/budget-guard.js +93 -0
  33. package/dist-worker/ccs.js +99 -0
  34. package/dist-worker/cost-hooks.js +115 -0
  35. package/dist-worker/cost-modules.js +330 -0
  36. package/dist-worker/cost-spi.js +3 -0
  37. package/dist-worker/edg.js +372 -0
  38. package/dist-worker/gdr.js +159 -0
  39. package/dist-worker/index.js +80 -2
  40. package/dist-worker/llm-http.js +20 -0
  41. package/dist-worker/sink-http.js +1 -0
  42. package/dist-worker/spill.js +115 -0
  43. package/dist-worker/tool-batch.js +66 -0
  44. package/dist-worker/tool-compact.js +275 -0
  45. package/dist-worker/tool-disclosure.js +90 -0
  46. package/dist-worker/tools.js +16 -0
  47. package/docs/ARCHITECTURE.md +32 -11
  48. package/docs/CHANGELOG.md +60 -0
  49. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  50. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  51. package/docs/RELEASE-v3.2.10.md +32 -0
  52. package/docs/ROADMAP.md +3 -3
  53. package/docs/SECURITY.md +9 -9
  54. package/docs/SUPERAGENT.md +5 -5
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/docs/WORKER-AGENT-LOOP.md +1 -1
  57. package/package.json +15 -3
  58. package/src/cost/attribution.ts +186 -0
  59. package/src/cost/audit.ts +170 -0
  60. package/src/cost/budget.ts +226 -0
  61. package/src/cost/bus.ts +97 -0
  62. package/src/cost/cert-chain.ts +102 -0
  63. package/src/cost/experiment.ts +136 -0
  64. package/src/cost/gate.ts +191 -0
  65. package/src/cost/host-surface.ts +97 -0
  66. package/src/cost/index.ts +316 -0
  67. package/src/cost/ledger-report.ts +186 -0
  68. package/src/cost/ledger.ts +385 -0
  69. package/src/cost/metrics.ts +193 -0
  70. package/src/cost/milestones.ts +170 -0
  71. package/src/cost/modules/index.ts +13 -0
  72. package/src/cost/modules/manifests.ts +129 -0
  73. package/src/cost/policy.ts +276 -0
  74. package/src/cost/report.ts +158 -0
  75. package/src/cost/reuse-report.ts +131 -0
  76. package/src/cost/rsi.ts +364 -0
  77. package/src/cost/toolface.ts +151 -0
  78. package/src/cost/types.ts +298 -0
  79. package/src/kernel.ts +37 -11
  80. package/src/plugins/cost-optimizer.ts +271 -26
  81. package/src/plugins/superagent-router.ts +83 -2
  82. package/src/plugins/superagent-worker.ts +1 -1
  83. package/src/superagent/router.ts +1 -1
  84. package/src/superagent/types.ts +70 -0
  85. package/worker/agent-loop.ts +444 -7
  86. package/worker/baseline-fit.ts +80 -0
  87. package/worker/budget-guard.ts +156 -0
  88. package/worker/ccs.ts +202 -0
  89. package/worker/cost-hooks.ts +133 -0
  90. package/worker/cost-modules.ts +356 -0
  91. package/worker/cost-spi.ts +283 -0
  92. package/worker/edg.ts +424 -0
  93. package/worker/gdr.ts +233 -0
  94. package/worker/index.ts +84 -3
  95. package/worker/llm-http.ts +18 -0
  96. package/worker/sink-http.ts +5 -2
  97. package/worker/spill.ts +133 -0
  98. package/worker/tool-batch.ts +124 -0
  99. package/worker/tool-compact.ts +317 -0
  100. package/worker/tool-disclosure.ts +131 -0
  101. package/worker/tools.ts +553 -531
  102. package/worker/types.ts +191 -0
@@ -0,0 +1,385 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * 3.3.0 系统层 · ledger(计量账本,设计 §5.1)。
7
+ *
8
+ * 按**事件**记账(不是按聚合),每个事件带 task/fork/session/turn/step/phase,
9
+ * 因此可回放、可按维度查询。
10
+ *
11
+ * 口径纪律:
12
+ * * 两本账 `execution` / `governance` 分开;`netSaved` 只减 governance;
13
+ * * 作用面 `worker` / `host` 分开,外部适配器(`external`)永不计入本项目节省;
14
+ * * 估算 token(provider 未返回 usage 的兜底)标记 `estimated: true`,
15
+ * 报表中与实测**分列**、不相加。
16
+ *
17
+ * 存储:进程内环形缓冲(有界)+ 可选 JSONL 落盘(`<dataDir>/cost-ledger.jsonl`)。
18
+ * 选择「逐行追加」而不是整文件重写:账本只增不改,单行 append 是 O(1);
19
+ * 整文件 tmp+rename(ticket-store 的风格)会让每次记账变成 O(n),账本量大时不可接受。
20
+ * 追加失败不影响主流程,只累加 `failed` 计数(成本记账不得打断执行)。
21
+ */
22
+
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+
26
+ import { COST_MODULES, COST_SURFACES, COST_PHASES } from './types.ts'
27
+
28
+ /** source → phase 的缺省推导(调用方也可显式给 phase)。 */
29
+ const PHASE_OF_SOURCE = Object.freeze({
30
+ model: 'completion',
31
+ tool: 'tool',
32
+ substitution: 'substitution',
33
+ retry: 'prompt',
34
+ authoring: 'authoring',
35
+ offline: 'authoring',
36
+ })
37
+
38
+ const LEDGER_FILE = 'cost-ledger.jsonl'
39
+
40
+ function finiteTokens(value) {
41
+ const n = Number(value)
42
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null
43
+ }
44
+
45
+ function emptyBlock() {
46
+ return { input: 0, output: 0, total: 0, saved: 0, entries: 0 }
47
+ }
48
+
49
+ function addToBlock(block, entry) {
50
+ block.input += entry.input
51
+ block.output += entry.output
52
+ block.total += entry.total
53
+ block.saved += entry.savedTokens
54
+ block.entries += 1
55
+ }
56
+
57
+ const str = (value) => (value === undefined || value === null ? null : String(value))
58
+ const int = (value, fallback = 0) => {
59
+ const n = Number(value)
60
+ return Number.isFinite(n) ? Math.trunc(n) : fallback
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * now?: () => number, maxEntries?: number, maxTasks?: number,
66
+ * dataDir?: string, persist?: boolean, logger?: { warn?: Function },
67
+ * }} [options]
68
+ */
69
+ export function createLedger(options = {}) {
70
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
71
+ const maxEntries = Math.max(1, Number(options.maxEntries ?? 5000) || 5000)
72
+ const maxTasks = Math.max(1, Number(options.maxTasks ?? 200) || 200)
73
+ const dataDir = options.dataDir === undefined || options.dataDir === null ? '' : String(options.dataDir).trim()
74
+ const persistEnabled = options.persist === true && dataDir !== ''
75
+ const file = persistEnabled ? path.join(dataDir, LEDGER_FILE) : null
76
+ let persistedAppends = 0
77
+ let persistedFailures = 0
78
+
79
+ const entries = []
80
+ const totals = { execution: emptyBlock(), governance: emptyBlock() }
81
+ const byModule = new Map()
82
+ const byTask = new Map()
83
+ const byFork = new Map()
84
+ /** 按作用面拆分(worker = 本项目自研;host = 外部适配器,只展示不并入)。 */
85
+ const bySurface = new Map()
86
+ /** 本项目自己的执行节省(worker 面、非外部)与外部适配器自报节省(分开累计)。 */
87
+ let ownExecutionSaved = 0
88
+ let externalExecutionSaved = 0
89
+ /** 实测 vs 估算(不混算)。 */
90
+ const estimateSplit = { measuredTokens: 0, measuredEntries: 0, estimatedTokens: 0, estimatedEntries: 0 }
91
+ let dropped = 0
92
+ let seq = 0
93
+
94
+ const moduleRow = (module) => {
95
+ if (!byModule.has(module)) byModule.set(module, emptyBlock())
96
+ return byModule.get(module)
97
+ }
98
+ const taskRow = (key) => {
99
+ if (!byTask.has(key)) byTask.set(key, emptyBlock())
100
+ return byTask.get(key)
101
+ }
102
+ const forkRow = (key) => {
103
+ if (!byFork.has(key)) byFork.set(key, emptyBlock())
104
+ return byFork.get(key)
105
+ }
106
+ const surfaceRow = (surface, external) => {
107
+ const key = `${surface}|${external ? 'external' : 'own'}`
108
+ if (!bySurface.has(key)) bySurface.set(key, { ...emptyBlock(), surface, external })
109
+ return bySurface.get(key)
110
+ }
111
+
112
+ /** 单行追加;失败只计数,不影响主流程。回放期间不重复落盘。 */
113
+ let replaying = false
114
+ function persistLine(entry) {
115
+ if (!file || replaying) return
116
+ try {
117
+ fs.mkdirSync(dataDir, { recursive: true })
118
+ const line = `${JSON.stringify({ v: 1, ...entry })}\n`
119
+ // 单次 append 调用:账本只增不改,避免整文件重写。
120
+ fs.appendFileSync(file, line, 'utf8')
121
+ persistedAppends += 1
122
+ } catch (error) {
123
+ persistedFailures += 1
124
+ try { options.logger?.warn?.(`[cost-ledger] append failed: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
125
+ }
126
+ }
127
+
128
+ function append(input = {}) {
129
+ const account = String(input.account ?? '')
130
+ if (account !== 'execution' && account !== 'governance') {
131
+ return { ok: false, code: 'account-invalid', message: 'account must be execution or governance' }
132
+ }
133
+ const source = input.source === undefined ? 'model' : String(input.source)
134
+ if (!Object.prototype.hasOwnProperty.call(PHASE_OF_SOURCE, source)) {
135
+ return { ok: false, code: 'source-invalid', message: `source must be one of ${Object.keys(PHASE_OF_SOURCE).join('/')}` }
136
+ }
137
+ const phase = input.phase === undefined ? PHASE_OF_SOURCE[source] : String(input.phase)
138
+ if (!COST_PHASES.includes(phase)) {
139
+ return { ok: false, code: 'phase-invalid', message: `phase must be one of ${COST_PHASES.join('/')}` }
140
+ }
141
+ const module = input.module === undefined ? 'unattributed' : String(input.module)
142
+ if (!COST_MODULES.includes(module)) {
143
+ return { ok: false, code: 'module-invalid', message: `module must be one of ${COST_MODULES.join('/')}` }
144
+ }
145
+ const surface = input.surface === undefined ? 'worker' : String(input.surface)
146
+ if (!COST_SURFACES.includes(surface)) {
147
+ return { ok: false, code: 'surface-invalid', message: `surface must be one of ${COST_SURFACES.join('/')}` }
148
+ }
149
+ const external = input.external === true
150
+ const inputTokens = finiteTokens(input.inputTokens ?? 0)
151
+ const outputTokens = finiteTokens(input.outputTokens ?? 0)
152
+ const cachedTokens = finiteTokens(input.cachedTokens ?? 0)
153
+ if (inputTokens === null) return { ok: false, code: 'input-tokens-invalid', message: 'inputTokens must be a non-negative number' }
154
+ if (outputTokens === null) return { ok: false, code: 'output-tokens-invalid', message: 'outputTokens must be a non-negative number' }
155
+ if (cachedTokens === null) return { ok: false, code: 'cached-tokens-invalid', message: 'cachedTokens must be a non-negative number' }
156
+ const totalInput = input.totalTokens === undefined ? inputTokens + outputTokens : finiteTokens(input.totalTokens)
157
+ if (totalInput === null) return { ok: false, code: 'total-tokens-invalid', message: 'totalTokens must be a non-negative number' }
158
+ if (totalInput === 0 && inputTokens + outputTokens > 0) {
159
+ return { ok: false, code: 'total-tokens-invalid', message: 'totalTokens is zero while input/output tokens are not' }
160
+ }
161
+ const baseline = input.baselineTokens === undefined ? null : finiteTokens(input.baselineTokens)
162
+ if (baseline === null && input.baselineTokens !== undefined) {
163
+ return { ok: false, code: 'baseline-tokens-invalid', message: 'baselineTokens must be a non-negative number' }
164
+ }
165
+ const explicitSaved = input.savedTokens === undefined ? null : finiteTokens(input.savedTokens)
166
+ if (explicitSaved === null && input.savedTokens !== undefined) {
167
+ return { ok: false, code: 'saved-tokens-invalid', message: 'savedTokens must be a non-negative number' }
168
+ }
169
+ // 显式 savedTokens 优先(归因条目 total=0,只能由调用方给出节省);
170
+ // 只有 baseline 时按 baseline - total 推导;两者都没有则 0。
171
+ const savedTokens = explicitSaved !== null
172
+ ? explicitSaved
173
+ : (baseline === null ? 0 : Math.max(0, baseline - totalInput))
174
+ const estimated = input.estimated === true
175
+
176
+ const at = now()
177
+ seq += 1
178
+ const entry = Object.freeze({
179
+ id: `cost_${at.toString(36)}_${seq.toString(36)}`,
180
+ at,
181
+ account,
182
+ module,
183
+ source,
184
+ phase,
185
+ surface,
186
+ external,
187
+ taskId: str(input.taskId),
188
+ forkId: str(input.forkId),
189
+ sessionId: str(input.sessionId),
190
+ ticketId: str(input.ticketId),
191
+ turn: int(input.turn),
192
+ step: int(input.step),
193
+ provider: str(input.provider),
194
+ model: str(input.model),
195
+ toolName: str(input.toolName),
196
+ input: inputTokens,
197
+ output: outputTokens,
198
+ cached: cachedTokens,
199
+ total: totalInput,
200
+ baselineTokens: baseline,
201
+ savedTokens,
202
+ estimated,
203
+ meta: Object.freeze({ ...(input.meta ?? {}) }),
204
+ })
205
+
206
+ entries.push(entry)
207
+ if (entries.length > maxEntries) {
208
+ entries.shift()
209
+ dropped += 1
210
+ }
211
+ addToBlock(totals[account], entry)
212
+ addToBlock(moduleRow(module), entry)
213
+ addToBlock(surfaceRow(surface, external), entry)
214
+ addToBlock(forkRow(entry.forkId ?? 'unattributed'), entry)
215
+ const taskKey = entry.taskId ?? entry.ticketId ?? 'unattributed'
216
+ addToBlock(taskRow(taskKey), entry)
217
+ while (byTask.size > maxTasks) {
218
+ const oldest = byTask.keys().next().value
219
+ byTask.delete(oldest)
220
+ }
221
+ if (account === 'execution') {
222
+ if (external) externalExecutionSaved += savedTokens
223
+ else if (surface === 'worker') ownExecutionSaved += savedTokens
224
+ }
225
+ if (estimated) {
226
+ estimateSplit.estimatedTokens += totalInput
227
+ estimateSplit.estimatedEntries += 1
228
+ } else {
229
+ estimateSplit.measuredTokens += totalInput
230
+ estimateSplit.measuredEntries += 1
231
+ }
232
+ persistLine(entry)
233
+ return { ok: true, entry }
234
+ }
235
+
236
+ function snapshot() {
237
+ const execution = Object.freeze({ ...totals.execution })
238
+ const governance = Object.freeze({ ...totals.governance })
239
+ const bySurfaceRows = [...bySurface.values()].map((row) => Object.freeze({ ...row })).sort((a, b) => b.total - a.total)
240
+ const byModuleRows = [...byModule.entries()].map(([module, block]) => Object.freeze({ module, ...block })).sort((a, b) => b.total - a.total)
241
+ const byTaskRows = [...byTask.entries()].map(([key, block]) => Object.freeze({ key, ...block })).sort((a, b) => b.total - a.total)
242
+ const byForkRows = [...byFork.entries()].map(([forkId, block]) => Object.freeze({ forkId, ...block })).sort((a, b) => b.total - a.total)
243
+ const allTokens = estimateSplit.measuredTokens + estimateSplit.estimatedTokens
244
+ return Object.freeze({
245
+ ok: true,
246
+ count: entries.length,
247
+ dropped,
248
+ execution,
249
+ governance,
250
+ /** 只算本项目(worker 面)的执行节省减去治理成本;外部适配器不参与。 */
251
+ netSaved: ownExecutionSaved - governance.total,
252
+ ownExecutionSaved,
253
+ externalSaved: externalExecutionSaved,
254
+ bySurface: bySurfaceRows,
255
+ byModule: byModuleRows,
256
+ byTask: byTaskRows,
257
+ byFork: byForkRows,
258
+ tokens: Object.freeze({
259
+ ...estimateSplit,
260
+ estimatedRatio: allTokens > 0 ? estimateSplit.estimatedTokens / allTokens : 0,
261
+ }),
262
+ persistence: Object.freeze({
263
+ enabled: persistEnabled,
264
+ file,
265
+ appended: persistedAppends,
266
+ failed: persistedFailures,
267
+ }),
268
+ })
269
+ }
270
+
271
+ /**
272
+ * 按维度查询(设计 §5.1):task / fork / session / module / account / phase / 时间窗。
273
+ * 返回最近 limit 条(按时间正序)。
274
+ */
275
+ function query(filter = {}) {
276
+ const limit = Math.max(1, Number(filter.limit ?? 200) || 200)
277
+ const matches = entries.filter((entry) => {
278
+ if (filter.taskId !== undefined && entry.taskId !== String(filter.taskId)) return false
279
+ if (filter.forkId !== undefined && entry.forkId !== String(filter.forkId)) return false
280
+ if (filter.sessionId !== undefined && entry.sessionId !== String(filter.sessionId)) return false
281
+ if (filter.ticketId !== undefined && entry.ticketId !== String(filter.ticketId)) return false
282
+ if (filter.module !== undefined && entry.module !== String(filter.module)) return false
283
+ if (filter.account !== undefined && entry.account !== String(filter.account)) return false
284
+ if (filter.phase !== undefined && entry.phase !== String(filter.phase)) return false
285
+ if (filter.surface !== undefined && entry.surface !== String(filter.surface)) return false
286
+ if (filter.external !== undefined && entry.external !== filter.external) return false
287
+ if (filter.estimated !== undefined && entry.estimated !== filter.estimated) return false
288
+ if (filter.from !== undefined && entry.at < Number(filter.from)) return false
289
+ if (filter.to !== undefined && entry.at > Number(filter.to)) return false
290
+ return true
291
+ })
292
+ const rows = matches.slice(-limit)
293
+ return Object.freeze({
294
+ ok: true,
295
+ matched: matches.length,
296
+ returned: rows.length,
297
+ tokens: rows.reduce((sum, entry) => sum + entry.total, 0),
298
+ saved: rows.reduce((sum, entry) => sum + entry.savedTokens, 0),
299
+ entries: rows.slice(),
300
+ })
301
+ }
302
+
303
+ /** 从 JSONL 重新装载(进程重启后回放);只读文件,不改内存中的既有事件。 */
304
+ function replay(direction = 'forward') {
305
+ if (!file) return { ok: false, code: 'persistence-disabled', message: 'ledger has no dataDir' }
306
+ let text
307
+ try {
308
+ text = fs.readFileSync(file, 'utf8')
309
+ } catch (error) {
310
+ if (error?.code === 'ENOENT') return { ok: true, loaded: 0, skipped: 0 }
311
+ return { ok: false, code: 'read-failed', message: String(error?.message ?? error) }
312
+ }
313
+ const lines = text.split('\n').filter((line) => line.trim() !== '')
314
+ const ordered = direction === 'reverse' ? [...lines].reverse() : lines
315
+ let loaded = 0
316
+ let skipped = 0
317
+ replaying = true
318
+ try {
319
+ for (const line of ordered) {
320
+ try {
321
+ const parsed = JSON.parse(line)
322
+ if (!parsed || typeof parsed !== 'object') { skipped += 1; continue }
323
+ const result = append({
324
+ account: parsed.account,
325
+ module: parsed.module,
326
+ source: parsed.source,
327
+ phase: parsed.phase,
328
+ surface: parsed.surface,
329
+ external: parsed.external,
330
+ taskId: parsed.taskId ?? undefined,
331
+ forkId: parsed.forkId ?? undefined,
332
+ sessionId: parsed.sessionId ?? undefined,
333
+ ticketId: parsed.ticketId ?? undefined,
334
+ turn: parsed.turn,
335
+ step: parsed.step,
336
+ provider: parsed.provider ?? undefined,
337
+ model: parsed.model ?? undefined,
338
+ toolName: parsed.toolName ?? undefined,
339
+ inputTokens: parsed.input,
340
+ outputTokens: parsed.output,
341
+ cachedTokens: parsed.cached,
342
+ totalTokens: parsed.total,
343
+ baselineTokens: parsed.baselineTokens ?? undefined,
344
+ savedTokens: parsed.savedTokens,
345
+ estimated: parsed.estimated,
346
+ meta: parsed.meta,
347
+ })
348
+ if (result.ok) loaded += 1
349
+ else skipped += 1
350
+ } catch {
351
+ skipped += 1
352
+ }
353
+ }
354
+ } finally {
355
+ replaying = false
356
+ }
357
+ return { ok: true, loaded, skipped }
358
+ }
359
+
360
+ function list(limit = 50) {
361
+ const n = Math.max(1, Number(limit) || 50)
362
+ return entries.slice(-n)
363
+ }
364
+
365
+ function reset() {
366
+ entries.length = 0
367
+ byModule.clear()
368
+ byTask.clear()
369
+ byFork.clear()
370
+ bySurface.clear()
371
+ Object.assign(totals.execution, emptyBlock())
372
+ Object.assign(totals.governance, emptyBlock())
373
+ ownExecutionSaved = 0
374
+ externalExecutionSaved = 0
375
+ estimateSplit.measuredTokens = 0
376
+ estimateSplit.measuredEntries = 0
377
+ estimateSplit.estimatedTokens = 0
378
+ estimateSplit.estimatedEntries = 0
379
+ dropped = 0
380
+ seq = 0
381
+ return { ok: true }
382
+ }
383
+
384
+ return Object.freeze({ append, snapshot, query, replay, list, reset })
385
+ }
@@ -0,0 +1,193 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * 3.3.0 · metrics(验收指标,设计 §10.1)。
7
+ *
8
+ * **每份报告必须给全**七项指标;缺任何一项都必须显式报 `complete: false` + `missing[]`,
9
+ * 不允许"报一半也算报告"。
10
+ *
11
+ * | 指标 | 口径 |
12
+ * |---|---|
13
+ * | tokens/task | 完整任务输入+输出,含重试/回退/失败执行 |
14
+ * | 成功率 | 与对照组同 scorer;差值 ≥2pp 下降即触发降级审查 |
15
+ * | GDR 命中率 / 未命中分布 | 每次复用尝试为分母 |
16
+ * | CCS 替换数 / 证书完整率 | 证书必须 100% 可回放 |
17
+ * | 节省归因 | A1/A2/A3/B1–B4 分列 + unattributed |
18
+ * | governance 成本 | 作者步骤、RSI 离线、选择/验证 |
19
+ * | 耗时 | wall-clock 中位数与 p95 |
20
+ */
21
+
22
+ export const METRIC_KEYS = Object.freeze([
23
+ 'tokensPerTask',
24
+ 'success',
25
+ 'gdr',
26
+ 'ccs',
27
+ 'attribution',
28
+ 'governance',
29
+ 'latency',
30
+ ])
31
+
32
+ /** 成功率相对对照组下降 ≥2pp ⇒ 触发降级审查(§10.1)。 */
33
+ export const SUCCESS_REGRESSION_PP = 0.02
34
+
35
+ const MODULES = Object.freeze(['a1-compact', 'a2-disclosure', 'a3-batch', 'b1-edg', 'b2-gdr', 'b3-ccs', 'b4-rsi', 'unattributed'])
36
+
37
+ function num(value) {
38
+ const n = Number(value)
39
+ return Number.isFinite(n) ? n : 0
40
+ }
41
+
42
+ function percentile(sorted, ratio) {
43
+ if (sorted.length === 0) return null
44
+ if (sorted.length === 1) return sorted[0]
45
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1))
46
+ return sorted[index]
47
+ }
48
+
49
+ function median(values) {
50
+ const sorted = [...values].sort((a, b) => a - b)
51
+ return percentile(sorted, 0.5)
52
+ }
53
+
54
+ function p95(values) {
55
+ const sorted = [...values].sort((a, b) => a - b)
56
+ return percentile(sorted, 0.95)
57
+ }
58
+
59
+ /**
60
+ * @param {{
61
+ * entries?: object[],
62
+ * certificates?: object[],
63
+ * tasks?: Array<{taskId: string, success?: boolean, elapsedMs?: number, tokens?: number}>,
64
+ * control?: { successRate?: number, tokensPerTask?: number },
65
+ * guard?: { attempts?: number, hits?: number, byReason?: object },
66
+ * audit?: { ok?: boolean, count?: number },
67
+ * traceCheck?: (taskId: string) => { reconciled?: boolean },
68
+ * }} [input]
69
+ */
70
+ export function buildMetricsReport(input = {}) {
71
+ const entries = Array.isArray(input.entries) ? input.entries : []
72
+ const certificates = Array.isArray(input.certificates) ? input.certificates : []
73
+ const tasks = Array.isArray(input.tasks) ? input.tasks : []
74
+ const missing = []
75
+
76
+ // ---- tokens/task:完整任务输入+输出(含重试/回退/失败执行)----
77
+ const tokensByTask = new Map()
78
+ for (const entry of entries) {
79
+ if (entry?.account !== 'execution') continue
80
+ const key = String(entry.taskId ?? 'unattributed')
81
+ tokensByTask.set(key, (tokensByTask.get(key) ?? 0) + Math.max(0, num(entry.total)))
82
+ }
83
+ const taskTokens = (tasks.length > 0
84
+ ? tasks.map((task) => Math.max(0, num(task.tokens) || (tokensByTask.get(String(task.taskId)) ?? 0)))
85
+ : [...tokensByTask.values()])
86
+ // 既有任务显式 tokens、也有账本 token 事件,才算「有 token 数据」;否则该项缺失。
87
+ const hasTokenData = tokensByTask.size > 0 || tasks.some((task) => num(task?.tokens) > 0)
88
+ const tokensPerTask = hasTokenData && taskTokens.length > 0
89
+ ? Math.round((taskTokens.reduce((sum, value) => sum + value, 0) / taskTokens.length) * 100) / 100
90
+ : null
91
+ if (tokensPerTask === null) missing.push('tokensPerTask')
92
+
93
+ // ---- 成功率:与对照组同 scorer;≥2pp 下降触发审查 ----
94
+ const scored = tasks.filter((task) => typeof task?.success === 'boolean')
95
+ const successRate = scored.length > 0 ? scored.filter((task) => task.success === true).length / scored.length : null
96
+ if (successRate === null) missing.push('success')
97
+ const controlRate = Number.isFinite(Number(input?.control?.successRate)) ? Number(input.control.successRate) : null
98
+ const successDelta = successRate !== null && controlRate !== null ? Math.round((successRate - controlRate) * 10000) / 10000 : null
99
+ const regression = successDelta !== null && successDelta <= -SUCCESS_REGRESSION_PP
100
+ const success = {
101
+ rate: successRate,
102
+ samples: scored.length,
103
+ scorer: 'same-as-control',
104
+ control: controlRate,
105
+ delta: successDelta,
106
+ /** §10.1:差值 ≥2pp 下降 ⇒ 触发降级审查。 */
107
+ reviewRequired: regression,
108
+ thresholdPp: SUCCESS_REGRESSION_PP,
109
+ }
110
+
111
+ // ---- GDR:每次复用尝试为分母 ----
112
+ const attempts = Math.max(0, num(input?.guard?.attempts))
113
+ const hits = Math.max(0, num(input?.guard?.hits))
114
+ const byReason = { ...(input?.guard?.byReason ?? {}) }
115
+ const gdr = {
116
+ attempts,
117
+ hits,
118
+ hitRate: attempts > 0 ? Math.round((hits / attempts) * 10000) / 10000 : null,
119
+ byReason,
120
+ denominator: 'reuse-attempts',
121
+ }
122
+ if (attempts === 0) missing.push('gdr')
123
+
124
+ // ---- CCS:替换数 + 证书完整率(必须 100% 可回放)----
125
+ const dispatched = certificates.filter((cert) => cert?.dispatched !== false)
126
+ const REQUIRED = ['edgeId', 'toolName', 'schemaHash', 'guardExpr', 'receiptDigest', 'replacedCallbackId', 'taskId']
127
+ const complete = certificates.filter((cert) => REQUIRED.every((key) => String(cert?.[key] ?? '').trim() !== '') && cert?.viaNativeDispatcher === true)
128
+ const reconciled = certificates.filter((cert) => {
129
+ if (typeof input?.traceCheck !== 'function') return true
130
+ try { return input.traceCheck(String(cert?.taskId ?? ''))?.reconciled === true } catch { return false }
131
+ })
132
+ const completeness = certificates.length > 0 ? Math.round((complete.length / certificates.length) * 10000) / 10000 : null
133
+ const ccs = {
134
+ substitutions: dispatched.length,
135
+ certificates: certificates.length,
136
+ completeness,
137
+ replayable: completeness === 1,
138
+ reconciled: certificates.length === 0 ? null : reconciled.length === certificates.length,
139
+ auditVerified: input?.audit?.ok !== false,
140
+ }
141
+ if (certificates.length === 0) missing.push('ccs')
142
+
143
+ // ---- 节省归因:按模块分列 + unattributed ----
144
+ const byModule = {}
145
+ for (const key of MODULES) byModule[key] = 0
146
+ for (const entry of entries) {
147
+ if (entry?.account !== 'execution' || !(num(entry?.savedTokens) > 0)) continue
148
+ const moduleKey = MODULES.includes(String(entry.module)) ? String(entry.module) : 'unattributed'
149
+ byModule[moduleKey] += Math.max(0, num(entry.savedTokens))
150
+ }
151
+ const attribution = { byModule, total: Object.values(byModule).reduce((sum, value) => sum + value, 0) }
152
+ if (attribution.total === 0) missing.push('attribution')
153
+
154
+ // ---- governance:作者 / RSI 离线 / 选择与验证 ----
155
+ const governanceByKind = { author: 0, rsiOffline: 0, selection: 0, other: 0 }
156
+ for (const entry of entries) {
157
+ if (entry?.account !== 'governance') continue
158
+ const tokens = Math.max(0, num(entry.total))
159
+ const kind = String(entry.meta?.kind ?? entry.source ?? 'other')
160
+ if (kind.includes('edg') || kind.includes('author')) governanceByKind.author += tokens
161
+ else if (kind.includes('rsi') || String(entry.source) === 'offline') governanceByKind.rsiOffline += tokens
162
+ else if (kind.includes('select') || kind.includes('valid') || kind.includes('load')) governanceByKind.selection += tokens
163
+ else governanceByKind.other += tokens
164
+ }
165
+ const governance = {
166
+ ...governanceByKind,
167
+ total: Object.values(governanceByKind).reduce((sum, value) => sum + value, 0),
168
+ }
169
+
170
+ // ---- 耗时:中位数 + p95 ----
171
+ const durations = tasks.map((task) => num(task?.elapsedMs)).filter((value) => value > 0)
172
+ const latency = {
173
+ medianMs: durations.length > 0 ? median(durations) : null,
174
+ p95Ms: durations.length > 0 ? p95(durations) : null,
175
+ samples: durations.length,
176
+ }
177
+ if (durations.length === 0) missing.push('latency')
178
+
179
+ const uniqueMissing = METRIC_KEYS.filter((key) => missing.includes(key))
180
+ return Object.freeze({
181
+ ok: true,
182
+ complete: uniqueMissing.length === 0,
183
+ missing: uniqueMissing,
184
+ tokensPerTask: Object.freeze({ value: tokensPerTask, includesRetries: true, includesFailures: true, samples: taskTokens.length }),
185
+ success: Object.freeze(success),
186
+ gdr: Object.freeze(gdr),
187
+ ccs: Object.freeze(ccs),
188
+ attribution: Object.freeze({ ...attribution, byModule: Object.freeze({ ...byModule }) }),
189
+ governance: Object.freeze(governance),
190
+ latency: Object.freeze(latency),
191
+ control: Object.freeze({ successRate: controlRate, tokensPerTask: Number.isFinite(Number(input?.control?.tokensPerTask)) ? Number(input.control.tokensPerTask) : null }),
192
+ })
193
+ }