@twinsearth/dsh-master 3.2.10 → 3.3.1

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 (104) hide show
  1. package/README.md +4 -0
  2. package/client.js +50 -0
  3. package/dist-host/contract/client.js +7 -0
  4. package/dist-host/contract/constants.js +16 -2
  5. package/dist-host/contract/index.js +1 -0
  6. package/dist-host/contract/relay.js +239 -0
  7. package/dist-host/cost/attribution.js +183 -0
  8. package/dist-host/cost/audit.js +163 -0
  9. package/dist-host/cost/budget.js +211 -0
  10. package/dist-host/cost/bus.js +95 -0
  11. package/dist-host/cost/cert-chain.js +100 -0
  12. package/dist-host/cost/experiment.js +132 -0
  13. package/dist-host/cost/fang-relay.js +329 -0
  14. package/dist-host/cost/gate.js +189 -0
  15. package/dist-host/cost/host-surface.js +80 -0
  16. package/dist-host/cost/index.js +345 -0
  17. package/dist-host/cost/ledger-report.js +193 -0
  18. package/dist-host/cost/ledger.js +401 -0
  19. package/dist-host/cost/metrics.js +198 -0
  20. package/dist-host/cost/milestones.js +209 -0
  21. package/dist-host/cost/modules/index.js +5 -0
  22. package/dist-host/cost/modules/manifests.js +130 -0
  23. package/dist-host/cost/policy.js +276 -0
  24. package/dist-host/cost/report.js +156 -0
  25. package/dist-host/cost/reuse-report.js +128 -0
  26. package/dist-host/cost/rsi.js +367 -0
  27. package/dist-host/cost/toolface.js +147 -0
  28. package/dist-host/cost/types.js +31 -0
  29. package/dist-host/kernel.js +28 -2
  30. package/dist-host/plugins/cost-optimizer.js +329 -27
  31. package/dist-host/plugins/superagent-router.js +104 -1
  32. package/dist-host/superagent/types.js +8 -0
  33. package/dist-worker/agent-loop.js +449 -7
  34. package/dist-worker/baseline-fit.js +66 -0
  35. package/dist-worker/budget-guard.js +93 -0
  36. package/dist-worker/ccs.js +99 -0
  37. package/dist-worker/cost-hooks.js +115 -0
  38. package/dist-worker/cost-modules.js +330 -0
  39. package/dist-worker/cost-spi.js +3 -0
  40. package/dist-worker/edg.js +372 -0
  41. package/dist-worker/gdr.js +159 -0
  42. package/dist-worker/index.js +80 -2
  43. package/dist-worker/llm-http.js +20 -0
  44. package/dist-worker/sink-http.js +1 -0
  45. package/dist-worker/spill.js +115 -0
  46. package/dist-worker/tool-batch.js +66 -0
  47. package/dist-worker/tool-compact.js +275 -0
  48. package/dist-worker/tool-disclosure.js +90 -0
  49. package/dist-worker/tools.js +16 -0
  50. package/docs/ARCHITECTURE.md +24 -0
  51. package/docs/CHANGELOG.md +66 -0
  52. package/docs/DESIGN-v3.3.0-cost-optimization.md +1058 -0
  53. package/docs/EXPERIMENT-M5-fang-relay.md +117 -0
  54. package/docs/EXPERIMENT-M5-real-ab.md +87 -0
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/package.json +2 -2
  57. package/src/contract/client.js +7 -0
  58. package/src/contract/constants.js +17 -2
  59. package/src/contract/index.js +1 -0
  60. package/src/contract/relay.js +223 -0
  61. package/src/cost/attribution.ts +186 -0
  62. package/src/cost/audit.ts +170 -0
  63. package/src/cost/budget.ts +226 -0
  64. package/src/cost/bus.ts +97 -0
  65. package/src/cost/cert-chain.ts +102 -0
  66. package/src/cost/experiment.ts +136 -0
  67. package/src/cost/fang-relay.ts +318 -0
  68. package/src/cost/gate.ts +191 -0
  69. package/src/cost/host-surface.ts +97 -0
  70. package/src/cost/index.ts +348 -0
  71. package/src/cost/ledger-report.ts +186 -0
  72. package/src/cost/ledger.ts +385 -0
  73. package/src/cost/metrics.ts +193 -0
  74. package/src/cost/milestones.ts +206 -0
  75. package/src/cost/modules/index.ts +13 -0
  76. package/src/cost/modules/manifests.ts +129 -0
  77. package/src/cost/policy.ts +276 -0
  78. package/src/cost/report.ts +158 -0
  79. package/src/cost/reuse-report.ts +131 -0
  80. package/src/cost/rsi.ts +364 -0
  81. package/src/cost/toolface.ts +151 -0
  82. package/src/cost/types.ts +298 -0
  83. package/src/kernel.ts +27 -2
  84. package/src/plugins/cost-optimizer.ts +313 -26
  85. package/src/plugins/superagent-router.ts +96 -1
  86. package/src/superagent/types.ts +70 -0
  87. package/worker/agent-loop.ts +444 -7
  88. package/worker/baseline-fit.ts +80 -0
  89. package/worker/budget-guard.ts +156 -0
  90. package/worker/ccs.ts +202 -0
  91. package/worker/cost-hooks.ts +133 -0
  92. package/worker/cost-modules.ts +356 -0
  93. package/worker/cost-spi.ts +283 -0
  94. package/worker/edg.ts +424 -0
  95. package/worker/gdr.ts +233 -0
  96. package/worker/index.ts +84 -3
  97. package/worker/llm-http.ts +18 -0
  98. package/worker/sink-http.ts +5 -2
  99. package/worker/spill.ts +133 -0
  100. package/worker/tool-batch.ts +124 -0
  101. package/worker/tool-compact.ts +317 -0
  102. package/worker/tool-disclosure.ts +131 -0
  103. package/worker/tools.ts +553 -531
  104. package/worker/types.ts +191 -0
@@ -0,0 +1,156 @@
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 系统层 · report(两条链路联动报表,设计 §8)。
6
+ *
7
+ * 两条链路的节省**必须分列、且不重复计**:
8
+ *
9
+ * * **单价项(A)**:A1 压缩回执 → 每次交互更便宜。来源 = execution 账里 `phase:'tool'`
10
+ * 的压缩归因条目(`meta.kind` 为 attribution)。
11
+ * * **次数项(B)**:B2/B3 命中 → 少调一次模型。来源 = **替换证书**(不是账本),
12
+ * 每个 dispatched 证书代表一次被跳过的回调,金额 = 该证书的 `savedTokens`(基线单价)。
13
+ *
14
+ * 总节省 = Σ(跳过的调用数 × 该步基线单价) + Σ(每次交互的单价下降),两项分列。
15
+ *
16
+ * 作者的代价必须摊进去:EDG 编写与 RSI 离线成本都在 governance 账;
17
+ * 若某任务 `execution 节省 < governance 摊销`,该任务记为**净亏**并显式列出,不隐藏。
18
+ */
19
+ function num(value) {
20
+ const n = Number(value);
21
+ return Number.isFinite(n) ? n : 0;
22
+ }
23
+ const nonNegative = (value) => Math.max(0, num(value));
24
+ /**
25
+ * 判定某条账本事件属于哪一列(互斥):
26
+ * * B 列**只认证书**:替换产生的账本事件(`meta.kind === 'substitution'`)单列出来,
27
+ * 既不进 A 也不进 B —— 否则同一次节省会被证书与账本各算一遍;
28
+ * * A 列只认 execution + tool 相位 + 非外部(external 是宿主面插件收益,单列)。
29
+ */
30
+ export function classifyEntry(entry) {
31
+ if (!entry || entry.account !== 'execution')
32
+ return 'governance';
33
+ if (entry.external === true)
34
+ return 'external';
35
+ if (entry.meta?.kind === 'substitution')
36
+ return 'skipped-call-ledger';
37
+ if (entry.phase === 'tool' && entry.savedTokens > 0)
38
+ return 'unit-price';
39
+ if (entry.savedTokens > 0)
40
+ return 'other-execution';
41
+ return 'consumption';
42
+ }
43
+ /**
44
+ * @param {{
45
+ * entries?: object[],
46
+ * certificates?: object[],
47
+ * amortize?: 'even' | 'per-task',
48
+ * moduleOf?: (entry: object) => string,
49
+ * }} input
50
+ */
51
+ export function buildSavingsReport(input = {}) {
52
+ const entries = Array.isArray(input.entries) ? input.entries : [];
53
+ const certificates = Array.isArray(input.certificates) ? input.certificates : [];
54
+ const amortize = input.amortize === 'per-task' ? 'per-task' : 'even';
55
+ // ---- 列 A:单价下降(A1 回执压缩) ----
56
+ const unitPrice = { savedTokens: 0, interactions: 0, byModule: {}, byTask: {} };
57
+ // ---- 列 B:跳过调用(B2/B3 复用) ----
58
+ const skippedCalls = { savedTokens: 0, calls: 0, byTool: {}, byTask: {} };
59
+ // ---- 治理成本(作者 + 离线) ----
60
+ const governance = { totalTokens: 0, byKind: {}, byTask: {} };
61
+ // ---- 其它(消耗、外部)----
62
+ const consumption = { tokens: 0, entries: 0 };
63
+ const externalSaved = 0;
64
+ for (const entry of entries) {
65
+ const bucket = classifyEntry(entry);
66
+ if (bucket === 'governance') {
67
+ const tokens = nonNegative(entry.total);
68
+ governance.totalTokens += tokens;
69
+ const kind = String(entry.meta?.kind ?? entry.source ?? 'governance');
70
+ governance.byKind[kind] = (governance.byKind[kind] ?? 0) + tokens;
71
+ const taskKey = entry.taskId ?? 'unattributed';
72
+ governance.byTask[taskKey] = (governance.byTask[taskKey] ?? 0) + tokens;
73
+ continue;
74
+ }
75
+ if (bucket === 'unit-price') {
76
+ const saved = nonNegative(entry.savedTokens);
77
+ unitPrice.savedTokens += saved;
78
+ unitPrice.interactions += 1;
79
+ const moduleKey = String(entry.module ?? 'unattributed');
80
+ unitPrice.byModule[moduleKey] = (unitPrice.byModule[moduleKey] ?? 0) + saved;
81
+ const taskKey = entry.taskId ?? 'unattributed';
82
+ unitPrice.byTask[taskKey] = (unitPrice.byTask[taskKey] ?? 0) + saved;
83
+ continue;
84
+ }
85
+ if (bucket === 'consumption') {
86
+ consumption.tokens += nonNegative(entry.total);
87
+ consumption.entries += 1;
88
+ }
89
+ }
90
+ // B 列:只来自证书,且只算真正派发成功的
91
+ for (const cert of certificates) {
92
+ if (cert?.dispatched === false)
93
+ continue;
94
+ const saved = nonNegative(cert?.savedTokens);
95
+ skippedCalls.calls += 1;
96
+ skippedCalls.savedTokens += saved;
97
+ const tool = String(cert?.toolName ?? 'unknown');
98
+ skippedCalls.byTool[tool] = (skippedCalls.byTool[tool] ?? 0) + saved;
99
+ const taskKey = cert?.taskId ?? 'unattributed';
100
+ skippedCalls.byTask[taskKey] = (skippedCalls.byTask[taskKey] ?? 0) + saved;
101
+ }
102
+ // ---- 任务级净额:execution 节省(A+B) − governance 摊销 ----
103
+ const taskIds = [...new Set([
104
+ ...Object.keys(unitPrice.byTask),
105
+ ...Object.keys(skippedCalls.byTask),
106
+ ...Object.keys(governance.byTask),
107
+ ])].filter((taskId) => taskId !== 'unattributed').sort();
108
+ const amortized = {};
109
+ if (amortize === 'even') {
110
+ // RSI/离线等无法落到具体任务的成本,按任务数均摊(显式声明策略)。
111
+ const shared = governance.byTask.unattributed ?? 0;
112
+ const perTask = taskIds.length > 0 ? shared / taskIds.length : 0;
113
+ for (const taskId of taskIds)
114
+ amortized[taskId] = (governance.byTask[taskId] ?? 0) + perTask;
115
+ }
116
+ else {
117
+ for (const taskId of taskIds)
118
+ amortized[taskId] = governance.byTask[taskId] ?? 0;
119
+ }
120
+ const tasks = taskIds.map((taskId) => {
121
+ const executionSaved = (unitPrice.byTask[taskId] ?? 0) + (skippedCalls.byTask[taskId] ?? 0);
122
+ const governanceAmortized = amortized[taskId] ?? 0;
123
+ const net = executionSaved - governanceAmortized;
124
+ return {
125
+ taskId,
126
+ executionSaved,
127
+ unitPriceSaved: unitPrice.byTask[taskId] ?? 0,
128
+ skippedCallSaved: skippedCalls.byTask[taskId] ?? 0,
129
+ governanceAmortized,
130
+ net,
131
+ /** §8:净亏任务必须显示,不隐藏。 */
132
+ netLoss: net < 0,
133
+ };
134
+ });
135
+ const netLossTasks = tasks.filter((task) => task.netLoss);
136
+ const totalSavedTokens = unitPrice.savedTokens + skippedCalls.savedTokens;
137
+ return Object.freeze({
138
+ ok: true,
139
+ amortize,
140
+ /** 两项分列——报表必须分别展示,禁止只报合计。 */
141
+ columns: Object.freeze({
142
+ unitPrice: Object.freeze({ ...unitPrice, byModule: { ...unitPrice.byModule }, byTask: { ...unitPrice.byTask } }),
143
+ skippedCalls: Object.freeze({ ...skippedCalls, byTool: { ...skippedCalls.byTool }, byTask: { ...skippedCalls.byTask } }),
144
+ }),
145
+ totalSavedTokens,
146
+ governance: Object.freeze({ ...governance, byKind: { ...governance.byKind }, byTask: { ...governance.byTask } }),
147
+ consumption: Object.freeze({ ...consumption }),
148
+ /** 宿主面外部插件自报收益:只展示,永不并入总节省。 */
149
+ externalSaved,
150
+ /** A/B 两列来源互斥(A=账本、B=证书),结构上不可能重复计。 */
151
+ doubleCountGuard: true,
152
+ tasks: Object.freeze(tasks.map((task) => Object.freeze(task))),
153
+ netLossTasks: Object.freeze(netLossTasks.map((task) => Object.freeze(task))),
154
+ netSaved: totalSavedTokens - governance.totalTokens,
155
+ });
156
+ }
@@ -0,0 +1,128 @@
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 · M3 交付物:**命中率与「本可节省」报表**(B1 EDG + B2 GDR,shadow 只算不替换)。
6
+ *
7
+ * 口径纪律(M3 最关键的一条):
8
+ *
9
+ * * **shadow 的节省是「本可节省」,不是节省**。报表字段名就叫 `wouldSaveTokens`,
10
+ * 并且 `actualSavedTokens` 在 shadow 下恒为 0;
11
+ * * **分母是「每次复用尝试」**,不是每次工具调用、也不是每个任务;
12
+ * * 未命中必须给原因分布(`schema-drift` / `guard-miss` / `arg-unextractable` …),
13
+ * 这是 RSI(B4)唯一能拿来做改进的输入;
14
+ * * shadow 证书**不进审计链**(它们没有派发,没有 native dispatcher 证据)。
15
+ */
16
+ export const GDR_REASONS = Object.freeze([
17
+ 'no-edge',
18
+ 'tool-mismatch',
19
+ 'schema-drift',
20
+ 'dependency-incomplete',
21
+ 'guard-miss',
22
+ 'arg-unextractable',
23
+ ]);
24
+ const num = (value) => {
25
+ const n = Number(value);
26
+ return Number.isFinite(n) && n >= 0 ? n : 0;
27
+ };
28
+ /**
29
+ * M3 报表。
30
+ *
31
+ * @param {{
32
+ * attempts?: Array<{toolName?: string, hit?: boolean, reason?: string | null, stage?: string | null, edgeId?: string | null, wouldSaveTokens?: number}>,
33
+ * shadowCertificates?: Array<object>,
34
+ * substitutions?: Array<object>,
35
+ * mode?: 'off' | 'shadow' | 'enforce',
36
+ * }} input
37
+ */
38
+ export function buildReuseReport(input = {}) {
39
+ const attempts = Array.isArray(input.attempts) ? input.attempts : [];
40
+ const shadowCertificates = Array.isArray(input.shadowCertificates) ? input.shadowCertificates : [];
41
+ const substitutions = Array.isArray(input.substitutions) ? input.substitutions : [];
42
+ const mode = input.mode === 'enforce' ? 'enforce' : input.mode === 'off' ? 'off' : 'shadow';
43
+ const byReason = Object.fromEntries(GDR_REASONS.map((reason) => [reason, 0]));
44
+ const byTool = {};
45
+ const byStage = {};
46
+ let hits = 0;
47
+ let wouldSaveTokens = 0;
48
+ for (const attempt of attempts) {
49
+ const toolName = String(attempt?.toolName ?? 'unknown');
50
+ if (!byTool[toolName])
51
+ byTool[toolName] = { attempts: 0, hits: 0, wouldSaveTokens: 0 };
52
+ const row = byTool[toolName];
53
+ row.attempts += 1;
54
+ if (attempt?.hit === true) {
55
+ hits += 1;
56
+ row.hits += 1;
57
+ const wouldSave = num(attempt?.wouldSaveTokens);
58
+ wouldSaveTokens += wouldSave;
59
+ row.wouldSaveTokens += wouldSave;
60
+ }
61
+ else {
62
+ const reason = String(attempt?.reason ?? 'guard-miss');
63
+ if (reason in byReason)
64
+ byReason[reason] += 1;
65
+ else
66
+ byReason['guard-miss'] += 1;
67
+ const stage = String(attempt?.stage ?? 'unknown');
68
+ byStage[stage] = (byStage[stage] ?? 0) + 1;
69
+ }
70
+ }
71
+ const total = attempts.length;
72
+ const hitRate = total > 0 ? hits / total : 0;
73
+ // shadow 与 enforce 的节省口径必须分开:shadow 的命中只是「本可替换」
74
+ const dispatchedSubstitutions = substitutions.filter((cert) => cert?.dispatched === true);
75
+ const actualSavedTokens = mode === 'enforce'
76
+ ? dispatchedSubstitutions.reduce((sum, cert) => sum + num(cert?.savedTokens), 0)
77
+ : 0;
78
+ return Object.freeze({
79
+ ok: true,
80
+ mode,
81
+ attempts: total,
82
+ hits,
83
+ hitRate,
84
+ /** 分母口径写进报表,避免被误读成「每次工具调用的命中率」。 */
85
+ denominator: 'reuse-attempts',
86
+ byReason: Object.freeze({ ...byReason }),
87
+ byStage: Object.freeze({ ...byStage }),
88
+ byTool: Object.freeze(Object.fromEntries(Object.entries(byTool).map(([key, row]) => [key, Object.freeze({ ...row })]))),
89
+ /** M3 的核心数字:shadow 下的「本可节省」。 */
90
+ wouldSaveTokens,
91
+ /** 真正派发成功的替换节省;shadow 恒为 0,绝不会把「本可」写成「已省」。 */
92
+ actualSavedTokens,
93
+ shadowCertificates: shadowCertificates.length,
94
+ shadowCertificateDispatched: shadowCertificates.filter((cert) => cert?.dispatched === true).length,
95
+ substitutions: dispatchedSubstitutions.length,
96
+ /** 三档语义:shadow 只算不替换;enforce 才可能真的替换成功。 */
97
+ discipline: Object.freeze({
98
+ shadowNeverBooksSavings: mode === 'shadow' ? actualSavedTokens === 0 : true,
99
+ shadowCertificatesNotAudited: shadowCertificates.every((cert) => cert?.dispatched !== true),
100
+ denominatorIsAttempts: true,
101
+ }),
102
+ /** 数据不足时的诚实提示(样本太小不能下结论)。 */
103
+ sufficiency: Object.freeze({
104
+ samples: total,
105
+ enoughForConclusion: total >= 20,
106
+ note: total >= 20 ? null : '样本不足 20:命中率尚不足以下结论',
107
+ }),
108
+ });
109
+ }
110
+ /** 人读行(CLI / 面板共用)。 */
111
+ export function formatReuseReport(report) {
112
+ const lines = [];
113
+ lines.push(`复用报表(${report.mode}) 尝试 ${report.attempts} · 命中 ${report.hits} · 命中率 ${(report.hitRate * 100).toFixed(1)}%`);
114
+ lines.push(`本可节省 ${report.wouldSaveTokens} tokens · 实际节省 ${report.actualSavedTokens} tokens`);
115
+ lines.push(`shadow 证书 ${report.shadowCertificates}(未派发 ${report.shadowCertificates - report.shadowCertificateDispatched})· 真实替换 ${report.substitutions}`);
116
+ lines.push('');
117
+ lines.push('未命中原因分布');
118
+ for (const [reason, count] of Object.entries(report.byReason))
119
+ lines.push(` ${reason.padEnd(22)} ${count}`);
120
+ lines.push('');
121
+ lines.push('按工具');
122
+ for (const [tool, row] of Object.entries(report.byTool)) {
123
+ lines.push(` ${tool.padEnd(20)} 尝试 ${row.attempts} · 命中 ${row.hits} · 本可节省 ${row.wouldSaveTokens}`);
124
+ }
125
+ if (report.sufficiency.note)
126
+ lines.push(`\n注意:${report.sufficiency.note}`);
127
+ return lines.join('\n');
128
+ }
@@ -0,0 +1,367 @@
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 · B4 `rsi`(离线递归自改进,设计 §7)。
6
+ *
7
+ * **位置:离线**。本文件不属于 Worker 热路径,不 import 任何 worker 模块,
8
+ * 也不会被插件 `apply()` 调用 —— 它只在运维侧被显式执行(见 `scripts/rsi-offline.mjs`)。
9
+ *
10
+ * 输入(全部来自已落库的事实):
11
+ * * ledger 快照(`status().ledger`)—— 省了多少、哪些模块、估算占比;
12
+ * * 替换证书(`audit.substitutions()`)—— 哪些边真的被复用;
13
+ * * 失败原因分布(`WorkerResult.cost.ccs.guard.byReason` 汇总)—— 为什么没命中。
14
+ *
15
+ * 产物(**候选配置**):
16
+ * * `guard-relax` —— 守卫表达式调整(针对 guard-miss 集中处);
17
+ * * `template-add` —— 把反复出现的成功边固化成模板;
18
+ * * `guidance-rewrite`—— 批量/披露教学文案改写(针对 shape 纠偏次数)。
19
+ *
20
+ * 纪律:
21
+ * * 每个候选**必须**过验证集打分(`score()`),未达阈值**不得冻结**;
22
+ * * `freeze()` 产出冻结清单条目(含输入摘要 + 打分 + 阈值 + 时间),
23
+ * **冻结后才允许被 active 使用**;
24
+ * * 报表把「候选」与「基线」放在同一张表里对比,禁止只报候选的漂亮数字;
25
+ * * 3.3.0 只交付**可复现的离线流程 + 冻结清单**,不做自动上线。
26
+ */
27
+ import crypto from 'node:crypto';
28
+ /* ------------------------------------------------------------------ *
29
+ * 输入归一化(可复现的关键:一切排序 + 稳定序列化)
30
+ * ------------------------------------------------------------------ */
31
+ function canonical(value) {
32
+ if (Array.isArray(value))
33
+ return `[${value.map((item) => canonical(item)).join(',')}]`;
34
+ if (value && typeof value === 'object') {
35
+ const keys = Object.keys(value).sort();
36
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
37
+ }
38
+ if (typeof value === 'number')
39
+ return Number.isFinite(value) ? String(value) : 'null';
40
+ if (value === undefined)
41
+ return 'null';
42
+ return JSON.stringify(value) ?? 'null';
43
+ }
44
+ export function digestOf(value) {
45
+ return `sha256:${crypto.createHash('sha256').update(canonical(value)).digest('hex')}`;
46
+ }
47
+ const MISS_REASONS = Object.freeze([
48
+ 'no-edge',
49
+ 'tool-mismatch',
50
+ 'schema-drift',
51
+ 'dependency-incomplete',
52
+ 'guard-miss',
53
+ 'arg-unextractable',
54
+ ]);
55
+ /**
56
+ * @param {{
57
+ * ledger?: object, certificates?: object[], guardStats?: {byReason?: object, matches?: number, hits?: number},
58
+ * corrections?: number, generatedAt?: number,
59
+ * }} input
60
+ */
61
+ export function normalizeInput(input = {}) {
62
+ const byReason = {};
63
+ for (const reason of MISS_REASONS)
64
+ byReason[reason] = Math.max(0, Number(input?.guardStats?.byReason?.[reason] ?? 0) || 0);
65
+ const certificates = (Array.isArray(input?.certificates) ? input.certificates : [])
66
+ .map((cert) => ({
67
+ edgeId: String(cert?.edgeId ?? ''),
68
+ toolName: String(cert?.toolName ?? ''),
69
+ guardExpr: String(cert?.guardExpr ?? ''),
70
+ dispatched: cert?.dispatched !== false,
71
+ savedTokens: Math.max(0, Number(cert?.savedTokens ?? 0) || 0),
72
+ }))
73
+ .sort((a, b) => `${a.edgeId}|${a.toolName}`.localeCompare(`${b.edgeId}|${b.toolName}`));
74
+ return {
75
+ ledger: {
76
+ ownExecutionSaved: Math.max(0, Number(input?.ledger?.ownExecutionSaved ?? 0) || 0),
77
+ governanceTotal: Math.max(0, Number(input?.ledger?.governance?.total ?? 0) || 0),
78
+ measuredTokens: Math.max(0, Number(input?.ledger?.tokens?.measuredTokens ?? 0) || 0),
79
+ estimatedTokens: Math.max(0, Number(input?.ledger?.tokens?.estimatedTokens ?? 0) || 0),
80
+ },
81
+ certificates,
82
+ guardStats: {
83
+ matches: Math.max(0, Number(input?.guardStats?.matches ?? 0) || 0),
84
+ hits: Math.max(0, Number(input?.guardStats?.hits ?? 0) || 0),
85
+ byReason,
86
+ },
87
+ corrections: Math.max(0, Number(input?.corrections ?? 0) || 0),
88
+ generatedAt: Number(input?.generatedAt ?? 0) || 0,
89
+ };
90
+ }
91
+ /* ------------------------------------------------------------------ *
92
+ * 分析 → 候选配置
93
+ * ------------------------------------------------------------------ */
94
+ export function analyze(input = {}) {
95
+ const data = normalizeInput(input);
96
+ const total = data.guardStats.matches;
97
+ const hits = data.guardStats.hits;
98
+ const hitRate = total > 0 ? hits / total : 0;
99
+ const findings = [];
100
+ const reason = (name) => data.guardStats.byReason[name] ?? 0;
101
+ if (reason('guard-miss') > 0)
102
+ findings.push({ key: 'guard-miss', count: reason('guard-miss'), note: '守卫过严或回执形态变化' });
103
+ if (reason('arg-unextractable') > 0)
104
+ findings.push({ key: 'arg-unextractable', count: reason('arg-unextractable'), note: '参数来源无法确定性提取' });
105
+ if (reason('schema-drift') > 0)
106
+ findings.push({ key: 'schema-drift', count: reason('schema-drift'), note: '工具 schema 变更导致旧图作废' });
107
+ if (reason('tool-mismatch') > 0 || reason('no-edge') > 0)
108
+ findings.push({ key: 'coverage-gap', count: reason('tool-mismatch') + reason('no-edge'), note: '图里缺边' });
109
+ if (reason('dependency-incomplete') > 0)
110
+ findings.push({ key: 'dependency-incomplete', count: reason('dependency-incomplete'), note: '依赖链过长' });
111
+ if (data.corrections > 0)
112
+ findings.push({ key: 'batch-shape', count: data.corrections, note: '批量形状纠偏触发' });
113
+ return Object.freeze({
114
+ ok: true,
115
+ inputDigest: digestOf(data),
116
+ data,
117
+ baseline: Object.freeze({
118
+ hitRate,
119
+ matches: total,
120
+ hits,
121
+ savedTokens: data.ledger.ownExecutionSaved,
122
+ governanceTokens: data.ledger.governanceTotal,
123
+ estimatedRatio: data.ledger.measuredTokens + data.ledger.estimatedTokens > 0
124
+ ? data.ledger.estimatedTokens / (data.ledger.measuredTokens + data.ledger.estimatedTokens)
125
+ : 0,
126
+ }),
127
+ findings: findings.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)),
128
+ });
129
+ }
130
+ /**
131
+ * 从分析结果生成**候选配置**(只提案,不生效)。
132
+ * 每个候选都带 `requiresValidation: true`,未过验证集不得冻结。
133
+ */
134
+ export function proposeCandidates(analysis) {
135
+ const findings = Array.isArray(analysis?.findings) ? analysis.findings : [];
136
+ const certificates = Array.isArray(analysis?.data?.certificates) ? analysis.data.certificates : [];
137
+ const candidates = [];
138
+ const guardMiss = findings.find((item) => item.key === 'guard-miss');
139
+ if (guardMiss) {
140
+ candidates.push({
141
+ id: 'cand_guard_relax',
142
+ kind: 'guard-relax',
143
+ rationale: `guard-miss ${guardMiss.count} 次:守卫过于严格`,
144
+ change: { action: 'relax-guard', target: 'edges-with-frequent-guard-miss', note: '把 equals 放宽为 regex,或增加 negate 分支' },
145
+ risk: 'high',
146
+ requiresValidation: true,
147
+ });
148
+ }
149
+ const unextractable = findings.find((item) => item.key === 'arg-unextractable');
150
+ if (unextractable) {
151
+ candidates.push({
152
+ id: 'cand_arg_source',
153
+ kind: 'guard-relax',
154
+ rationale: `arg-unextractable ${unextractable.count} 次:参数来源不可确定性提取`,
155
+ change: { action: 'switch-arg-source', from: 'literal', to: 'jsonpath', note: '改用回执里稳定出现的字段' },
156
+ risk: 'medium',
157
+ requiresValidation: true,
158
+ });
159
+ }
160
+ // 反复成功的边 → 固化为模板
161
+ const successByEdge = new Map();
162
+ for (const cert of certificates) {
163
+ if (!cert.dispatched)
164
+ continue;
165
+ const key = `${cert.edgeId}|${cert.toolName}|${cert.guardExpr}`;
166
+ successByEdge.set(key, (successByEdge.get(key) ?? 0) + Math.max(1, cert.savedTokens > 0 ? 1 : 0));
167
+ }
168
+ const repeatable = [...successByEdge.entries()].filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
169
+ if (repeatable.length > 0) {
170
+ candidates.push({
171
+ id: 'cand_template_add',
172
+ kind: 'template-add',
173
+ rationale: `${repeatable.length} 条边被重复复用 ≥2 次,值得固化进模板库`,
174
+ change: { action: 'add-templates', edges: repeatable.map(([key]) => key.split('|')[0]) },
175
+ risk: 'low',
176
+ requiresValidation: true,
177
+ });
178
+ }
179
+ if (findings.some((item) => item.key === 'batch-shape')) {
180
+ candidates.push({
181
+ id: 'cand_guidance',
182
+ kind: 'guidance-rewrite',
183
+ rationale: '批量形状纠偏仍在触发,教学文案可再收紧',
184
+ change: { action: 'rewrite-guidance', target: 'tool-batch.guidance', note: '把反例写得更具体' },
185
+ risk: 'low',
186
+ requiresValidation: true,
187
+ });
188
+ }
189
+ return Object.freeze(candidates.sort((a, b) => a.id.localeCompare(b.id)));
190
+ }
191
+ /* ------------------------------------------------------------------ *
192
+ * 验证集打分 → 冻结
193
+ * ------------------------------------------------------------------ */
194
+ const DEFAULT_THRESHOLDS = Object.freeze({ minAccuracy: 0.98, maxDestructiveHits: 0, minSamples: 20 });
195
+ /**
196
+ * 用验证集给候选打分。验证集必须**独立于**生成候选的样本(调用方保证)。
197
+ * @param {{candidate: object, validation: {samples?: number, correct?: number, destructiveHits?: number, savedTokens?: number}}} input
198
+ */
199
+ export function score(input = {}) {
200
+ const candidate = input?.candidate;
201
+ if (!candidate || !candidate.id)
202
+ return { ok: false, code: 'candidate-invalid', message: 'candidate with id required' };
203
+ const samples = Math.max(0, Number(input?.validation?.samples ?? 0) || 0);
204
+ const correct = Math.max(0, Number(input?.validation?.correct ?? 0) || 0);
205
+ const destructiveHits = Math.max(0, Number(input?.validation?.destructiveHits ?? 0) || 0);
206
+ const savedTokens = Math.max(0, Number(input?.validation?.savedTokens ?? 0) || 0);
207
+ if (correct > samples)
208
+ return { ok: false, code: 'validation-invalid', message: 'correct cannot exceed samples' };
209
+ const accuracy = samples > 0 ? correct / samples : 0;
210
+ return {
211
+ ok: true,
212
+ candidateId: String(candidate.id),
213
+ samples,
214
+ correct,
215
+ accuracy,
216
+ destructiveHits,
217
+ savedTokens,
218
+ scoredAt: Date.now(),
219
+ };
220
+ }
221
+ /**
222
+ * 冻结:**未达阈值一律拒绝**。产出冻结清单条目(含输入摘要,可复现核对)。
223
+ * @param {{candidate: object, score: object, analysis: object, thresholds?: object, now?: () => number}} input
224
+ */
225
+ export function freeze(input = {}) {
226
+ const candidate = input?.candidate;
227
+ const scoreValue = input?.score;
228
+ const analysis = input?.analysis;
229
+ if (!candidate?.id)
230
+ return { ok: false, code: 'candidate-invalid', message: 'candidate with id required' };
231
+ if (!scoreValue || scoreValue.ok !== true)
232
+ return { ok: false, code: 'score-missing', message: 'candidate must be scored before freezing' };
233
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...(input?.thresholds ?? {}) };
234
+ const reasons = [];
235
+ if (scoreValue.samples < thresholds.minSamples)
236
+ reasons.push('samples-below-threshold');
237
+ if (scoreValue.accuracy < thresholds.minAccuracy)
238
+ reasons.push('accuracy-below-threshold');
239
+ if (scoreValue.destructiveHits > thresholds.maxDestructiveHits)
240
+ reasons.push('destructive-hits');
241
+ if (!analysis?.inputDigest)
242
+ reasons.push('input-digest-missing');
243
+ if (reasons.length > 0)
244
+ return { ok: false, code: 'freeze-refused', reasons };
245
+ const entry = Object.freeze({
246
+ candidateId: String(candidate.id),
247
+ kind: String(candidate.kind ?? 'unknown'),
248
+ risk: String(candidate.risk ?? 'unknown'),
249
+ changeDigest: digestOf(candidate.change ?? {}),
250
+ inputDigest: analysis.inputDigest,
251
+ validation: Object.freeze({
252
+ samples: scoreValue.samples,
253
+ correct: scoreValue.correct,
254
+ accuracy: scoreValue.accuracy,
255
+ destructiveHits: scoreValue.destructiveHits,
256
+ savedTokens: scoreValue.savedTokens,
257
+ }),
258
+ thresholds: Object.freeze({ ...thresholds }),
259
+ frozenAt: typeof input?.now === 'function' ? input.now() : Date.now(),
260
+ /** 冻结后才允许 active 使用;冻结 ≠ 自动上线。 */
261
+ usableByActive: true,
262
+ });
263
+ return { ok: true, entry };
264
+ }
265
+ /**
266
+ * 生成报表:候选与基线**同表展示**(防止只展示候选的漂亮数字)。
267
+ */
268
+ export function report({ analysis, scored = [], frozen = [] }) {
269
+ const baseline = analysis?.baseline ?? { hitRate: 0, matches: 0, hits: 0, savedTokens: 0, governanceTokens: 0, estimatedRatio: 0 };
270
+ const rows = [
271
+ {
272
+ id: 'baseline',
273
+ kind: 'baseline',
274
+ hitRate: baseline.hitRate,
275
+ samples: baseline.matches,
276
+ accuracy: null,
277
+ savedTokens: baseline.savedTokens,
278
+ governanceTokens: baseline.governanceTokens,
279
+ status: 'baseline',
280
+ },
281
+ ...scored.map((item) => {
282
+ const frozenEntry = frozen.find((entry) => entry.candidateId === item.candidateId);
283
+ return {
284
+ id: item.candidateId,
285
+ kind: 'candidate',
286
+ hitRate: null,
287
+ samples: item.samples,
288
+ accuracy: item.accuracy,
289
+ savedTokens: item.savedTokens,
290
+ governanceTokens: null,
291
+ status: frozenEntry ? 'frozen' : 'candidate',
292
+ };
293
+ }),
294
+ ];
295
+ return Object.freeze({
296
+ ok: true,
297
+ inputDigest: analysis?.inputDigest ?? null,
298
+ generatedAt: Date.now(),
299
+ findings: analysis?.findings ?? [],
300
+ rows,
301
+ frozen: frozen.map((entry) => ({ ...entry })),
302
+ /** 3.3.0 不做自动上线:清单之外的一切都只是提案。 */
303
+ autoDeploy: false,
304
+ });
305
+ }
306
+ /**
307
+ * M5 交付物:**冻结清单**(可持久化、可复核)。
308
+ *
309
+ * 清单只描述"在什么数据、什么阈值下冻结了什么",不含执行能力:
310
+ * * `inputDigest`:生成候选时的输入摘要(同一输入必得同一摘要);
311
+ * * `entries`:逐条冻结记录,按 candidateId 排序保证可 diff;
312
+ * * `manifestDigest`:整份清单的摘要,用于事后核对「清单没被改过」。
313
+ */
314
+ export function buildFreezeManifest({ analysis, frozen = [], now } = {}) {
315
+ const entries = [...(Array.isArray(frozen) ? frozen : [])]
316
+ .map((entry) => ({ ...entry }))
317
+ .sort((a, b) => String(a.candidateId).localeCompare(String(b.candidateId)));
318
+ const manifest = {
319
+ kind: 'rsi-freeze-manifest',
320
+ version: 1,
321
+ inputDigest: analysis?.inputDigest ?? null,
322
+ thresholds: { ...DEFAULT_THRESHOLDS },
323
+ generatedAt: Number(now ?? 0) || 0,
324
+ entries,
325
+ /** 3.3.0 不自动上线:清单只声明「可被 active 使用」,上线仍是人工动作。 */
326
+ autoDeploy: false,
327
+ };
328
+ return Object.freeze({ ...manifest, manifestDigest: digestOf(manifest) });
329
+ }
330
+ /**
331
+ * 复核冻结清单:清单没被改动、输入没变、条目仍然满足阈值。
332
+ * 返回 `issues[]` —— 任何一条都意味着"这份清单不能再作为依据"。
333
+ */
334
+ export function verifyFreezeManifest(manifest, { analysis, thresholds } = {}) {
335
+ const issues = [];
336
+ if (!manifest || manifest.kind !== 'rsi-freeze-manifest')
337
+ issues.push('manifest-kind-invalid');
338
+ const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
339
+ if (entries.length === 0)
340
+ issues.push('manifest-empty');
341
+ if (manifest?.autoDeploy !== false)
342
+ issues.push('manifest-must-not-auto-deploy');
343
+ // 清单自身完整性:重算摘要必须一致(防止事后手改)
344
+ const body = { ...manifest };
345
+ delete body.manifestDigest;
346
+ if (manifest?.manifestDigest !== digestOf(body))
347
+ issues.push('manifest-digest-mismatch');
348
+ // 输入漂移:候选是在旧数据上冻结的,就不能再当依据
349
+ if (analysis?.inputDigest && manifest?.inputDigest && analysis.inputDigest !== manifest.inputDigest) {
350
+ issues.push('input-digest-drift');
351
+ }
352
+ const limits = { ...DEFAULT_THRESHOLDS, ...(thresholds ?? {}) };
353
+ for (const entry of entries) {
354
+ const validation = entry?.validation ?? {};
355
+ if (Number(validation.samples ?? 0) < limits.minSamples)
356
+ issues.push(`entry-samples-below:${entry?.candidateId}`);
357
+ if (Number(validation.accuracy ?? 0) < limits.minAccuracy)
358
+ issues.push(`entry-accuracy-below:${entry?.candidateId}`);
359
+ if (Number(validation.destructiveHits ?? 0) > limits.maxDestructiveHits)
360
+ issues.push(`entry-destructive-hits:${entry?.candidateId}`);
361
+ if (entry?.usableByActive !== true)
362
+ issues.push(`entry-not-usable:${entry?.candidateId}`);
363
+ }
364
+ return Object.freeze({ ok: issues.length === 0, issues: Object.freeze(issues), entries: entries.length });
365
+ }
366
+ export const RSI_THRESHOLDS = DEFAULT_THRESHOLDS;
367
+ export { MISS_REASONS };