dsh-math-calculator 2.1.1 → 2.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 (44) hide show
  1. package/CHANGELOG.md +921 -0
  2. package/README.md +64 -50
  3. package/dsh.plugin.json +2 -2
  4. package/lib/__wtest.txt +1 -0
  5. package/lib/awareness.js +69 -20
  6. package/lib/format.js +522 -13
  7. package/lib/formula-library-em.js +428 -0
  8. package/lib/formulas.js +28 -1
  9. package/lib/guards.js +653 -0
  10. package/lib/help.js +509 -0
  11. package/lib/index.js +1357 -212
  12. package/lib/ops/shared.js +547 -6
  13. package/lib/ops/units-op.js +116 -10
  14. package/lib/ops/workspace-op.js +10 -0
  15. package/lib/playbook.js +32 -7
  16. package/lib/types/format.d.ts +31 -1
  17. package/lib/types/formula-library-em.d.ts +28 -0
  18. package/lib/types/formulas.d.ts +2 -0
  19. package/lib/types/guards.d.ts +255 -0
  20. package/lib/types/help.d.ts +70 -0
  21. package/lib/types/index.d.ts +34 -1
  22. package/lib/types/ops/shared.d.ts +87 -3
  23. package/lib/types/playbook.d.ts +28 -0
  24. package/lib/types/units-data.d.ts +253 -0
  25. package/lib/types/units.d.ts +54 -4
  26. package/lib/types/workspace.d.ts +50 -0
  27. package/lib/units-data.js +1037 -0
  28. package/lib/units.js +82 -117
  29. package/lib/workspace.js +54 -0
  30. package/package.json +1 -1
  31. package/src/awareness.ts +69 -20
  32. package/src/format.ts +520 -12
  33. package/src/formula-library-em.ts +435 -0
  34. package/src/formulas.ts +35 -4
  35. package/src/guards.ts +673 -0
  36. package/src/help.ts +543 -0
  37. package/src/index.ts +1492 -213
  38. package/src/ops/shared.ts +589 -9
  39. package/src/ops/units-op.ts +125 -9
  40. package/src/ops/workspace-op.ts +10 -0
  41. package/src/playbook.ts +32 -7
  42. package/src/units-data.ts +1128 -0
  43. package/src/units.ts +149 -130
  44. package/src/workspace.ts +65 -0
package/lib/guards.js ADDED
@@ -0,0 +1,653 @@
1
+ /**
2
+ * 防呆层:在数字旁边多放一句「这个数看起来不对劲」。
3
+ *
4
+ * ## 为什么需要这一层
5
+ *
6
+ * 一份实测复盘文档记录了 200 余条表达式的使用,其中**真正算错的只有单位口径类**,
7
+ * 但代价最贵的不是算错,而是**算对了却没人觉得不对**:
8
+ *
9
+ * ```
10
+ * 2645.6*28*3600*8000/1e9 → 2133.41184
11
+ * 调用者贴上「万吨」的标签 → 以此为据指控原文「错了 10 倍」
12
+ * 真相:2133 是「千吨」,万吨应为 /1e10 → 213.34
13
+ * ```
14
+ *
15
+ * 返回体当时只有一行裸数字,**既不说量级、也不说 label 与结果是否对得上**。
16
+ *
17
+ * ## 这一层做什么、不做什么
18
+ *
19
+ * **做**:数量级标注、label 与结果的量级比对、同批次一致性比对。
20
+ * **不做**:替调用者判断结论对错 —— 那需要动机与上下文,工具拿不到。
21
+ *
22
+ * 判据必须**保守**:只在「label 明确声称了一个量级词」且「结果差得离谱」时提示。
23
+ * 宁可漏报,不可误报 —— 误报会让整条通道被关掉,比漏报更糟。
24
+ *
25
+ * @module dsh-math-calculator/guards
26
+ */
27
+ /**
28
+ * 「量级词 → 该词代表的 10 的幂」映射。
29
+ *
30
+ * ## 收录范围刻意小
31
+ *
32
+ * 只收**量级词**(万、亿、千…),不收具体单位(吨、度、W)——
33
+ * 因为「1 万」代表 10⁴,而「1 吨」代表什么取决于量纲,工具不知道量纲。
34
+ *
35
+ * 量级词是纯数量级的,与量纲无关,所以能安全比对。
36
+ */
37
+ const MAGNITUDE_WORDS = [
38
+ { word: '万亿', power: 12 },
39
+ { word: '千亿', power: 11 },
40
+ { word: '百亿', power: 10 },
41
+ { word: '十亿', power: 9 },
42
+ { word: '亿', power: 8 },
43
+ { word: '千万', power: 7 },
44
+ { word: '百万', power: 6 },
45
+ { word: '十万', power: 5 },
46
+ { word: '万', power: 4 },
47
+ { word: '千', power: 3 },
48
+ { word: '百', power: 2 },
49
+ { word: '十', power: 1 },
50
+ ];
51
+ /**
52
+ * 从 label 里认出量级词。
53
+ *
54
+ * @param label - 条目的 label(如「年耗N2万吨」「年发电量亿度」)
55
+ * @returns 命中的量级词与幂次;没命中返回 null
56
+ */
57
+ export function magnitudeOfLabel(label) {
58
+ if (typeof label !== 'string' || label.trim() === '')
59
+ return null;
60
+ const text = label.trim();
61
+ /**
62
+ * ## 只在 label **就是**一个量级表述时才检查
63
+ *
64
+ * 踩过的坑(实测反馈 2026-11-06):原来用 `label.includes(词)`,
65
+ * 于是 `"1亿棵树日产台"` 命中「亿」,工具按「结果单位是亿」去解释,
66
+ * 得出「若按字面读成亿则为 2.50e20」这种**从来没有人会这么读**的警告。
67
+ *
68
+ * 原话:「我写 label 为『1亿棵树日产台』的意思是**以 1 亿棵树为输入条件**
69
+ * 算出的日产台数,不是『结果单位是亿』。工具却按后者解释,
70
+ * 于是**每一个含量级词的 label 都会触发一条无意义警告**。」
71
+ *
72
+ * 判据改为:量级词必须出现在**结尾**,且它前面那段只能是
73
+ * · 空(label 就是「亿」)
74
+ * · 纯数字 / 小数(「2.5亿」「300万」)
75
+ * · 数字 + 分隔(「2.5 亿」「日产 3 亿」这类仍不认——宁可漏报)
76
+ *
77
+ * 也就是说,只有「数值 + 量级」这种**像单位一样**的写法才被当成量级表达式。
78
+ * `"1亿棵树日产台"` 的量级词后面还有「棵树日产台」,说明它是**条件描述**而非单位,放过。
79
+ */
80
+ /**
81
+ * 先把结尾的成对括号剥掉:`年发电量(亿)`、`产量(万件)` 都是常见写法。
82
+ * 括号里的内容是「量级 + 单位」时整体当单位看待。
83
+ */
84
+ let body = text;
85
+ const bracketPair = body.match(/[((\[【]\s*(.+?)\s*[))\]】]$/);
86
+ if (bracketPair)
87
+ body = bracketPair[1].trim();
88
+ /**
89
+ * ## 判据:量级词必须处在「单位位置」
90
+ *
91
+ * 三种形态算单位位置,其余一律当描述放过:
92
+ *
93
+ * ① 结尾:`年发电量亿`、`2.5亿`、`亿`
94
+ * ② 量级词 + 单位词收尾:`产量万件`、`金额万元`、`里程万公里`
95
+ * ③ 量级词 + 数量名词收尾:`树木万棵`、`人口亿人`
96
+ *
97
+ * 反例(不放行):`1亿棵树日产台` —— 量级词后面还跟着一长串描述,
98
+ * 它是**条件**(以 1 亿棵树为输入),不是单位。
99
+ * 实测原话:「我写 label 为『1亿棵树日产台』的意思是
100
+ * 以 1 亿棵树为输入条件算出的日产台数,不是『结果单位是亿』。」
101
+ *
102
+ * 所以 ② ③ 都要求「量级词之后**只剩很短的一段**」(≤ 4 个汉字),
103
+ * 那段是单位或数量名词;超过就是句子,放过。
104
+ */
105
+ for (const entry of MAGNITUDE_WORDS) {
106
+ const at = body.indexOf(entry.word);
107
+ if (at < 0)
108
+ continue;
109
+ const prefix = body.slice(0, at).trim();
110
+ const after = body.slice(at + entry.word.length).trim();
111
+ /** 前缀是纯数字(`2.5亿`)或空(`亿`)→ 明确是量级表达式。 */
112
+ if (prefix === '' || /^[0-9][0-9,.]*$/.test(prefix)) {
113
+ /** 后面还有一长串 → 这是句子,放过。 */
114
+ if (after.length > 4)
115
+ continue;
116
+ return entry;
117
+ }
118
+ /**
119
+ * ## 前缀里的数字不能一律当「条件」
120
+ *
121
+ * 踩过:`年耗H2O万吨`、`年耗N2万吨` 这类 label 里的数字是**化学式下标**
122
+ * (H2O 的 2、N2 的 2),不是「一天产出 2 亿」那种数量条件。
123
+ * 旧判据一见到前缀里有数字就放过,导致四条年度物料全都不检查 ——
124
+ * 而那恰恰是报告里「同一个错犯了四次」的原始案例。
125
+ *
126
+ * 现在的区分:数字只作为**独立数量出现**时才算条件;
127
+ * 紧贴在字母/汉字后(`H2O`、`N2`、`CO2`)算单词的一部分。
128
+ *
129
+ * `一天产出1亿` → 1 前面是中文「出」、后面是「亿」→ 独立数量,放过
130
+ * `年耗H2O万吨` → 2 夹在 H 与 O 之间 → 单词内部,不是条件
131
+ */
132
+ const digitIsStandalone = /(^|[^A-Za-z])[0-9]/.test(prefix) && !/[A-Za-z][0-9][A-Za-z]/.test(prefix);
133
+ /**
134
+ * 纯中文前缀(`年发电量亿`)→ 只在量级词处于结尾或**紧接一个短单位**时认。
135
+ *
136
+ * `after` 允许两种形态:
137
+ * · 空或很短(≤4 字):`年发电量亿`、`产量万件`
138
+ * · 「单位 + 说明」:`年发电量亿度_按8000h` —— 单位是「度」,
139
+ * 后面的 `_按8000h` 是**注释**,不该让整条失效。
140
+ *
141
+ * 判据:`after` 的第一个分隔符(_ / ( / 空格)之前那段要很短。
142
+ */
143
+ const unitPart = after.split(/[_((\s]/)[0];
144
+ if (unitPart.length <= 4) {
145
+ if (prefix !== '' && digitIsStandalone)
146
+ return null;
147
+ return entry;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * 给数值加一个量级标注,如 `2.13e5 (~10⁵)`。
154
+ *
155
+ * ## 为什么不直接格式化成分数/万/亿
156
+ *
157
+ * 因为工具不知道量纲,也就不知道该用「万吨」还是「万度」还是「万焦」。
158
+ * 只给**纯数量级**(10 的幂),调用方自己配上单位 —— 这不容易出错。
159
+ *
160
+ * @param value - 已格式化的结果文本(如「2133.41184」)
161
+ * @returns 带量级标注的文本;值不是有限数或量级为 1 时原样返回
162
+ */
163
+ export function annotateMagnitude(value) {
164
+ const numeric = Number(value);
165
+ if (!Number.isFinite(numeric) || numeric === 0)
166
+ return value;
167
+ const magnitude = Math.floor(Math.log10(Math.abs(numeric)));
168
+ /**
169
+ * 整数且 ≥ 10⁶(百万)时补千分位。
170
+ *
171
+ * 实测反馈(2026-11-06 第二份):「一条 21 位数字就占满一行,
172
+ * 且**位数多到无法目视核位**(本次我数错过一次 0 的个数)。」
173
+ *
174
+ * 千分位是**纯视觉分组**,不改变值——「整数永远完整」这条不变量不受影响。
175
+ * 只加在批量显示里,`result` 数据通道保持原样。
176
+ */
177
+ const text = Number.isInteger(numeric) && Math.abs(numeric) >= 1e6
178
+ ? value + '(' + numeric.toLocaleString('en-US') + ')'
179
+ : value;
180
+ /** 10⁰~10³ 这个区间是日常数字,标了反而吵。 */
181
+ if (magnitude < 1)
182
+ return text;
183
+ const superscript = superscriptOf(magnitude);
184
+ return text + ' (~10' + superscript + ')';
185
+ }
186
+ /**
187
+ * 三角函数用了「无单位角度」时,补一句「按弧度计算」。
188
+ *
189
+ * ## 为什么值得加(成本极低)
190
+ *
191
+ * 实测反馈(2026-11-06 第二份):
192
+ *
193
+ * ```
194
+ * sin(30) = -0.98803162 ← 弧度
195
+ * sin(30 deg) = 0.5 ← 加单位才对
196
+ * ```
197
+ *
198
+ * 中文工程语境里 `sin(30)` 的默认读法是 **30 度**。
199
+ * 按数学惯例走弧度是对的,但**不给提示**就是静默错误:
200
+ * `-0.988` 看起来像个正常数,用户不会察觉自己问的是 30 度。
201
+ *
202
+ * ## 判据:只在「像角度但没写 deg」时提示
203
+ *
204
+ * · 函数是三角函数(sin/cos/tan/asin…)
205
+ * · 参数是裸数字(无 deg/rad/单位)
206
+ * · 数值 ∈ [0, 360](角度制下的常见范围)
207
+ *
208
+ * 三条同时满足才提示 —— 幂函数、双曲函数、`sin(1e6)` 之类一律不注(那是真的用弧度)。
209
+ *
210
+ * @param expression - 归一化后的表达式
211
+ * @returns 提示文本;不需要时返回空串
212
+ */
213
+ export function checkAngleUnit(expression) {
214
+ if (typeof expression !== 'string' || expression === '')
215
+ return '';
216
+ /** 三角函数据 + 裸数字参数(允许负号与小数)。 */
217
+ const match = expression.match(/\b(sin|cos|tan|cot|sec|csc)\s*\(\s*([+-]?[0-9]+(?:\.[0-9]+)?)\s*\)/i);
218
+ if (!match)
219
+ return '';
220
+ const value = Number(match[2]);
221
+ if (!Number.isFinite(value) || Math.abs(value) > 360)
222
+ return '';
223
+ /** 0 与 π 的整数倍在两种单位下都特殊,不注。 */
224
+ if (value === 0)
225
+ return '';
226
+ /**
227
+ * 提示里用**实际写的函数名**,不硬写 sin —— `cos(45)` 被说成「sin(45)」会让人怀疑
228
+ * 提示是模板拼的、没看懂自己的输入。
229
+ */
230
+ const fn = match[1].toLowerCase();
231
+ return '注:三角函数按**弧度**计算(' + fn + '(' + match[2] + ') 不是 ' + match[2] + ' 度)。' +
232
+ '要用角度请写 ' + fn + '(' + match[2] + ' deg)。';
233
+ }
234
+ /**
235
+ * 给带 SI 前缀的结果补一句中文量级参照。
236
+ *
237
+ * ## 为什么需要它
238
+ *
239
+ * 实测反馈(2026-11-06 第二份):
240
+ *
241
+ * ```
242
+ * 2.66e8 * 330 kg = 87.78 Tg ← 太克,现实中没人这么说
243
+ * (2.66e8*330 kg) in t = 8.778e+7 t
244
+ * ```
245
+ *
246
+ * 「?87.78?Tg」读不出「8,778 万吨」——而中文语料里这个量级就叫「亿吨」。
247
+ * 报告作者两次把「吨」当成「亿吨」,起因正是**输出里没有一个中文量级锚点**。
248
+ *
249
+ * ## 为什么不动 prest? 而是加注释
250
+ *
251
+ * 换前缀策略(k/M/G 封顶)会改变所有既有输出,破坏依赖精确文本的调用方;
252
+ * 而「加一句参照」是**追加信息**,不改变原有数值文本。
253
+ * 按项目一贯取舍:能加注释就不改主文本。
254
+ *
255
+ * ## 只认 SI 前缀 + 简单单位,且只在中英文语料真正会用的量级出现
256
+ *
257
+ /**
258
+ * 带单位的结果:给 AI 补一句「这个数有多大、等价于什么」。
259
+ *
260
+ * ## 为什么需要它
261
+ *
262
+ * 实测:`evaluate` 带单位的结果只有裸文本,`notice` 为空:
263
+ *
264
+ * ```
265
+ * 2.66e8 * 330 kg → 87.78 Mt
266
+ * ```
267
+ *
268
+ * 而 `units` 操作会给出【代入过程】与【注意】。两者信息量差得远:
269
+ * 模型看到 `87.78 Mt` 并不知道它是 8778 万吨、量级是 10⁷ 吨。
270
+ *
271
+ * 用户原话:「面向 AI 的运算信息最好把信息注释写到位。」
272
+ *
273
+ * ## 补什么
274
+ *
275
+ * 三件事,都是模型判断「这个数合不合理」时真正要用的:
276
+ * 1. **展开成基准单位**(`= 8.778e7 t`)—— 便于和别的数比
277
+ * 2. **中文量级说法**(`约 8778 万吨`)—— 中文语料的习惯表达
278
+ * 3. **量级**(`10⁷`)—— 一眼判断大小
279
+ *
280
+ * ## 只对「带单位」的结果补
281
+ *
282
+ * 纯数字走 `(~10^N)` 标注那条路,不重复;复合单位(`m/s^2`)不补 ——
283
+ * 量级词在那个位置不适用。
284
+ *
285
+ * @param text - 已格式化的结果文本
286
+ * @returns 说明文本;不需要时返回空串
287
+ */
288
+ export function explainUnitResult(text) {
289
+ if (typeof text !== 'string' || text === '')
290
+ return '';
291
+ /** `数值 + 空格 + 单个单位符号`(允许前缀如 `Mt`/`km`)。复合单位不匹配。 */
292
+ const match = text.match(/^(-?[0-9][0-9.]*(?:e[+-]?[0-9]+)?)\s+([A-Za-zµ]{1,3})(?![\w/^])$/);
293
+ if (!match)
294
+ return '';
295
+ const numeric = Number(match[1]);
296
+ const symbol = match[2];
297
+ if (!Number.isFinite(numeric) || numeric === 0)
298
+ return '';
299
+ /** 单位符号 → [基准单位名, 到基准的倍数]。只收会出现在正文里的。 */
300
+ const SCALES = {
301
+ t: ['t', 1], kt: ['t', 1e3], Mt: ['t', 1e6], Gt: ['t', 1e9], Tt: ['t', 1e12],
302
+ kg: ['kg', 1], g: ['g', 1], mg: ['mg', 1],
303
+ m: ['m', 1], km: ['m', 1e3], Mm: ['m', 1e6], Gm: ['m', 1e9],
304
+ W: ['W', 1], kW: ['W', 1e3], MW: ['W', 1e6], GW: ['W', 1e9], TW: ['W', 1e12],
305
+ J: ['J', 1], kJ: ['J', 1e3], MJ: ['J', 1e6], GJ: ['J', 1e9], TJ: ['J', 1e12],
306
+ Pa: ['Pa', 1], kPa: ['Pa', 1e3], MPa: ['Pa', 1e6],
307
+ };
308
+ const scale = SCALES[symbol];
309
+ if (!scale)
310
+ return '';
311
+ const [baseSymbol, factor] = scale;
312
+ const inBase = numeric * factor;
313
+ const magnitude = Math.floor(Math.log10(Math.abs(inBase)));
314
+ /** 中文量级:只对质量说「万吨/亿吨」——那正是中文语料里最常混的量级。 */
315
+ let chinese = '';
316
+ if (baseSymbol === 't' && symbol !== 't') {
317
+ const abs = Math.abs(inBase);
318
+ if (abs >= 1e8)
319
+ chinese = '约 ' + fnSmall(inBase / 1e8) + ' 亿吨';
320
+ else if (abs >= 1e4)
321
+ chinese = '约 ' + fnSmall(inBase / 1e4) + ' 万吨';
322
+ }
323
+ /**
324
+ * ## 唯一门槛:量级 ≥ 10⁴
325
+ *
326
+ * 试过「带前缀就注」,结果 `3 km = 3000 m,量级 10³` —— 注了等于没注,
327
+ * 反而占行宽。日常量级(≤ 10³)人本来就有直觉,不需要说明。
328
+ *
329
+ * 只有跨到 10⁴ 以上(万吨级、亿米级)才值得说一句:
330
+ * 那正是人容易数错 0 的区间,也是中文量级词真正派上用场的地方。
331
+ */
332
+ if (Math.abs(inBase) < 1e4)
333
+ return '';
334
+ const parts = ['= ' + fnSmall(inBase) + ' ' + baseSymbol];
335
+ if (chinese)
336
+ parts.push(chinese);
337
+ parts.push('量级 10' + superscriptOf(magnitude));
338
+ return '注:' + text + ' ' + parts.join(',') + '。';
339
+ }
340
+ /** 小数值的紧凑写法(最多 6 位有效数字)。 */
341
+ function fnSmall(value) {
342
+ if (!Number.isFinite(value) || value === 0)
343
+ return '0';
344
+ const abs = Math.abs(value);
345
+ if (abs >= 1e6 || abs < 1e-4) {
346
+ const [mantissa, exponent] = value.toExponential(6).split('e');
347
+ const trimmed = mantissa.includes('.') ? mantissa.replace(/0+$/, '').replace(/\.$/, '') : mantissa;
348
+ return trimmed + 'e' + exponent.replace('+', '');
349
+ }
350
+ return String(Number(value.toPrecision(6)));
351
+ }
352
+ /**
353
+ * 给带 SI 前缀的结果补一句中文量级参照。
354
+ *
355
+ * ## 为什么需要它
356
+ *
357
+ * 实测反馈:`2.66e8 * 330 kg` 曾被输出成 `87.78 Tg`(太克)——
358
+ * 而中文语料里这个量级叫「8778 万吨」。单位归位后 `Tg` 不再出现,
359
+ * 但这条注释仍然作为**兜底**保留:万一某种组合漏出了冷门前缀,它会补一句人话。
360
+ *
361
+ * ## 只认 SI 前缀 + 简单单位
362
+ *
363
+ * · 复合单位(kg/m³、m/s)不注 —— 量级词在那个位置不适用
364
+ *
365
+ * @param text - 已格式化的结果(如 `87.78 Tg`)
366
+ * @returns 带中文参照的文本;不需要时原样返回
367
+ */
368
+ export function annotateMetricPrefix(text) {
369
+ if (typeof text !== 'string' || text === '')
370
+ return text;
371
+ /** 形如 `87.78 Tg`:数值 + 空格 + 大写前缀 + 单字符单位。 */
372
+ const match = text.match(/^(-?[0-9.]+(?:e[+-]?[0-9]+)?)\s+(\w)\w(?![\w/^])/i);
373
+ if (!match)
374
+ return text;
375
+ const numeric = Number(match[1]);
376
+ if (!Number.isFinite(numeric))
377
+ return text;
378
+ /**
379
+ * 只处理「质量 × 克」这一族 —— 它是中文语料里量级混用最严重的场景
380
+ * (万吨/亿吨 vs Tg/Pg),也是报告里唯一真实出过事故的地方。
381
+ */
382
+ const massFromGram = text.match(/^(-?[0-9.]+(?:e[+-]?[0-9]+)?)\s+(\w)g(?!\w)/);
383
+ if (!massFromGram)
384
+ return text;
385
+ const prefix = massFromGram[2];
386
+ const prefixPower = { k: 3, M: 6, G: 9, T: 12, P: 15 };
387
+ const power = prefixPower[prefix];
388
+ if (power === undefined)
389
+ return text;
390
+ /** 换算成「吨」(吨 = 10⁶ 克)。 */
391
+ const inTons = numeric * Math.pow(10, power - 6);
392
+ if (!Number.isFinite(inTons) || Math.abs(inTons) === 0)
393
+ return text;
394
+ /**
395
+ * ## 只在「中文量级词真的用得着」时才注
396
+ *
397
+ * `1 Gg(= 1000 吨)` 这种注了等于没注 —— 「1000 吨」和 `1 Gg` 一样好懂,
398
+ * 而多出来的括号反而占行宽。所以只在 ≥ 万吨(10⁴ 吨)时才补:
399
+ * 那正是「Tg/Pg 读不出、而中文语料说万吨/亿吨」的分界。
400
+ */
401
+ if (Math.abs(inTons) < 1e4)
402
+ return text;
403
+ const chinese = Math.abs(inTons) >= 1e8
404
+ ? String(Number((inTons / 1e8).toPrecision(4))) + ' 亿吨'
405
+ : String(Number((inTons / 1e4).toPrecision(4))) + ' 万吨';
406
+ return text + '(= ' + chinese + ')';
407
+ }
408
+ /** 把指数转成上标(用 ASCII 兜底,避免字体缺字)。 */
409
+ function superscriptOf(n) {
410
+ const map = {
411
+ '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
412
+ '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹', '-': '⁻',
413
+ };
414
+ return String(n).split('').map(function (ch) { return map[ch] ?? ch; }).join('');
415
+ }
416
+ /**
417
+ * 检查 label 声称的量级与结果是否对得上。
418
+ *
419
+ * ## 判据要松
420
+ *
421
+ * 「万吨」标 213.34 —— 213 是**吨**为单位时的数值,而 label 说的是「万吨」,
422
+ * 也就是 213 万吨 = 2.13×10⁶ 吨。这种「label 带量级词但数值本身没到量级」
423
+ * 恰恰**不是**错误:数值 213 + 单位「万吨」= 213 万吨,是正常的。
424
+ *
425
+ * 所以判据不能是「结果是否达到 10⁴」,而是:
426
+ *
427
+ * · 若把结果**按裸数字直接读成该量级**(213 × 10⁴ = 2.13e6),
428
+ * 与「结果本身就是最终量」的读法相比差很多 —— 这时才提示可能混了换算。
429
+ *
430
+ * 实际上更有价值的是**同批一致性**:一批里所有「年耗(万吨)」条目
431
+ * 都落在同一量级、却与另一批同义条目的量级差 10 倍 —— 见 `findBatchAnomalies`。
432
+ *
433
+ * 因此这里只做一件事:**提示 label 里含量级词,提醒调用方核对换算**。
434
+ * 不做数值比对(容易误报)。
435
+ *
436
+ * @param label - 条目 label
437
+ * @param result - 结果文本
438
+ * @returns 提示文本;无需提示时返回空串
439
+ */
440
+ export function checkLabelMagnitude(label, result) {
441
+ const hit = magnitudeOfLabel(label);
442
+ if (!hit)
443
+ return '';
444
+ const numeric = Number(result);
445
+ if (!Number.isFinite(numeric))
446
+ return '';
447
+ /**
448
+ * 结果已经是科学计数法(很大或很小)时,说明调用方给的表达式
449
+ * 本来就跨了量级,这时 label 里的量级词很可能是**整条链的最终单位**,
450
+ * 而不是需要再乘一次的因子 —— 不提示。
451
+ */
452
+ if (/e[+-]/i.test(result))
453
+ return '';
454
+ const magnitude = Math.floor(Math.log10(Math.abs(numeric)));
455
+ /**
456
+ * 结果量级 ≥ label 量级时,说明数值本身就跨了那个量级
457
+ * (例:label「亿」,结果 2.88e8)—— 这才是可疑的:2.88e8 亿 = 2.88e16。
458
+ */
459
+ if (magnitude >= hit.power) {
460
+ const asLiteral = numeric * Math.pow(10, hit.power);
461
+ return '⚠ label 含「' + hit.word + '」,而结果 ' + result +
462
+ ' 本身已达 10' + superscriptOf(magnitude) +
463
+ ' 量级;若按字面读成「' + hit.word + '」则为 ' + asLiteral.toExponential(2).replace(/e\+/, 'e') + ',请核对换算。';
464
+ }
465
+ return '';
466
+ }
467
+ /**
468
+ * 比对锚点,返回提示文本。
469
+ *
470
+ * ## 为什么不处理非有限数
471
+ *
472
+ * `1/0` 得 Infinity。锚点比较对它没有意义(`Infinity > 1e10` 恒真,
473
+ * 但那条提示毫无信息量)。非有限数该由另一条通道报(数值型 operation 用非有限数
474
+ * 本身就该报错),锚点这里静默跳过。
475
+ *
476
+ * ## 为什么只比数值,不管量纲
477
+ *
478
+ * `9e7 kg` 与 `max:1e10` 的语义可能是「9e9 克」——量纲不同,锚点不该管。
479
+ * 这条限制必须写进 help,否则会变成新的误报源。
480
+ *
481
+ * ## expect 的判据是「偏离 ≥1 个数量级」
482
+ *
483
+ * 因为调用方通常只对**量级**有把握(「这是千吨级的」),对具体值没有。
484
+ * 差 0 个数量级(同量级)不报;差 1 个及以上才报。
485
+ *
486
+ * @param spec - 调用方给的锚点
487
+ * @param result - 结果文本(可能是「123」「123 kg」「1.2e5」)
488
+ * @returns 提示文本;无需提示时返回空串
489
+ */
490
+ export function checkAnchors(spec, result) {
491
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec))
492
+ return '';
493
+ const s = spec;
494
+ const numeric = parseResultNumber(result);
495
+ if (!Number.isFinite(numeric) || numeric === 0)
496
+ return '';
497
+ const note = typeof s.note === 'string' && s.note.trim() !== '' ? s.note.trim() : '';
498
+ /** 引用依据时用它;没给就用中性说法,不编。 */
499
+ const basis = note ? '(' + note + ')' : '(按你设定的锚点)';
500
+ const shown = trimForWarn(result);
501
+ const lines = [];
502
+ if (typeof s.max === 'number' && Number.isFinite(s.max) && numeric > s.max) {
503
+ const ratio = numeric / s.max;
504
+ lines.push('⚠ 超出锚点上限 ' + showAnchorValue(s.max) + basis + ' 达 ' + formatRatio(ratio) + ' 倍');
505
+ }
506
+ if (typeof s.min === 'number' && Number.isFinite(s.min) && numeric < s.min) {
507
+ const ratio = s.min / numeric;
508
+ lines.push('⚠ 低于锚点下限 ' + showAnchorValue(s.min) + basis + ' 达 ' + formatRatio(ratio) + ' 分之一');
509
+ }
510
+ if (typeof s.expect === 'number' && Number.isFinite(s.expect)) {
511
+ const actual = Math.floor(Math.log10(Math.abs(numeric)));
512
+ const drift = actual - Math.round(s.expect);
513
+ if (Math.abs(drift) >= 1) {
514
+ lines.push('⚠ 量级偏离锚点期望 10' + superscriptOf(Math.round(s.expect)) + basis +
515
+ ':实际落在 10' + superscriptOf(actual) + ' 量级(差 ' + Math.abs(drift) + ' 个数量级)');
516
+ }
517
+ }
518
+ if (lines.length === 0)
519
+ return '';
520
+ lines.push(' 若这个数要写进设定,请先确认口径。');
521
+ return lines.join('\n');
522
+ }
523
+ /**
524
+ * 从结果文本里取出数值。
525
+ *
526
+ * 结果可能是纯数字(`2133.41184`)、带单位(`9e15 kg`)、带量级标注
527
+ * (`2133 (~10³)`)。取**开头的数值部分**即可。
528
+ *
529
+ * @param text - 结果文本
530
+ * @returns 数值;解析不出时返回 NaN
531
+ */
532
+ function parseResultNumber(text) {
533
+ if (typeof text !== 'string')
534
+ return NaN;
535
+ const match = text.trim().match(/^-?[0-9][0-9,.]*(?:e[+-]?[0-9]+)?/i);
536
+ if (!match)
537
+ return NaN;
538
+ return Number(match[0].replace(/,/g, ''));
539
+ }
540
+ /**
541
+ * 锚点值在提示里的写法。
542
+ *
543
+ * 大数用科学计数法:`1e10` 比 `10000000000` 好读得多 ——
544
+ * 后者要数零,而「数零」正是量级判断最容易出错的地方。
545
+ *
546
+ * @param value - 锚点数值
547
+ * @returns 便于阅读的写法
548
+ */
549
+ function showAnchorValue(value) {
550
+ const abs = Math.abs(value);
551
+ if (abs !== 0 && (abs >= 1e6 || abs < 1e-4))
552
+ return value.toExponential(0).replace(/e\+/, 'e');
553
+ return String(Number(value.toPrecision(6)));
554
+ }
555
+ /** 警告里引用时截短,避免把 64 位小数抄进提示。 */
556
+ function trimForWarn(text) {
557
+ const numeric = Number(text);
558
+ if (!Number.isFinite(numeric))
559
+ return text;
560
+ const abs = Math.abs(numeric);
561
+ if (abs !== 0 && (abs >= 1e15 || abs < 1e-4))
562
+ return numeric.toExponential(3).replace(/e\+/, 'e');
563
+ return String(Number(numeric.toPrecision(6)));
564
+ }
565
+ /** 「超了 N 倍」的倍数表述。 */
566
+ function formatRatio(ratio) {
567
+ if (!Number.isFinite(ratio))
568
+ return '很多';
569
+ if (ratio >= 1000)
570
+ return Math.round(ratio).toLocaleString('en-US');
571
+ if (ratio >= 10)
572
+ return String(Math.round(ratio));
573
+ return String(Number(ratio.toPrecision(3)));
574
+ }
575
+ /**
576
+ * 同批次量级一致性比对。
577
+ *
578
+ * ## 这是文档里最想要的一条
579
+ *
580
+ * 真实案例:四条「年耗/年产(万吨)」全部用了 `/1e9` 而不是 `/1e10`,
581
+ * 全部偏大 10 倍,全部报「成功」。
582
+ *
583
+ * 单条看不出问题(2133 看起来就是个正常数),但**四条一起就该看出来** ——
584
+ * 它们全落在同一量级,而那个量级与该组共同声称的单位不匹配。
585
+ *
586
+ * ## 判据
587
+ *
588
+ * 1. 按 label 里的量级词分组(同组意味着同一个数量级口径)
589
+ * 2. 组内 ≥2 条,且量级**一致**(说明是同一个系统性偏差)
590
+ * 3. 该量级 ≥ label 声称的量级(说明裸数字本身就跨了那个量级 → 可疑)
591
+ *
592
+ * 三条都满足才提示。这样误报率极低。
593
+ *
594
+ * @param entries - 本次批量里成功的条目
595
+ * @returns 提示文本;无需提示时返回空串
596
+ */
597
+ export function findBatchAnomalies(entries) {
598
+ const groups = new Map();
599
+ for (const entry of entries) {
600
+ if (!entry.label)
601
+ continue;
602
+ const hit = magnitudeOfLabel(entry.label);
603
+ if (!hit)
604
+ continue;
605
+ const numeric = Number(entry.result);
606
+ if (!Number.isFinite(numeric) || numeric === 0)
607
+ continue;
608
+ /** 已经是科学计数法的不纳入 —— 调用方显然知道自己在跨量级。 */
609
+ if (/e[+-]/i.test(entry.result))
610
+ continue;
611
+ const magnitude = Math.floor(Math.log10(Math.abs(numeric)));
612
+ if (!groups.has(hit.word))
613
+ groups.set(hit.word, { word: hit.word, power: hit.power, items: [] });
614
+ groups.get(hit.word).items.push({ label: entry.label, value: numeric, magnitude });
615
+ }
616
+ const problems = [];
617
+ for (const group of groups.values()) {
618
+ if (group.items.length < 2)
619
+ continue;
620
+ /**
621
+ * 组内量级是否一致(同一个系统性偏差的特征)。
622
+ *
623
+ * ⚠️ 判据修正过一次:最初写「量级必须 ≥ label 声称的量级」,
624
+ * 于是 10³ < 10⁴(万吨)的情况被放过 —— 而那恰恰就是要抓的:
625
+ * 用 /1e9 得到「千吨」,却贴上「万吨」标签,差**恰好一个**量级。
626
+ *
627
+ * 真正的判据是:组内量级一致,**且与 label 声称的量级不一致**。
628
+ * 完全相等时说明调用方口径已经对齐(数值就是该量级下的数),不必提示。
629
+ */
630
+ const magnitudes = group.items.map(function (i) { return i.magnitude; });
631
+ const allSameMagnitude = magnitudes.every(function (m) { return m === magnitudes[0]; });
632
+ if (!allSameMagnitude)
633
+ continue;
634
+ /**
635
+ * 组量级与 label 量级的差。
636
+ * 差 0:口径已对齐(213 万吨 就是 213,正常)→ 不提示
637
+ * 差 ±1:最可疑 —— 「千吨」被当成「万吨」正是这一类
638
+ * 差更大:量级完全不同,也可能是正常的大数/小数,不强行提示
639
+ */
640
+ const gap = magnitudes[0] - group.power;
641
+ if (gap !== 1 && gap !== -1)
642
+ continue;
643
+ const values = group.items.map(function (i) { return i.label + ' = ' + i.value; }).join('\n ');
644
+ problems.push('⚠ 本批有 ' + group.items.length + ' 条 label 含「' + group.word + '」(=10' +
645
+ superscriptOf(group.power) + '),但结果全部落在 10' + superscriptOf(magnitudes[0]) +
646
+ ' 量级:\n ' + values +
647
+ '\n 量级与 label 声称的差 ' + Math.abs(gap) + ' 个数量级 —— ' +
648
+ '若数值本来该是「' + group.word + '」为单位,除数可能少除了一个 10' +
649
+ '(例如该用 /1e' + (group.power + 6) + ' 却用了 /1e' + (group.power + 5) + ')。' +
650
+ '\n 或反过来:若数值已是对的量级,label 里的「' + group.word + '」是多余的。');
651
+ }
652
+ return problems.join('\n');
653
+ }