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