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/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  containsNonFinite,
41
41
  formatValue,
42
42
  isIntegerLike,
43
+ isNonFiniteValue,
43
44
  toFiniteNumber,
44
45
  toJsonValue,
45
46
  unwrapResult,
@@ -134,6 +135,7 @@ import {
134
135
  switchWorkspace,
135
136
  updateWorkspace,
136
137
  workspaceDirectory,
138
+ workspaceVariablesOf,
137
139
  type SavedTerrain,
138
140
  type WorkspaceData,
139
141
  } from './workspace.js'
@@ -190,6 +192,8 @@ import {
190
192
  currentDisplayPrecision,
191
193
  fast,
192
194
  normalizeSource,
195
+ normalizeChineseUnits,
196
+ rational,
193
197
  safeNumberForJson,
194
198
  setActiveDisplayPrecision,
195
199
  trimNumber,
@@ -211,7 +215,10 @@ import { opImpact } from './ops/impact-op.js'
211
215
  import { opQuantities } from './ops/quantities-op.js'
212
216
  import { opFormulaTable } from './ops/formula-table-op.js'
213
217
  import { opFeasibility } from './ops/feasibility-op.js'
214
- import { opDerivation } from './ops/derivation-op.js'
218
+ import { opDerivation, DERIVATION_SUBCOMMANDS } from './ops/derivation-op.js'
219
+ import { WORLD_KEYS } from './world-params.js'
220
+ import { opHelp, HELP_TOPICS } from './help.js'
221
+ import { annotateMagnitude, annotateMetricPrefix, checkAnchors, checkAngleUnit, checkLabelMagnitude, explainUnitResult, findBatchAnomalies } from './guards.js'
215
222
  import { opCustom } from './ops/custom-op.js'
216
223
  import { opUnits } from './ops/units-op.js'
217
224
  import { opBalance } from './ops/balance-op.js'
@@ -258,6 +265,17 @@ export interface MathResult {
258
265
  * 提示属于「给人/模型看的说明」,走这个字段即可。
259
266
  */
260
267
  notice?: string
268
+ /**
269
+ * 批量模式:「部分成功」——本批有失败条目,但也有成功结果可用。
270
+ *
271
+ * 为什么要单独一个字段:`ok` 是布尔,说不了「废了几条、还剩几条」。
272
+ * 没有它,调用方只能二选一:整批当废(浪费成功条目)或整批当可用(把失败当成功)。
273
+ */
274
+ partial?: boolean
275
+ /** 批量模式:失败条目数。 */
276
+ failed?: number
277
+ /** 批量模式:成功条目数。 */
278
+ succeeded?: number
261
279
  }
262
280
 
263
281
  // ────────────────────────────────────────────────────────────────────────────
@@ -388,7 +406,216 @@ const RISKY_FUNCTION_LIMITS = new Map<string, number>([
388
406
  // ────────────────────────────────────────────────────────────────────────────
389
407
 
390
408
  /** 把任意异常翻译成带类型的 CalcError。 */
391
- function translateError(raw: unknown, fallbackType = 'internal'): CalcError {
409
+ /**
410
+ * 给括号类语法错补一句「在哪一层、哪一对对不上」。
411
+ *
412
+ * ## 为什么需要它
413
+ *
414
+ * 原来的提示是一句固定文案「检查括号是否配对、运算符是否连续」——
415
+ * 对长公式等于没说。实测反馈原话:char 25 指向的是多余的右括号,
416
+ * 真正缺的那个左括号在更前面,人得自己数。
417
+ *
418
+ * 这里做的是**最小可用的定位**,不是完整解析器:
419
+ * · 扫一遍得到每个括号的层级与配对状态
420
+ * · 报出第一处不配对的位置(缺左 / 多右)
421
+ * · 回显表达式并在下方标出各层的起止
422
+ *
423
+ * ## 为什么不做「自动补括号」
424
+ *
425
+ * 猜意图补全括号,猜错时会把报错变成一个**算错的数** ——
426
+ * 比直接报错糟得多。这里只指出位置,补不补、补在哪,由调用方决定。
427
+ *
428
+ * @param source - 归一化后的表达式
429
+ * @returns 提示文本;括号本身平衡时返回空串(说明不是括号问题)
430
+ */
431
+ function diagnoseParentheses(source: string): string {
432
+ if (typeof source !== 'string' || source === '') return ''
433
+ const openAt: Array<{ index: number; depth: number }> = []
434
+ const pairs: Array<{ open: number; close: number; depth: number }> = []
435
+ let depth = 0
436
+ let firstUnexpectedClose = -1
437
+ /** 引号内的括号不算结构(例如 5 cm to "inch(2)" 这类)。 */
438
+ let inQuote: string | null = null
439
+
440
+ for (let i = 0; i < source.length; i += 1) {
441
+ const ch = source[i]
442
+ if (inQuote !== null) {
443
+ if (ch === inQuote) inQuote = null
444
+ continue
445
+ }
446
+ if (ch === '"' || ch === "'") { inQuote = ch; continue }
447
+ if (ch === '(') {
448
+ openAt.push({ index: i, depth })
449
+ depth += 1
450
+ } else if (ch === ')') {
451
+ depth -= 1
452
+ const opened = openAt.pop()
453
+ if (opened === undefined) {
454
+ if (firstUnexpectedClose < 0) firstUnexpectedClose = i
455
+ depth = 0
456
+ } else {
457
+ pairs.push({ open: opened.index, close: i, depth: opened.depth })
458
+ }
459
+ }
460
+ }
461
+
462
+ const lines: string[] = []
463
+ if (firstUnexpectedClose >= 0) {
464
+ lines.push('第 ' + (firstUnexpectedClose + 1) + ' 个字符处的 ")" 没有对应的左括号')
465
+ if (openAt.length > 0) {
466
+ lines.push('这几个左括号始终没配上: ' + openAt.map(function (o) { return '第 ' + (o.index + 1) + ' 字符' }).join('、'))
467
+ }
468
+ lines.push('多数情况是它前面漏了一个 "("')
469
+ } else if (openAt.length > 0) {
470
+ lines.push('这几个左括号没有闭合: ' + openAt.map(function (o) { return '第 ' + (o.index + 1) + ' 字符' }).join('、'))
471
+ lines.push('在末尾补上 ' + openAt.length + ' 个 ")" 即可(若本意不是这样,删掉多余的 "(")')
472
+ } else {
473
+ return ''
474
+ }
475
+
476
+ /** 回显表达式并标出层级(超过 120 字符只给结论,避免糊成一片)。 */
477
+ if (source.length <= 120) {
478
+ const marks: string[] = []
479
+ for (let i = 0; i < source.length; i += 1) marks.push(' ')
480
+ for (const pair of pairs) {
481
+ marks[pair.open] = String(Math.min(9, pair.depth))
482
+ marks[pair.close] = String(Math.min(9, pair.depth))
483
+ }
484
+ lines.push('')
485
+ lines.push(source)
486
+ lines.push(marks.join(''))
487
+ lines.push('(数字标的是括号层级,同层级的左右括号应该一一对应)')
488
+ }
489
+ return lines.join('\n')
490
+ }
491
+
492
+ /**
493
+ * 语法错误的具体解释:**先认常见误因,再退到通用诊断**。
494
+ *
495
+ * ## 为什么需要分类
496
+ *
497
+ * 实测反馈(2026-11-06 第二份)列了三种同 `syntax` 但原因完全不同的情况:
498
+ *
499
+ * ```
500
+ * / 112.5e8 → 「检查括号是否配对、运算符是否连续」
501
+ * 2.66亿 * 330 → 没有任何提示
502
+ * 1,000 * 2 → 仍说「检查括号」 ← 真因是千分位逗号
503
+ * ```
504
+ *
505
+ * 第三条最糟:**提示指向了错误的方向**。用户会去数括号,而问题是逗号。
506
+ *
507
+ * ## 顺序:具体 → 通用
508
+ *
509
+ * 越具体的误因越先判,因为它们能被可靠识别;都认不出才用括号诊断兜底。
510
+ *
511
+ * @param source - 归一化后的表达式
512
+ * @param engineText - 引擎给的原始消息(含 char 位置)
513
+ * @returns 解释文本
514
+ */
515
+ function explainSyntax(source: string, engineText: string): string {
516
+ const at = engineText.match(/char ([0-9]+)/i)
517
+ const position = at ? Number(at[1]) : 0
518
+ /** 出错字符附近的一小段,便于用户对照。 */
519
+ const snippet = position > 0 && source.length > 0
520
+ ? source.slice(Math.max(0, position - 1), Math.min(source.length, position + 6))
521
+ : ''
522
+
523
+ /**
524
+ * ① 千分位逗号 —— 中文语料里极常见(`1,000`、`139,000,000`)。
525
+ * 引擎把逗号当分隔符,报语法错。
526
+ */
527
+ if (/[0-9],[0-9]{3}/.test(source)) {
528
+ return '无法解析:表达式里有**千分位逗号**(如 1,000)。去掉逗号即可 —— 千分位只是书写习惯,不参与计算。'
529
+ }
530
+
531
+ /**
532
+ * ② 中文/未知字符 —— 量级词与中文单位若无对应展开,会落到这里。
533
+ * 给出可操作的下一步(而不是「检查括号」)。
534
+ */
535
+ if (/[\u4e00-\u9fff]/.test(source)) {
536
+ return '无法解析:表达式里有中文。中文单位(里/丈/亩/斤/吨)与量级词(万/亿/万亿)可直接写;' +
537
+ '其它中文标识符请改用英文变量名,或先存进工作区。'
538
+ }
539
+
540
+ /**
541
+ * ③ 运算符位置 —— 开头/连续运算符。
542
+ */
543
+ if (/^[*/+^]|\/\/|[*/+^]{2,}/.test(source)) {
544
+ return '无法解析:运算符位置不对' + (position > 0 ? '(第 ' + position + ' 个字符附近「' + snippet + '」)' : '') +
545
+ '。检查是否以运算符开头,或出现连续运算符。'
546
+ }
547
+
548
+ /**
549
+ * ④ 兜底:括号诊断(它会给出层级图),再退到通用建议。
550
+ */
551
+ const diagnostic = diagnoseParentheses(source)
552
+ if (diagnostic) return diagnostic
553
+
554
+ return '检查括号是否配对、运算符是否连续。' +
555
+ (position > 0 ? '(引擎报错位置:第 ' + position + ' 个字符「' + snippet + '」)' : '')
556
+ }
557
+
558
+ /**
559
+ * 把 mathjs `Unexpected type of argument in function to` 这类内部报错
560
+ * 翻译成调用方能行动的说法。
561
+ *
562
+ * ## 为什么它必须单独处理
563
+ *
564
+ * 实测(1107 报告问题 6):
565
+ *
566
+ * ```
567
+ * (0.907 kg / (10 hp * 0.475)) to min
568
+ * → 错误[internal]: Unexpected type of argument in function to
569
+ * (expected: Unit or string or ... actual: function, index: 1)
570
+ * ```
571
+ *
572
+ * `internal` 在本项目的约定里意思是**工具自己坏了** —— 会让人去重试,
573
+ * 而真实原因有四种,全部是写法问题:
574
+ *
575
+ * ```
576
+ * ① 目标单位名撞上内置函数 1 s to min → actual: function
577
+ * (min/max/mean/sum/abs/round… 在 mathjs 里是函数,不是单位)
578
+ * ② 值根本不是单位 0.5 to kW → actual: Unit | identifier
579
+ * (`to` 只能把**带单位的量**换算,裸数字没有单位可换)
580
+ * ③ 值算了半天不是有限数 (1/0) to kW
581
+ * ④ 目标单位拼错 2 m to cmm
582
+ * ```
583
+ *
584
+ * 四类的修复方向完全不同,所以不能笼统说「检查写法」。
585
+ *
586
+ * @param message - mathjs 的原始消息
587
+ * @returns 翻译后的错误;认不出时返回 null(交给上层兜底)
588
+ */
589
+ function translateToError(message: string): CalcError | null {
590
+ if (!/in function to\b/i.test(message)) return null
591
+ /** ① 目标单位名是内置函数:消息里 `actual: function` 就是这个信号。 */
592
+ if (/actual: function/i.test(message)) {
593
+ const known = ['min','max','mean','median','mode','sum','prod','std','variance','abs','sqrt','exp','log','floor','ceil','round','sign','norm','size','index','count','map','filter']
594
+ return new CalcError(
595
+ 'unit_name_conflict',
596
+ '目标单位名与内置函数同名,mathjs 把它当函数而不是单位',
597
+ '常见撞名: ' + known.slice(0, 10).join(' / ') + ' …' +
598
+ '。时间单位写全称(minute / hour / second),或改用 operation="units"' +
599
+ '(units 的单位表与函数命名空间互不干扰)。',
600
+ )
601
+ }
602
+ /** ② 值不是单位:`actual: Unit | identifier` 说明左边根本没解析成带单位的量。 */
603
+ if (/actual: Unit \| identifier/i.test(message)) {
604
+ return new CalcError(
605
+ 'not_a_quantity',
606
+ 'to 只能给「带单位的量」换算,左边这个值不带单位',
607
+ '要么写成带单位的形态(1 hp、2 kg),要么先去算出一个带单位的结果;' +
608
+ '纯数字换单位请用 operation="units"(给 unitValue/unitFrom/unitTo)。',
609
+ )
610
+ }
611
+ /** ③ 剩下的是「左边算出来不是有限数量」,最典型是除以零。(按 4 类) */
612
+ return new CalcError(
613
+ 'not_a_quantity',
614
+ 'to 的目标单位没被识别,或左边的值不构成可换算的量',
615
+ '检查目标单位拼写(cm / cm^3 / MJ/kg);若左边算式含除法,先确认它没除以零。',
616
+ )
617
+ }
618
+ function translateError(raw: unknown, fallbackType = 'internal', source = ''): CalcError {
392
619
  if (raw instanceof CalcError) return raw
393
620
  if (raw instanceof SymbolicError) {
394
621
  return new CalcError(raw.stage, raw.message, '符号引擎的能力有限,可尝试 operation="integral" 配数值区间。')
@@ -396,11 +623,40 @@ function translateError(raw: unknown, fallbackType = 'internal'): CalcError {
396
623
  const message = raw instanceof Error ? raw.message : String(raw ?? '未知错误')
397
624
  const text = message.trim() || '未知错误'
398
625
 
626
+ /** `to` 的四种写法错误优先翻译 —— 它原本一律落进 `internal`。 */
627
+ const toError = translateToError(text)
628
+ if (toError) return toError
629
+
399
630
  if (/Undefined symbol/i.test(text)) {
400
631
  return new CalcError('unknown_symbol', text, '先定义变量,或通过 variables 参数传入,例如 {"m": 500}。')
401
632
  }
402
- if (/Unexpected end of expression|Value expected|Parenthesis|Invalid left hand side|SyntaxError|Unexpected operator/i.test(text)) {
403
- return new CalcError('syntax', text, '检查括号是否配对、运算符是否连续。')
633
+ /**
634
+ * 未定义的函数也归 `unknown_symbol`,不归 `internal`。
635
+ *
636
+ * 实测反馈:`nosuchfunc(1)` 报 `internal`,而 `internal` 的含义是
637
+ * 「工具自己坏了」—— 会误导用户去重试同一个错。函数名打错是**输入问题**,
638
+ * 该给可操作的提示。
639
+ */
640
+ if (/Undefined function/i.test(text)) {
641
+ const name = text.match(/Undefined function\s+(\S+)/i)?.[1] ?? ''
642
+ return new CalcError(
643
+ 'unknown_symbol',
644
+ name ? '没有名为 "' + name + '" 的函数' : text,
645
+ '可用 operation="help" 查看支持的全部函数与公式。',
646
+ )
647
+ }
648
+ /**
649
+ * ⚠️ `SyntaxError` 与 `Syntax error` **不是同一个字符串**:一个是标识符名、一个是消息文本。
650
+ * mathjs 抛的是后者(`Syntax error in part "某中文*2" (char 1)`)。
651
+ *
652
+ * 踩过:正则只写了前者,于是这类错误落到了末尾的兜底分支 ——
653
+ * 表现为「错误类型对(syntax)、但没有任何 hint」。这类漏判不报错、不显眼,
654
+ * 靠的是对照实验(同样是语法错,`1+*2` 有提示而 `某中文*2` 没有)才揪出来。
655
+ *
656
+ * 所以两种写法都收,并且用 `\s*` 容忍中间空格差异。
657
+ */
658
+ if (/Unexpected end of expression|Value expected|Parenthesis|Invalid left hand side|Syntax\s*Error|Unexpected operator/i.test(text)) {
659
+ return new CalcError('syntax', text, explainSyntax(source, text))
404
660
  }
405
661
  if (/Units do not match|Cannot convert|not found.*unit/i.test(text)) {
406
662
  return new CalcError('unit_mismatch', text, '两侧单位必须同量纲。')
@@ -427,6 +683,156 @@ function translateError(raw: unknown, fallbackType = 'internal'): CalcError {
427
683
  // AST 安全校验
428
684
  // ────────────────────────────────────────────────────────────────────────────
429
685
 
686
+ /**
687
+ * mathjs 内置的**单字母单位符号**。
688
+ *
689
+ * 实测确认的完整清单(`math.evaluate('1 X')` 能得到 Unit 的那些)。
690
+ * 注意 `i`(虚数单位)与 `e`(自然常数)**不在**此列 —— 它们是常量不是单位。
691
+ */
692
+ const BARE_UNIT_LETTERS = new Set('ABCFHJKLNSTVWbghlmst'.split(''))
693
+
694
+ /**
695
+ * 拦下「未定义的单字母变量被当成单位」这一类**静默错误**。
696
+ *
697
+ * ## 为什么这是最危险的一类
698
+ *
699
+ * 实测反馈(`math_execute_实测问题与改进建议.md` 的 P0-2):
700
+ *
701
+ * ```
702
+ * rho=2035; rho*V → 2.035 kV ← V 被当成伏特
703
+ * rho=2035; rho*m → 2.035 km ← m 被当成米
704
+ * rho=2035; rho*g → 2.035 kg ← g 被当成克
705
+ * ```
706
+ *
707
+ * 不报错、不警告,而且数字看着正常(2.035 是个"合理"的数)。
708
+ * 而物理推演里最规范的变量名恰恰都是单字母:m/V/W/T/L/P/A/C/h/g。
709
+ * **工具在惩罚最规范的命名习惯。**
710
+ *
711
+ * ## 为什么不能简单地「未定义就报错」
712
+ *
713
+ * 因为 mathjs 把 `5 V`(单位)与 `rho*V`(变量)解析成**完全相同**的 AST:
714
+ *
715
+ * ```
716
+ * 5 V → OperatorNode(*) [ConstantNode(5), SymbolNode(V)]
717
+ * rho*V → OperatorNode(*) [SymbolNode(rho), SymbolNode(V)]
718
+ * ```
719
+ *
720
+ * 语法层无法区分,只能看**上下文**。所以判据是:
721
+ *
722
+ * · 放行:符号处「单位位置」—— 父是乘法且兄弟里有数字(`5 V`、`9.81 m`),
723
+ * 或父是幂且指数是数字(`s^2`)。这些是真单位用法。
724
+ * · 报错:符号与**已定义变量同级**(`rho*V` 的兄弟 rho 已定义),
725
+ * 或它**单独出现**(`V` 一个字母单独求值,语义含糊)。
726
+ * · 其它:放行(如纯单位表达式 `9.81 m/s^2`)。
727
+ *
728
+ * ## 已知的取舍
729
+ *
730
+ * 批内混用「变量 + 单位」会被拦(`["v=30", "9.81 m/s^2 * v"]` 里的 m/s)。
731
+ * 这是有意的:那种写法本身含糊,报错比猜更安全,且提示会说明两种写法。
732
+ *
733
+ * @param node - 已解析的 AST
734
+ * @param scope - 当前作用域(含已定义变量)
735
+ */
736
+ function guardBareUnitSymbols(node: any, scope: Record<string, unknown>): void {
737
+ if (!node || typeof node !== 'object') return
738
+ const defined = new Set(Object.keys(scope ?? {}))
739
+ const problems: string[] = []
740
+
741
+ const visit = (current: any, parent: any): void => {
742
+ if (!current || typeof current !== 'object') return
743
+ if (current.type === 'SymbolNode') {
744
+ const name = String(current.name ?? '')
745
+ if (name.length === 1 && !defined.has(name) && BARE_UNIT_LETTERS.has(name)) {
746
+ const siblings: any[] = Array.isArray(parent?.args) ? parent.args : []
747
+ const others = siblings.filter((a) => a !== current)
748
+ const hasConstantSibling = others.some((a) => a?.type === 'ConstantNode')
749
+ const hasDefinedSibling = others.some(
750
+ (a) => a?.type === 'SymbolNode' && defined.has(String(a.name)),
751
+ )
752
+ /**
753
+ * ★ 顺序是判据的一部分,不能只看「同级有没有数字」。
754
+ *
755
+ * mathjs 保留了书写顺序,于是:
756
+ *
757
+ * 2 b → OperatorNode(*) [0:ConstantNode(2), 1:SymbolNode(b)] ← 单位
758
+ * b*2 → OperatorNode(*) [0:SymbolNode(b), 1:ConstantNode(2)] ← 变量
759
+ *
760
+ * 两者兄弟都含数字,旧判据一律放过 —— 结果 `b*2` 被算成
761
+ * 「2 b(单位)」,静默给出 `2 b`。实测踩到过:
762
+ * 批量里 `b=oops*2` 失败之后,后续 `d=b*2` 不报错、算出 `2 b`,
763
+ * 一个依赖失败步骤的下游悄悄变成了合法结果(虚成功)。
764
+ *
765
+ * 单位写法是「数字在前、符号在后」;符号在前是**变量乘数字**。
766
+ */
767
+ const symbolIndex = siblings.indexOf(current)
768
+ const constantIndex = siblings.findIndex((a) => a?.type === 'ConstantNode')
769
+ const constantBefore = constantIndex >= 0 && constantIndex < symbolIndex
770
+ // 单位位置:数字 × 单位(数字在前)、或 数字 × 单位的幂
771
+ const isUnitPosition =
772
+ (parent?.type === 'OperatorNode' && parent.op === '*' && constantBefore) ||
773
+ (parent?.type === 'OperatorNode' && parent.op === '^' && hasConstantSibling)
774
+ /**
775
+ * 三个条件里**任一**成立就报:都不是「数字在前的单位写法」时,
776
+ * 这个单字母就不该被静默当成单位。
777
+ *
778
+ * 踩过的坑:旧版只报了「有已定义兄弟」与「单独出现」两种情况,
779
+ * 于是 `b*2`(符号在前、没有已定义兄弟、又不是根)三条全不命中,
780
+ * 被放过去算成 `2 b` —— 批量里一个失败步骤的下游就这样变成了
781
+ * 「成功」,而且是合法单位结果,最难看出来的那种。
782
+ */
783
+ if (!isUnitPosition) {
784
+ const isBareSymbolChild =
785
+ parent?.type === 'OperatorNode' &&
786
+ parent.op === '*' &&
787
+ siblings.length === 2 &&
788
+ siblings.every((a) => a === current || a?.type === 'ConstantNode')
789
+ if (hasDefinedSibling || parent === null || isBareSymbolChild) problems.push(name)
790
+ }
791
+ }
792
+ return
793
+ }
794
+ for (const key of ['args', 'items']) {
795
+ const list = current[key]
796
+ if (Array.isArray(list)) for (const child of list) visit(child, current)
797
+ }
798
+ if (current.content) visit(current.content, current)
799
+ if (current.object) visit(current.object, current)
800
+ if (current.index) visit(current.index, current)
801
+ /**
802
+ * ★ 赋值语句的右半部分挂在 `value` 上,不在 `args` 里。
803
+ *
804
+ * 踩过的坑:`d=b*2` 的 AST 是 `AssignmentNode(name=d, value=OperatorNode(*))`,
805
+ * 而这里原本只走 `args/items/content/object/index` —— 赋值右侧**整个不被遍历**。
806
+ * 于是批量里 `b=oops*2` 失败之后,`d=b*2` 从未被判据看到,
807
+ * 直接被 mathjs 当成「2 b」算出一个合法单位结果。
808
+ *
809
+ * 这类「下游悄悄变成虚成功」比报错难发现得多:批量汇总里它是「成功」。
810
+ */
811
+ if (current.value && typeof current.value === 'object') visit(current.value, current)
812
+ }
813
+
814
+ visit(node, null)
815
+
816
+ if (problems.length > 0) {
817
+ const uniq = [...new Set(problems)]
818
+ const list = uniq.map((n) => '"' + n + '"').join('、')
819
+ /**
820
+ * 「为什么报错」这句写在 `hint` 里,不写 `message`。
821
+ *
822
+ * 批量模式的失败行格式是 `失败[type]:<hint>`,**只显示 hint**;
823
+ * 单条模式两者都回。把「既是变量名也是单位符号」放进 hint,
824
+ * 两种模式都能看到 —— 这是用户理解报错原因的唯一线索。
825
+ */
826
+ throw new CalcError(
827
+ 'unknown_symbol',
828
+ '未定义的标识符 ' + list,
829
+ list + ' 既是变量名也是单位符号。若要当单位用,请写成带数字的形式(如 "5 V");' +
830
+ '若要当变量,请先在本次调用里赋值,或通过 variables 参数传入(如 {"' +
831
+ uniq[0] + '": 100})。',
832
+ )
833
+ }
834
+ }
835
+
430
836
  function guardAst(node: any): void {
431
837
  if (!node || typeof node.traverse !== 'function') return
432
838
  node.traverse((current: any) => {
@@ -458,6 +864,61 @@ function guardAst(node: any): void {
458
864
  })
459
865
  }
460
866
 
867
+ /**
868
+ * 判断表达式里有没有**显式要求的目标单位**。
869
+ *
870
+ * mathjs 里三种写法都表示「换成这个单位」:
871
+ *
872
+ * ```
873
+ * 5 cm to inch OperatorNode(op=to)
874
+ * to(5 cm, inch) FunctionNode(fn=to)
875
+ * (10 hp/0.475).to('kW') FunctionNode(fn=AccessorNode(property=to))
876
+ * ```
877
+ *
878
+ * ## 为什么需要这个判断
879
+ *
880
+ * 输出层的单位归位(`normalizeUnitPrefix`)会在「没人指定单位」时挑一个顺口的档。
881
+ * 但调用方一旦写了 `.to(...)`,选择权就该归他 —— 否则会出现
882
+ * `2 m to cm` 被改成 `2 m` 这种**把用户的换算悄悄撤回**的回归。
883
+ * 数字仍然合法(`2 m` 是个正经长度),所以这类错误不会被怀疑。
884
+ *
885
+ * @param node - 已解析的 AST;解析失败时调用方本来就走不到这里
886
+ * @returns 有显式目标单位时为 true
887
+ */
888
+ function usesExplicitUnitConversion(node: any): boolean {
889
+ let found = false
890
+ if (!node || typeof node !== 'object') return false
891
+ const visit = (current: any, depth: number): void => {
892
+ if (found || !current || typeof current !== 'object' || depth > 64) return
893
+ if (current.type === 'OperatorNode' && String(current.op ?? '') === 'to') {
894
+ found = true
895
+ return
896
+ }
897
+ if (current.type === 'FunctionNode') {
898
+ if (String(current.fn?.name ?? '') === 'to') {
899
+ found = true
900
+ return
901
+ }
902
+ /** `.to('kW')` 的 fn 是 AccessorNode,属性名挂在 index 上。 */
903
+ const indexNode = current.fn?.index
904
+ const property = typeof indexNode?.value === 'string' ? indexNode.value : indexNode?.name
905
+ if (typeof property === 'string' && property.replace(/^["']|["']$/g, '') === 'to') {
906
+ found = true
907
+ return
908
+ }
909
+ }
910
+ for (const key of ['args', 'items']) {
911
+ const list = current[key]
912
+ if (Array.isArray(list)) for (const child of list) visit(child, depth + 1)
913
+ }
914
+ for (const key of ['content', 'object', 'index', 'value']) {
915
+ if (current[key]) visit(current[key], depth + 1)
916
+ }
917
+ }
918
+ visit(node, 0)
919
+ return found
920
+ }
921
+
461
922
  function guardComplexity(node: any): void {
462
923
  if (!node || typeof node.traverse !== 'function') return
463
924
  node.traverse((current: any) => {
@@ -478,27 +939,138 @@ function guardComplexity(node: any): void {
478
939
  // ────────────────────────────────────────────────────────────────────────────
479
940
 
480
941
  /** 把 variables 字典转成 mathjs 可用的 scope。 */
942
+ /**
943
+ * 是否把工作区里登记的命名变量并入作用域。
944
+ *
945
+ * ## 这是显式开关,默认关闭
946
+ *
947
+ * 打开后同一个调用在不同工作区状态下会给出不同结果 ——
948
+ * 那会削弱「同输入同输出」。所以:
949
+ * · 默认关闭,**不改变任何现有调用的行为**
950
+ * · 要开就在工作区里固化:`{action2:"set", useWorkspaceVariables:true}`
951
+ *
952
+ * 关掉后 `workspaceVariables` 里存的东西仍然存着(能 show、能 remove),
953
+ * 只是不参与计算 —— 与旧版行为完全一致。
954
+ *
955
+ * @param name - 工作区名;省略用当前工作区
956
+ * @returns 是否启用
957
+ */
958
+ function useWorkspaceVariables(name?: string): boolean {
959
+ try {
960
+ const data = loadWorkspace(name ?? activeWorkspaceNameForCache())
961
+ return data.useWorkspaceVariables === true
962
+ } catch {
963
+ return false
964
+ }
965
+ }
966
+
967
+ /**
968
+ * 把工作区变量与本次传入的变量合成最终作用域输入。
969
+ *
970
+ * ## 优先级:本次参数 > 工作区
971
+ *
972
+ * 临时覆盖长期,符合「显式优先」。同名的键以本次传入为准 ——
973
+ * 这样「临时想换个值算一下」不必先去改工作区。
974
+ *
975
+ * ## 两个来源都要规范化
976
+ *
977
+ * `buildScope` 会校验变量名与取值,所以这里不能只做浅合并:
978
+ * 否则工作区里存了非法名字(历史上可能写进去过),
979
+ * 会绕过校验直接进 mathjs 作用域。
980
+ *
981
+ * @param passed - 本次调用传的 `variables` 参数
982
+ * @returns 合并后的变量字典;开关关闭时原样返回 passed
983
+ */
984
+ function mergeWorkspaceVariables(passed: unknown): unknown {
985
+ if (!useWorkspaceVariables()) return passed
986
+ const stored = workspaceVariablesOf()
987
+ const incoming =
988
+ passed && typeof passed === 'object' && !Array.isArray(passed)
989
+ ? (passed as Record<string, unknown>)
990
+ : {}
991
+ if (Object.keys(stored).length === 0 && Object.keys(incoming).length === 0) return passed
992
+ return { ...stored, ...incoming }
993
+ }
994
+
995
+ /**
996
+ * 收集上一次 `buildScope` 跳过的非法变量键。
997
+ *
998
+ * 模块级、每次调用开头清空 —— 与 `activeDisplayPrecision` 同样的理由:
999
+ * 逐层传参要改四十多个函数签名,而这个信息只在**入口**用来组装一条提示。
1000
+ */
1001
+ let skippedVariableKeys: Array<{ name: string; reason: string }> = []
1002
+
1003
+ /**
1004
+ * 取本次调用中被跳过的非法变量键(组装提示用)。
1005
+ *
1006
+ * @returns 被跳过的键与原因
1007
+ */
1008
+ export function takeSkippedVariableKeys(): Array<{ name: string; reason: string }> {
1009
+ const taken = skippedVariableKeys
1010
+ skippedVariableKeys = []
1011
+ return taken
1012
+ }
1013
+
1014
+ /**
1015
+ * 清理被跳过的变量键记录(每次工具调用开头)。
1016
+ */
1017
+ export function resetSkippedVariableKeys(): void {
1018
+ skippedVariableKeys = []
1019
+ }
1020
+
1021
+ /**
1022
+ * 把变量字典装进 mathjs 作用域。
1023
+ *
1024
+ * ## 非法键只跳过,不中止整批
1025
+ *
1026
+ * 实测反馈(2026-11-06 第二份):
1027
+ *
1028
+ * ```
1029
+ * expressions: ["P*330", "Q+1"], variables: {"P":1000, "Q":5, "人口":1}
1030
+ * → ok:false,连合法的 P*330 都没算
1031
+ * ```
1032
+ *
1033
+ * 一个键名写错,前面排队的算式全白写的 —— 而这类语料的**概念名几乎全是中文**
1034
+ * (人口/粮食/石料),用户第一反应就是用中文命名。
1035
+ *
1036
+ * 现在:合法的键照常进作用域,非法的记进 `skippedVariableKeys`,
1037
+ * 由入口在结果里列出来。**算出来的数仍然是对的** —— 只是那个中文键没生效。
1038
+ *
1039
+ * ## 为什么不做「支持中文变量名」
1040
+ *
1041
+ * 那要放宽 mathjs 的词法,而词法宽松会带来新的歧义
1042
+ * (中文字符与单位名、量级词的边界更难判)。
1043
+ * 相比之下「跳过 + 明确告知」既解决了痛点,又不引入不确定性。
1044
+ *
1045
+ * @param engine - mathjs 实例
1046
+ * @param variables - 变量字典
1047
+ * @returns 可用的作用域对象
1048
+ */
481
1049
  function buildScope(engine: any, variables: unknown): Record<string, any> {
482
1050
  const scope: Record<string, any> = {}
483
1051
  if (!variables || typeof variables !== 'object' || Array.isArray(variables)) return scope
484
1052
  for (const [key, raw] of Object.entries(variables as Record<string, unknown>)) {
485
1053
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
486
- throw new CalcError('invalid_argument', `变量名 "${key}" 不合法`, '变量名只能含字母、数字、下划线。')
1054
+ skippedVariableKeys.push({ name: key, reason: '标识符只能含字母、数字、下划线' })
1055
+ continue
487
1056
  }
488
1057
  if (typeof raw === 'number') {
489
- if (!Number.isFinite(raw)) throw new CalcError('invalid_argument', `变量 "${key}" 不是有限数字`)
1058
+ if (!Number.isFinite(raw)) {
1059
+ skippedVariableKeys.push({ name: key, reason: '不是有限数字' })
1060
+ continue
1061
+ }
490
1062
  scope[key] = engine.bignumber(String(raw))
491
1063
  } else if (typeof raw === 'string') {
492
1064
  // 允许字符串形式的大数,例如 "1e100"
493
1065
  try {
494
1066
  scope[key] = engine.bignumber(raw.trim())
495
1067
  } catch {
496
- throw new CalcError('invalid_argument', `变量 "${key}" 无法解析为数字: ${raw}`)
1068
+ skippedVariableKeys.push({ name: key, reason: '无法解析为数字' })
497
1069
  }
498
1070
  } else if (typeof raw === 'boolean') {
499
1071
  scope[key] = raw
500
1072
  } else {
501
- throw new CalcError('invalid_argument', `变量 "${key}" 必须是数字或数字字符串`)
1073
+ skippedVariableKeys.push({ name: key, reason: '必须是数字或数字字符串' })
502
1074
  }
503
1075
  }
504
1076
  return scope
@@ -548,6 +1120,24 @@ function opEvaluate(engine: any, expression: string, scope: Record<string, any>,
548
1120
  if (expression === '') {
549
1121
  throw new CalcError('invalid_argument', '表达式为空')
550
1122
  }
1123
+ /**
1124
+ * 数学记号归一化(`**` → `^`、`π` → `pi`、`ln(` → `log(`、`×`/`÷`)。
1125
+ *
1126
+ * 为什么必须在这里:其它 op(derivative / integral / stats / solve…)
1127
+ * 都在内部调了 `normalizeSource`,**只有 evaluate 漏了** ——
1128
+ * 于是 `2**10`、`π`、`2 × 3` 这些直觉写法在 evaluate 上全部报错,
1129
+ * 而同一套写法在 formula 的 overrides 里却能过(实测反馈的语法糖问题)。
1130
+ *
1131
+ * 幂等:这些替换重复执行结果不变,所以不会因多次归一化而出错。
1132
+ */
1133
+ expression = normalizeSource(expression)
1134
+ /**
1135
+ * 中文单位展开:把「1 里 + 3 米」变成 mathjs 能算的形态。
1136
+ *
1137
+ * 必须放在 normalizeSource **之后**:前者处理数学记号(× ÷ π **),
1138
+ * 后者处理单位名;顺序反了会让 `2 × 3 里` 这类混合写法漏掉。
1139
+ */
1140
+ expression = normalizeChineseUnits(expression)
551
1141
  if (expression.length > MAX_INPUT_LENGTH) {
552
1142
  throw new CalcError('too_large', `表达式过长(${expression.length} > ${MAX_INPUT_LENGTH})`)
553
1143
  }
@@ -555,11 +1145,14 @@ function opEvaluate(engine: any, expression: string, scope: Record<string, any>,
555
1145
  try {
556
1146
  node = engine.parse(expression)
557
1147
  } catch (error) {
558
- throw translateError(error, 'syntax')
1148
+ throw translateError(error, 'syntax', expression)
559
1149
  }
560
1150
  guardAst(node)
561
1151
  guardComplexity(node)
562
1152
 
1153
+ // 拦「未定义单字母被当单位」的静默错误(实测反馈 P0-2)
1154
+ guardBareUnitSymbols(node, scope)
1155
+
563
1156
  let raw: any
564
1157
  try {
565
1158
  raw = node.evaluate(scope)
@@ -568,8 +1161,53 @@ function opEvaluate(engine: any, expression: string, scope: Record<string, any>,
568
1161
  }
569
1162
 
570
1163
  const value = unwrapResult(raw)
1164
+ /**
1165
+ * ## 非有限值必须在**这里**拦,不能指望顶层
1166
+ *
1167
+ * 顶层的 `containsNonFinite(result.value)` 拿到的是**序列化后**的值:
1168
+ * 带单位的结果被压成字符串 `"Infinity g"`,而那个判据只认恰好 `"Infinity"`。
1169
+ * 于是:
1170
+ *
1171
+ * ```
1172
+ * 1/0 → 错误[non_finite] ✓ 拦住
1173
+ * (1/0)*2 kg → ok=true, "Infinity g" ✗ 放过
1174
+ * ```
1175
+ *
1176
+ * 而后者更危险:它看起来**像个带单位的正经结果**(`Infinity g` 读起来
1177
+ * 像「无穷克」,不像「算错了」),而且会顺着链式推演传下去。
1178
+ *
1179
+ * 所以判在原始对象上 —— `isNonFiniteValue` 会下钻到 `Unit.value`。
1180
+ * 注意 `1e400`、`2^1024` 这类**有限的极大数**不能误判:
1181
+ * 它们转成双精度才溢出,而 BigNumber 自己知道自己是有限的。
1182
+ */
1183
+ if (isNonFiniteValue(value)) {
1184
+ const shown = String(raw)
1185
+ throw new CalcError(
1186
+ 'non_finite',
1187
+ `计算结果非有限(${shown})`,
1188
+ '常见于除以零、tan(π/2)、log(0)。若要符号形式可改用 operation="simplify"。',
1189
+ )
1190
+ }
1191
+ /**
1192
+ * 表达式里写了目标单位(`.to('cm')`、`5 cm to inch`)时,**不归位**。
1193
+ *
1194
+ * 归位的职责是「调用方没指定时挑一个顺口的档」;指定了就不该被改写。
1195
+ * 详见 `formatValue` 的 `respectExplicitUnit` 参数说明 —— 那里记着
1196
+ * `2 m to cm` 被改回 `2 m` 这条回归。
1197
+ */
1198
+ const explicitUnit = usesExplicitUnitConversion(node)
1199
+ const text = formatValue(engine, value, precision, currentDisplayPrecision(), explicitUnit)
571
1200
  return {
572
- result: formatValue(engine, value, precision, currentDisplayPrecision()),
1201
+ /**
1202
+ * 带 SI 质量前缀时补一句中文量级参照(如 `87.78 Tg(= 8778 万吨)`)。
1203
+ *
1204
+ * 只对「质量 × 克」这一族生效 —— 它是中文语料里量级混用最严重的场景,
1205
+ * 也是实测反馈里唯一真实出过事故的地方(两次把「吨」当成「亿吨」)。
1206
+ *
1207
+ * 其它单位、复合单位一律原样返回:量级词在那个位置不适用,
1208
+ * 硬套只会制造噪音。
1209
+ */
1210
+ result: annotateMetricPrefix(text),
573
1211
  value: toJsonValue(value),
574
1212
  }
575
1213
  }
@@ -598,9 +1236,10 @@ function opEvaluate(engine: any, expression: string, scope: Record<string, any>,
598
1236
  */
599
1237
  function opEvaluateBatch(
600
1238
  engine: any,
601
- expressions: string[],
1239
+ expressions: Array<string | { label?: string; expr?: string }>,
602
1240
  scope: Record<string, any>,
603
1241
  precision: number,
1242
+ terse = false,
604
1243
  ): Outcome {
605
1244
  const items = expressions.slice(0, MAX_BATCH_EXPRESSIONS)
606
1245
  if (items.length === 0) {
@@ -608,44 +1247,290 @@ function opEvaluateBatch(
608
1247
  }
609
1248
 
610
1249
  const shown: string[] = []
611
- const values: Array<{ expression: string; ok: boolean; result?: string; error?: string }> = []
1250
+ const values: Array<{ expression: string; label?: string; ok: boolean; result?: string; error?: string }> = []
1251
+ /** 量级类提示(不影响成败,只提醒核对)。 */
1252
+ const magnitudeWarnings: string[] = []
612
1253
  let successCount = 0
613
1254
  let failCount = 0
614
1255
 
615
1256
  for (const raw of items) {
616
- const expression = typeof raw === 'string' ? raw.trim() : String(raw ?? '')
617
- if (expression === '') {
618
- values.push({ expression, ok: false, error: 'empty' })
1257
+ /**
1258
+ * 两种条目形态:
1259
+ * 字符串 `"1/7"`
1260
+ * 带标签对象 `{label:"储耗比-中值", expr:"139e8/152e4"}`
1261
+ *
1262
+ * ## 为什么要 label
1263
+ *
1264
+ * 实测反馈:同一批算两个口径(`["139e8/1743500","139e8/152e4"]` → 7972 / 9144),
1265
+ * 输出里**只有顺序号区分**。后续引用时得回翻表达式原文,
1266
+ * 报告里「一次差点引错」。
1267
+ *
1268
+ * 批内条目一多(该会话单批最多 14 条),纯靠位置对齐的心智负担很大。
1269
+ */
1270
+ /**
1271
+ * ## 纯数字元素自动转成表达式
1272
+ *
1273
+ * 实测反馈(2026-11-06 第二份):`expressions: [0.5, 9.9, 10]` 五条全被当成
1274
+ * **「空表达式」跳过**,汇总「成功 0,失败 5」。
1275
+ *
1276
+ * 这是**类型错误被伪装成内容错误** —— 用户传的是合法数字,
1277
+ * 工具说「空表达式」,会让人去翻数据源而不是检查类型。
1278
+ *
1279
+ * 转成字符串是零风险的:`0.5` → `"0.5"` → 正常求值得 0.5。
1280
+ * 布尔与 null 不转换(那不是「一个数」,是调用方写错了,该报错)。
1281
+ */
1282
+ const entry =
1283
+ typeof raw === 'string'
1284
+ ? { expr: raw }
1285
+ : typeof raw === 'number' && Number.isFinite(raw)
1286
+ ? { expr: String(raw) }
1287
+ : (raw as { label?: string; expr?: string }) ?? {}
1288
+ /**
1289
+ * ## 元素里的未知键也要点名
1290
+ *
1291
+ * 顶层的未知参数有 `findUnknownArguments` 拦着,但**数组元素内部没查**。
1292
+ * 实测(2026-11-06):
1293
+ *
1294
+ * ```
1295
+ * {expressions:[{label:'a', expr:'1+1', unit:'kg/day'}]} → ok=true,unit 被静默丢弃
1296
+ * {expression:'1+1', unit:'kg/day'} → 报 unknown_argument ✓
1297
+ * ```
1298
+ *
1299
+ * 危险在于:将来若要加 `unit` / `assumption` 这类**按条**的参数,
1300
+ * 它们必然写在这个位置 —— 而现有行为会让它们**静默不生效**,
1301
+ * 调用方以为生效了。这正是本项目一直在打击的那类问题。
1302
+ *
1303
+ * 处理方式:认不出的键当作**该条失败**(而不是整批失败),
1304
+ * 这样一条写错不影响其余条目 —— 与「逐条独立报错」的既有约定一致。
1305
+ */
1306
+ /** 元素级允许的键。新增一条按条参数时记得加在这里,否则会被自己的校验拦下。 */
1307
+ const allowedEntryKeys = ['label', 'expr', 'sanity']
1308
+ const unknownEntryKeys =
1309
+ typeof raw === 'string'
1310
+ ? []
1311
+ : Object.keys(raw as Record<string, unknown>).filter(function (k) { return !allowedEntryKeys.includes(k) })
1312
+ if (unknownEntryKeys.length > 0) {
1313
+ const echoName = (entry as { label?: string }).label || String((entry as { expr?: string }).expr ?? '')
1314
+ values.push({ expression: '', label: String(echoName), ok: false, error: 'unknown_argument' })
1315
+ shown.push(
1316
+ ' ' + echoName + ' → 失败[unknown_argument]:这一条里有认不出的键 ' +
1317
+ unknownEntryKeys.join('、') + '(只接受 ' + allowedEntryKeys.join(' / ') + ')',
1318
+ )
1319
+ failCount++
1320
+ continue
1321
+ }
1322
+ const label = typeof entry.label === 'string' ? entry.label.trim() : ''
1323
+ const rawExpression = String(entry.expr ?? '').trim()
1324
+
1325
+ if (rawExpression === '') {
1326
+ values.push({ expression: rawExpression, label, ok: false, error: 'empty' })
619
1327
  shown.push(' (空表达式)→ 跳过')
620
1328
  failCount++
621
1329
  continue
622
1330
  }
1331
+ /**
1332
+ * 归一化只用于**计算**,回显仍用用户原文。
1333
+ *
1334
+ * 否则用户写 `π`、`2**10` 会看到自己被回显成 `pi`、`2^10` ——
1335
+ * 那会让人怀疑「我传的到底被改成了什么」。
1336
+ */
1337
+ const expression = normalizeChineseUnits(normalizeSource(rawExpression))
623
1338
  try {
624
- const node = engine.parse(expression)
1339
+ /**
1340
+ * 解析单独包一层:给 `translateError` 传 `'syntax'` 作为兜底类型。
1341
+ *
1342
+ * 为什么:单条求值里写的是 `translateError(error, 'syntax')`,
1343
+ * 而批量这里原来是 `translateError(error)` —— 默认兜底是 `internal`。
1344
+ * 于是 `不存在xyz(1)` 在单条里报 syntax、在批量里报 internal。
1345
+ * `internal` 的含义是「工具自己坏了」,会误导人去重试同一个错,必须对齐。
1346
+ */
1347
+ let node: any
1348
+ try {
1349
+ node = engine.parse(expression)
1350
+ } catch (error) {
1351
+ throw translateError(error, 'syntax', expression)
1352
+ }
625
1353
  guardAst(node)
626
1354
  guardComplexity(node)
1355
+
1356
+ // 拦「未定义单字母被当单位」的静默错误(实测反馈 P0-2)
1357
+ guardBareUnitSymbols(node, scope)
627
1358
  const value = unwrapResult(node.evaluate(scope))
628
- const text = formatValue(engine, value, precision, currentDisplayPrecision())
629
- values.push({ expression, ok: true, result: text })
630
- shown.push(' ' + expression + ' = ' + text)
1359
+ /**
1360
+ * ## 批量也必须拦非有限值
1361
+ *
1362
+ * 实测(1107 报告问题 2、3):单条 `1/0` 报错,批量却宣告
1363
+ * 「算式全部求值成功(4 条)」,其中就包括 `1/0` 与 `log(0)`。
1364
+ *
1365
+ * 这构成一条危险的对称破坏:**同一个式子,换个调用方式就从「错」
1366
+ * 变成「对」**。而批量恰恰是 AI 最常用的形态(一次算 8—14 条),
1367
+ * 于是最该拦住的地方恰好不拦。
1368
+ *
1369
+ * 归入 `failed` 而不是单独一类,理由是与单条 `non_finite` 对齐:
1370
+ * 调用方的分流逻辑只需要认 `ok/partial/failed` 三个字段。
1371
+ */
1372
+ if (isNonFiniteValue(value)) {
1373
+ throw new CalcError(
1374
+ 'non_finite',
1375
+ `计算结果非有限(${String(value)})`,
1376
+ '常见于除以零、tan(π/2)、log(0)。若要符号形式可改用 operation="simplify"。',
1377
+ )
1378
+ }
1379
+ /** 批量同样要尊重显式目标单位(见 `usesExplicitUnitConversion`)。 */
1380
+ const text = formatValue(
1381
+ engine, value, precision, currentDisplayPrecision(), usesExplicitUnitConversion(node),
1382
+ )
1383
+ values.push({ expression, label: label || undefined, ok: true, result: text })
1384
+ /**
1385
+ * 回显格式:有 label 用 label,否则用用户原文。
1386
+ *
1387
+ * 回显原文(`π` 而不是 `pi`)是刻意的 —— 否则用户会怀疑「我传的到底被改成了什么」。
1388
+ */
1389
+ const echo = label ? label : rawExpression
1390
+ /**
1391
+ * 量级标注:给裸数字配上「这是 10 的几次方」。
1392
+ *
1393
+ * 实测复盘里最贵的一次错误是:`/1e9` 算出 2133 却贴上「万吨」标签,
1394
+ * 据此指控原文「错了 10 倍」。返回体当时只有一行裸数字,看不出量级。
1395
+ * 标上 `(~10³)` 之后,「2133 与万吨差一个量级」这件事人眼即可发现。
1396
+ */
1397
+ /**
1398
+ * 量级标注:给裸数字配上「这是 10 的几次方」。
1399
+ *
1400
+ * 实测复盘里最贵的一次错误是:`/1e9` 算出 2133 却贴上「万吨」标签,
1401
+ * 据此指控原文「错了 10 倍」。返回体当时只有一行裸数字,看不出量级。
1402
+ *
1403
+ * ## 带单位的结果另走一条
1404
+ *
1405
+ * `annotateMagnitude` 内部做 `Number(text)`,而 `87.78 Mt` 会得到 NaN ——
1406
+ * 带单位时它静默失效。所以这里按结果形态分派:
1407
+ * 纯数字走 `(~10^N)`,带单位走 `explainUnitResult`(给出基准值与中文量级)。
1408
+ */
1409
+ const shownValue = annotateMagnitude(text)
1410
+ const unitExplain = explainUnitResult(text)
1411
+ shown.push(
1412
+ ' ' + echo + (label ? '(' + rawExpression + ')' : '') + ' = ' + shownValue +
1413
+ (unitExplain ? '|' + unitExplain : ''),
1414
+ )
1415
+ /**
1416
+ * label 与结果的量级核对:只有 label 里**明确写了量级词**(万/亿/千…)才查,
1417
+ * 且结果本身已跨过那个量级时才提示 —— 宁漏勿误。
1418
+ */
1419
+ if (label) {
1420
+ const warn = checkLabelMagnitude(label, text)
1421
+ if (warn) magnitudeWarnings.push(warn)
1422
+ }
1423
+ /**
1424
+ * ③ 锚点比对:调用方给了 `sanity` 就拿它的判据去比。
1425
+ *
1426
+ * 三条来源共用同一个 `magnitudeWarnings` 通道 —— 它的下游
1427
+ * (terse 分支、汇总行、结构化计数)全部自动生效,不必改三处。
1428
+ *
1429
+ * 为什么在这里而不是在缓存层:批量路径**根本不走缓存**(见上方注释),
1430
+ * 所以不存在「换锚点却命中旧提示」的问题。
1431
+ */
1432
+ if (typeof entry === 'object' && entry !== null) {
1433
+ const sanity = (entry as { sanity?: unknown }).sanity
1434
+ const anchorWarn = checkAnchors(sanity, text)
1435
+ if (anchorWarn) {
1436
+ const anchorEcho = label || rawExpression
1437
+ magnitudeWarnings.push(anchorEcho + ' → ' + anchorWarn)
1438
+ }
1439
+ }
631
1440
  successCount++
632
1441
  } catch (error) {
633
1442
  const translated = translateError(error)
634
- values.push({ expression, ok: false, error: translated.type })
1443
+ values.push({ expression, label: label || undefined, ok: false, error: translated.type })
635
1444
  // 失败要显眼,但不能长篇大论 —— 一行说清哪条、什么类型
636
- shown.push(' ' + expression + ' → 失败[' + translated.type + ']' +
1445
+ const echo = label ? label : rawExpression
1446
+ shown.push(' ' + echo + ' → 失败[' + translated.type + ']' +
637
1447
  (translated.hint ? ':' + translated.hint : ''))
638
1448
  failCount++
639
1449
  }
640
1450
  }
641
1451
 
1452
+ /**
1453
+ * `terse` 模式:只回「表达式 = 结果」,去掉标题与统计。
1454
+ *
1455
+ * 实测反馈:批量 8—14 条时,标题与统计行本身也是固定开销;
1456
+ * 报告估算「若全程 terse,省 40%+ 工具输出 token」。
1457
+ *
1458
+ * 但**失败信息不能省**:省掉失败行会让调用方以为「全成功了」。
1459
+ */
1460
+ if (terse) {
1461
+ /**
1462
+ * terse 的承诺是「省掉标题与统计」,**不是**省掉风险提示。
1463
+ *
1464
+ * 踩过:第一版把 ⚠ 量级提示一起省了,于是 terse 用户看到的是一行干净数字,
1465
+ * 而干净正是最危险的 —— 他会以为没问题。
1466
+ */
1467
+ const terseWarnings: string[] = []
1468
+ const terseBatch = findBatchAnomalies(
1469
+ values
1470
+ .filter(function (v) { return v.ok && typeof v.result === 'string' })
1471
+ .map(function (v) { return { label: v.label, result: String(v.result) } }),
1472
+ )
1473
+ if (terseBatch) magnitudeWarnings.push(terseBatch)
1474
+ if (magnitudeWarnings.length > 0) {
1475
+ terseWarnings.push('')
1476
+ for (const warning of magnitudeWarnings) terseWarnings.push(warning)
1477
+ }
1478
+ return {
1479
+ result: [...shown.map((s) => s.trim()), ...terseWarnings].join('\n'),
1480
+ value: toJsonValue({
1481
+ count: items.length,
1482
+ success: successCount,
1483
+ failed: failCount,
1484
+ magnitudeWarnings: magnitudeWarnings.length,
1485
+ items: values,
1486
+ } as never),
1487
+ }
1488
+ }
1489
+
642
1490
  const header = items.length < expressions.length
643
1491
  ? `═══ 批量求值(前 ${items.length} 条,共给了 ${expressions.length} 条)═══`
644
1492
  : `═══ 批量求值(${items.length} 条)═══`
645
1493
 
1494
+ /**
1495
+ * 同批次量级一致性比对。
1496
+ *
1497
+ * 真实案例:四条「年耗/年产(万吨)」全部用了 /1e9(实际是千吨),
1498
+ * 全部偏大 10 倍、全部报「成功」。单条看不出问题,四条一起就该看出来。
1499
+ */
1500
+ const batchWarning = findBatchAnomalies(
1501
+ values
1502
+ .filter(function (v) { return v.ok && typeof v.result === 'string' })
1503
+ .map(function (v) { return { label: v.label, result: String(v.result) } }),
1504
+ )
1505
+ if (batchWarning) magnitudeWarnings.push(batchWarning)
1506
+
1507
+ /**
1508
+ * 分级语义:把「算式没塌」与「结论已验证」分开。
1509
+ *
1510
+ * 实测反馈原话:「成功 7,失败 0」会让调用方跳过复核 ——
1511
+ * 因为「成功」两个字读起来像「对了」。这里显式说明它只表示什么。
1512
+ */
1513
+ const summary: string[] = []
1514
+ if (failCount === 0) {
1515
+ summary.push('算式全部求值成功(' + successCount + ' 条,只表示算式无语法/符号错,不含结论正确性判断)')
1516
+ } else {
1517
+ summary.push('成功 ' + successCount + ',失败 ' + failCount)
1518
+ }
1519
+ if (magnitudeWarnings.length > 0) {
1520
+ summary.push('')
1521
+ summary.push('⚠ 量级待核(' + magnitudeWarnings.length + ' 条):')
1522
+ for (const warning of magnitudeWarnings) summary.push(warning)
1523
+ }
1524
+
646
1525
  return {
647
- result: [header, '', ...shown, '', `成功 ${successCount},失败 ${failCount}`].join('\n'),
648
- value: toJsonValue({ count: items.length, success: successCount, failed: failCount, items: values } as never),
1526
+ result: [header, '', ...shown, '', ...summary].join('\n'),
1527
+ value: toJsonValue({
1528
+ count: items.length,
1529
+ success: successCount,
1530
+ failed: failCount,
1531
+ magnitudeWarnings: magnitudeWarnings.length,
1532
+ items: values,
1533
+ } as never),
649
1534
  }
650
1535
  }
651
1536
 
@@ -674,10 +1559,86 @@ function opDerivative(expression: string, variable: string, precision: number):
674
1559
  return { result: symbolic, value: symbolic }
675
1560
  }
676
1561
 
677
- function opIntegral(expression: string, variable: string, precision: number): Outcome {
1562
+ function opIntegral(
1563
+ expression: string,
1564
+ variable: string,
1565
+ precision: number,
1566
+ from?: number,
1567
+ to?: number,
1568
+ ): Outcome {
678
1569
  const text = normalizeSource(expression)
679
1570
  if (text === '') throw new CalcError('invalid_argument', '表达式为空')
1571
+
1572
+ // 只给一端是含糊的输入:要么两端都给,要么都不给
1573
+ if ((from === undefined) !== (to === undefined)) {
1574
+ throw new CalcError(
1575
+ 'invalid_argument',
1576
+ '定积分需要同时给 from 与 to',
1577
+ '需要同时给两端(如 from=0, to=3);只给一端无法确定积分区间。',
1578
+ )
1579
+ }
1580
+
680
1581
  const primitive = symbolicIntegral(text, variable)
1582
+
1583
+ /**
1584
+ * ── 给了上下限:算定积分 ──
1585
+ *
1586
+ * 实测反馈(P1-3):以前传 `from`/`to` 会被**静默忽略**,
1587
+ * 只返回不定积分 `1/3*x^3 + C` —— 调用者以为拿到了定积分。
1588
+ * 文档的原话:「绝不要静默忽略已传入的参数,这是最伤信任的行为。」
1589
+ *
1590
+ * 现在按 Newton-Leibniz 算 F(to) − F(from)。
1591
+ */
1592
+ if (from !== undefined && to !== undefined) {
1593
+ const engine = bignum(precision)
1594
+ /**
1595
+ * 用**有理数引擎**代入,而不是 BigNumber。
1596
+ *
1597
+ * 实测对比(原函数 1/3·x³ 在 x=3 处):
1598
+ * BigNumber → 8.99999999999999999999999999999999999999…
1599
+ * Fraction → 9
1600
+ *
1601
+ * BigNumber 的 `1/3` 是截断小数,乘 27 回不到整数。
1602
+ * 定积分出现的多半是有理系数多项式,Fraction 能约分回精确值。
1603
+ */
1604
+ const rationalEngine = rational()
1605
+ let exact: any
1606
+ try {
1607
+ const F = rationalEngine.parse(primitive)
1608
+ const upper = F.evaluate({ [variable]: to })
1609
+ const lower = F.evaluate({ [variable]: from })
1610
+ exact = rationalEngine.subtract(upper, lower)
1611
+ } catch (error) {
1612
+ throw new CalcError(
1613
+ 'unsupported',
1614
+ '原函数 ' + primitive + ' 无法代入上下限求值',
1615
+ '这是符号引擎的能力边界。可改用 operation="evaluate" 自己做数值积分(如黎曼和)。',
1616
+ )
1617
+ }
1618
+
1619
+ /**
1620
+ * 显示:优先精确形式(整数或分数),并给出 BigNumber 小数近似。
1621
+ *
1622
+ * 为什么两个都给:`1/3` 这种系数在定积分里很常见,
1623
+ * 化为 `6.75` 会丢「精确等于 27/4」这个信息;而工程场景又常需要小数。
1624
+ */
1625
+ const exactTextRaw = rationalEngine.format(exact)
1626
+ // 分母为 1 时不该写成 `9/1`(mathjs 的 ratio 格式会保留 /1)
1627
+ const exactText = exactTextRaw.replace(/\/1$/, '')
1628
+ const asNumber = Number(rationalEngine.number(exact))
1629
+ const decimalText = Number.isFinite(asNumber)
1630
+ ? formatValue(engine, engine.bignumber(String(asNumber)), precision, currentDisplayPrecision())
1631
+ : exactText
1632
+ const isFraction = exactText.includes('/')
1633
+ return {
1634
+ result:
1635
+ '原函数:' + primitive + ' + C\n' +
1636
+ '定积分 ∫[' + from + '→' + to + '] ' + text + ' d' + variable + ' = ' +
1637
+ exactText + (isFraction ? '(= ' + decimalText + ')' : ''),
1638
+ value: toJsonValue(exactText),
1639
+ }
1640
+ }
1641
+
681
1642
  return { result: `${primitive} + C`, value: primitive }
682
1643
  }
683
1644
 
@@ -1563,7 +2524,12 @@ function opFormula(args: MathArgs, engine: any, precision: number): Outcome {
1563
2524
 
1564
2525
  const overrides = (args.overrides ?? {}) as Record<string, number | string>
1565
2526
 
1566
- let resolved: { values: Record<string, unknown>; overridden: string[]; missing: string[] }
2527
+ let resolved: {
2528
+ values: Record<string, unknown>
2529
+ overridden: string[]
2530
+ missing: string[]
2531
+ unknownKeys: string[]
2532
+ }
1567
2533
  try {
1568
2534
  resolved = resolveParams(formula, overrides)
1569
2535
  } catch (error) {
@@ -1581,6 +2547,17 @@ function opFormula(args: MathArgs, engine: any, precision: number): Outcome {
1581
2547
  )
1582
2548
  }
1583
2549
 
2550
+ /**
2551
+ * ── 未知参数名:明确告知,不静默丢弃 ──
2552
+ *
2553
+ * 实测反馈:overrides 的 key 拼错时公式**照常出数**,只是那个参数用了默认值。
2554
+ * 报告里那次「236 倍缺口」误判就是这么来的 —— 而结果看着完全正常。
2555
+ *
2556
+ * 这里不抛错(可能是调用方故意传常数名,或用法上无伤),
2557
+ * 而是把「你没生效的参数」写成一条提示,让调用方能自己判断。
2558
+ */
2559
+ const unknownOverrideKeys = resolved.unknownKeys ?? []
2560
+
1584
2561
  const expression = resolveExpression(formula, args.customExpression)
1585
2562
  const scope = buildFormulaScope(engine, resolved.values)
1586
2563
 
@@ -1619,7 +2596,7 @@ function opFormula(args: MathArgs, engine: any, precision: number): Outcome {
1619
2596
  try {
1620
2597
  node = engine.parse(expression)
1621
2598
  } catch (error) {
1622
- throw translateError(error, 'syntax')
2599
+ throw translateError(error, 'syntax', expression)
1623
2600
  }
1624
2601
  guardAst(node)
1625
2602
  guardComplexity(node)
@@ -1634,6 +2611,22 @@ function opFormula(args: MathArgs, engine: any, precision: number): Outcome {
1634
2611
  const parts = [describeFormula(formula, expression, resolved.values, resolved.overridden)]
1635
2612
  parts.push(`结果: ${formatValue(engine, raw, precision, currentDisplayPrecision())}${formula.unit ? ` ${formula.unit}` : ''}`)
1636
2613
 
2614
+ /**
2615
+ * 未知参数名提示:把「你没生效的 key」明确说出来。
2616
+ *
2617
+ * 紧跟在结果后面(而不是压到输出末尾),因为这类问题会直接改变结论,
2618
+ * 读者必须第一眼就看到。
2619
+ */
2620
+ if (unknownOverrideKeys.length > 0) {
2621
+ parts.push('')
2622
+ parts.push(
2623
+ '⚠ 以下 overrides 的 key 不在公式「' + formula.name + '」的参数里,**已被忽略**:' +
2624
+ unknownOverrideKeys.join('、') +
2625
+ '。该公式的参数是:' + Object.keys(formula.params).join('、') +
2626
+ '。若这是拼写笔误,结果可能已偏离——请核对后重算。',
2627
+ )
2628
+ }
2629
+
1637
2630
  /**
1638
2631
  * 经验公式的适用性提示。
1639
2632
  *
@@ -1834,123 +2827,6 @@ function opFormulas(args: MathArgs): Outcome {
1834
2827
  return { result: lines.join('\n'), value: { query, matches: matches.map((m) => m.id) } }
1835
2828
  }
1836
2829
 
1837
- /** 能力清单。 */
1838
-
1839
- /** 能力清单。 */
1840
- function opHelp(): Outcome {
1841
- return {
1842
- result: [
1843
- '══════ math_execute 完整能力清单 ══════',
1844
- '',
1845
- '【求值与代数】',
1846
- ' evaluate 任意 mathjs 表达式;BigNumber 64 位精度',
1847
- ' 例:10^20+5、factorial(50)、(1+2i)*(3-i)、5 cm to inch、sin(30 deg)',
1848
- ' 批量:给 expressions=["1/7","2^0.5","log(10)"] 一次算多条,',
1849
- ' 逐条独立报错;比多次调用省往返',
1850
- ' solve 解方程。多方程逗号分隔 = 方程组',
1851
- ' 例:3x + 2y = 10, x - y = 1 / x^2 - 5x + 6 = 0',
1852
- ' system 显式线性系统(matrix + vector)',
1853
- ' derivative 符号求导(order 可指定高阶)',
1854
- ' integral 符号不定积分',
1855
- ' simplify 符号化简',
1856
- ' expand 符号展开',
1857
- ' roots 多项式求根(含复数根)',
1858
- '',
1859
- '【扩展函数】(可直接写在表达式里)',
1860
- ' rank(A) 矩阵的秩;nullspace(A) 零空间基;svd(A) 奇异值',
1861
- ' factorize(n) 整数质因数分解',
1862
- ' nsolve("表达式", 初值) 数值求根(牛顿法 + 二分兜底)',
1863
- '',
1864
- '【线性代数】',
1865
- ' matrix 矩阵乘法/求逆/行列式/转置/迹;只给 matrix 时输出完整画像',
1866
- ' (秩/行列式/迹/奇异值/零空间/逆)',
1867
- ' eigen 特征值与特征向量',
1868
- '',
1869
- '【统计与概率】',
1870
- ' stats 描述统计(均值/中位数/众数/方差/标准差/分位数/偏度/峰度)',
1871
- ' dist 概率分布:normal/binomial/poisson/exponential/chisquare/t/normalinv',
1872
- ' regression 线性与多项式回归(含 R²)',
1873
- '',
1874
- '【微分方程】',
1875
- ' ode 数值求解 ODE(RK4):一阶方程组(SIR/捕食模型)、二阶方程自动降阶',
1876
- ' 返回轨迹、终点值、极值、发散警告',
1877
- '',
1878
- '【公式库】',
1879
- ' formula 调用已收录的 ' + FORMULAS.length + ' 条自然科学公式',
1880
- ' 例:formula="escape_velocity", overrides={M,r}',
1881
- ' 任何参数可覆盖 —— 架空世界改造的关键',
1882
- ' formulas 检索与分类:',
1883
- ' query="关键词" 按名称/关键词',
1884
- ' query="layer:derived" 按规则层级(fundamental=基本规则、mathematical=数学基础(与基本同级)、derived/phenomenological/engineering/conventional)',
1885
- ' query="domain:物理" 按学科领域(多标签)',
1886
- ' query="freq:high" 按主角感知频率',
1887
- ' query="symbol:m" 按符号含义',
1888
- ' formula-table 两级扩展:工作区临时公式(覆盖/自定义/停用)+ 全局公式包',
1889
- ' (tableAction: global-list / global-add / global-remove)',
1890
- ' 字段与校验规则见 docs/formula-authoring.md',
1891
- '',
1892
- '【世界观推演】',
1893
- ' world 输入星球参数 → 体感画像:重力/昼夜/季节/纬度气候/地形上限 +',
1894
- ' 天空配色/恒星结构与寿命/卫星系统/化学窗口/生命闸门/跨模块一致性检查',
1895
- ' 【world 完整字段表】(全部可选,默认地球)',
1896
- ' 恒星:starMass(太阳质量)、starRadius(太阳半径)、starTemp(K,默认5778)',
1897
- ' 轨道:orbitAU(AU,默认1)、eccentricity(默认0.0167)',
1898
- ' 行星:planetMass(地球质量)、planetRadius(地球半径)、rotationHours(默认23.93)、',
1899
- ' obliquityDeg(默认23.44)、albedo(默认0.306)',
1900
- ' 大气:pressure(Pa,默认101325)、molarMass(kg/mol,默认0.02896)、',
1901
- ' gamma(默认1.4)、greenhouse(增温K,默认33)',
1902
- ' 地表:materialStrength(Pa,默认1.3e8花岗岩)、materialDensity(默认2700)',
1903
- ' 地形 terrain(附带):kind=coastal/plain/plateau/mountain/valley/desert/',
1904
- ' forest/tundra/island/lake;elevation(海拔m)、distanceToSea(km)、',
1905
- ' latitudeDeg、annualPrecipMm、humidity(0-1)、windward(1迎风/-1背风)、',
1906
- ' reliefHeight(m)、oceanCurrent=warm/cold/none、',
1907
- ' surface=soil/sand/rock/ice/water/vegetation',
1908
- ' constants 改变物理常数,自动推导派生量并评估世界偏离度',
1909
- ' 【可改的基础旋钮】c(光速) / G(引力常数) / h(普朗克) / kB(玻尔兹曼) /',
1910
- ' e_charge(元电荷) / me(电子质量) / mp(质子质量) / NA(阿伏伽德罗) /',
1911
- ' epsilon0(真空介电常数)',
1912
- ' 【派生量,自动跟随,不能直接改】hbar / R / mu0 / kCoulomb / sigma /',
1913
- ' bWien / alpha_fs / Ry / muB',
1914
- ' 例:{c:1.5e8} → mu0/sigma/alpha_fs 等自动重算并评估偏离度',
1915
- ' quantities 看哪些物理量该跟着常数变、哪些不该(量纲分析 + 缩放指数)',
1916
- ' impact 改常数后跑全部公式并与现实对比,算每条偏差',
1917
- ' units 单位换算:把「三里」「两丈」「五百斤」换成标准单位',
1918
- ' 不带单位参数 → 列出全部可用单位(含中文古制与天文单位)',
1919
- ' 例:unitValue=30, unitFrom="里", unitTo="km" → 15 km',
1920
- ' 自创单位用 customUnits 覆盖(如把「里」定成 576 m)',
1921
- ' balance 收支盘点:粮/水/能/地/财政/军费/信息 多项一起看,给最短板',
1922
- ' 例:balance={grain:{demand:2.7e6,supply:3.1e6,unit:"kg/年"}}',
1923
- ' 回答「这个王国养不养得起二十万兵」这类问题(feasibility 只管单项上限)',
1924
- ' bodies 天体目录:太阳系 8 大行星 + 卫星 + 代表性系外行星 + 各亮度级恒星',
1925
- ' 例:bodyName="mars" / bodyKind="moon" / 不带参数列全部',
1926
- ' bodyConstant="alpha_fs", bodyFactor=2 → 看改常数后哪些天体数据要重算',
1927
- ' (关键:行星质量/半径/轨道与 α 无关,绝不跟着改)',
1928
- '',
1929
- '【数据管理】',
1930
- ' workspace 工作区管理(一个世界观 = 一套常数表 + 公式表 + 地点 + 变量)',
1931
- ' action2: list/show/create/switch/save/set/add-terrain/delete/reset',
1932
- ' stats/cleanup 可查看存储与清理;不同工作区完全隔离',
1933
- ' 注意:本插件没有图形界面(2026-10-25 已移除),全部经工具与 HTTP 路由操作',
1934
- '',
1935
- '【其他】',
1936
- ' help 本清单',
1937
- ' prompt-mode 调整系统提示注入档位(off/brief/full)',
1938
- '',
1939
- '【常用参数】',
1940
- ' expression 表达式/方程(多方程用逗号)',
1941
- ' variables 变量字典,如 {"m":500,"v":30}',
1942
- ' operation 上面的值;不填=auto',
1943
- ' precision 有效数字位数,默认 64,范围 1-256',
1944
- ' variable 求导/求根的自变量,默认 x',
1945
- ' overrides 公式参数覆盖',
1946
- ' constants 要改的物理常数',
1947
- ' world 星球参数',
1948
- ' terrain 局部地形参数',
1949
- '',
1950
- '【失败时】返回 ok=false + error.type/message/hint,按 hint 修正即可,不会崩溃。',
1951
- ].join('\n'),
1952
- }
1953
- }
1954
2830
 
1955
2831
  // ────────────────────────────────────────────────────────────────────────────
1956
2832
  // 主流程
@@ -1976,6 +2852,123 @@ function normalizeOperation(value: unknown): MathOperation {
1976
2852
  }
1977
2853
 
1978
2854
  /** 工具主入口:永不抛异常。 */
2855
+ /**
2856
+ * 承认的参数名白名单 —— 从 `MathArgs` 接口派生,不手写。
2857
+ *
2858
+ * ## 为什么需要这份白名单
2859
+ *
2860
+ * 踩过的坑:`{expression:'1+3', foo_bar_baz:123}` 会**静默成功**并返回 3。
2861
+ * 调用方以为参数生效了,实际被整个吞掉。这类「以为做了、其实没做」
2862
+ * 比报错危险得多 —— 与 overrides 里 key 拼错被丢弃是同一类问题。
2863
+ *
2864
+ * ## 为什么不靠 JSON Schema 的 additionalProperties
2865
+ *
2866
+ * 工具的 input schema 是宿主侧契约,加严会影响所有历史调用;
2867
+ * 而「参数名拼错」这件事在本工具内部就能判,判得更早、提示更具体。
2868
+ *
2869
+ * ## 白名单从哪来
2870
+ *
2871
+ * 不手写清单(手写必然与接口漂移)。用 TypeScript 的类型约束保证
2872
+ * `KNOWN_ARGUMENT_KEYS` 覆盖 `MathArgs` 的全部键:少一个键 tsconfig 会报错。
2873
+ * 见文件末尾的 `_AssertKeysCoverMathArgs`。
2874
+ */
2875
+ const KNOWN_ARGUMENT_KEYS: ReadonlySet<string> = new Set<string>([
2876
+ // 基础计算
2877
+ 'expression', 'expressions', 'terse', 'variables', 'operation', 'variable',
2878
+ 'matrix', 'vector', 'precision', 'displayPrecision',
2879
+ // 数据与统计
2880
+ 'data', 'dataY', 'distribution', 'params', 'x', 'degree',
2881
+ // 公式
2882
+ 'formula', 'overrides', 'query', 'customExpression',
2883
+ // 微分方程 / 物理
2884
+ 'initial', 'from', 'to', 'steps', 'samples',
2885
+ // 星球与推演
2886
+ 'world', 'terrain', 'constants', 'derivation', 'layer',
2887
+ // 工作区
2888
+ 'action2', 'workspace', 'name', 'description', 'terrainName', 'workspaceVariables',
2889
+ 'useWorkspaceVariables',
2890
+ // 设定一致性
2891
+ 'claims', 'listConstraints',
2892
+ // 单位
2893
+ 'unitValue', 'unitFrom', 'unitTo', 'customUnits', 'unitScales', 'unitScale',
2894
+ // 收支
2895
+ 'balance',
2896
+ // 天体
2897
+ 'bodyName', 'bodyKind', 'bodyConstant', 'bodyFactor', 'bodyType', 'bodyEvidence',
2898
+ // 逆推校准
2899
+ 'anchor', 'anchorValue', 'compareFrom', 'compareTo',
2900
+ // 幻想层
2901
+ 'fantasyAbility', 'fantasyDemand', 'fantasyPhysical', 'fantasyBodyArea',
2902
+ // 混沌 / 继承 / 对比
2903
+ 'lyapunov', 'inheritDerived', 'inheritBase', 'inheritSync', 'diffA', 'diffB',
2904
+ // 观测者 / 图 / 时间轴 / 白皮书 / 溯源 / 算力
2905
+ 'observerKind', 'graphDepths', 'timelineFrom', 'timelineTo', 'timelineSteps',
2906
+ 'whitepaperMode', 'traceTarget', 'costKind', 'costX', 'costY',
2907
+ 'costIterations', 'costCombos', 'costWithPipeline',
2908
+ // 提示档位与帮助分节
2909
+ 'promptMode',
2910
+ 'helpTopic',
2911
+ // 公式表
2912
+ 'tableAction', 'formulaId', 'newExpression', 'newParams', 'customFormula',
2913
+ // 自定义常数
2914
+ 'customAction', 'customConstant', 'customConstantName', 'expectedDimension',
2915
+ // 结论文翻转
2916
+ 'flipClaim', 'flipEpsilon', 'flipBase',
2917
+ // 杂项
2918
+ 'threshold', 'worstLimit', 'variableName', 'constantName',
2919
+ 'emptyOnly', 'dryRun', 'olderThanDays', 'keep', 'tempPrefixes',
2920
+ 'sensitivityBase', 'sensitivityExponent',
2921
+ ]);
2922
+
2923
+ /**
2924
+ * world 的**顶层简写参数**(`{operation:'world', starMass:10}` 这种写法)。
2925
+ *
2926
+ * 这些键**不在 `MathArgs` 接口里**,但它们是合法输入 —— 由 `WORLD_PARAMS` 决定,
2927
+ * `opWorld` 会遍历它把顶层值并入 `world`。
2928
+ *
2929
+ * 踩过的坑:白名单第一版只抄了接口里的 4 个(planetRadius/planetMass/rotationHours/obliquityDeg),
2930
+ * 于是 `{operation:'world', starMass:10}` 被新加的校验拦下 —— **world 套件当场红**。
2931
+ * 教训:白名单不能只认 TypeScript 接口,接口落后于实现的部分会变成假阳性。
2932
+ * 所以这里**从真实数据源 `WORLD_KEYS` 派生**,接口补不全也不影响。
2933
+ */
2934
+ const WORLD_SHORTHAND_KEYS: readonly string[] = WORLD_KEYS
2935
+
2936
+ /**
2937
+ * 查有没有拼错/多余的参数名。
2938
+ *
2939
+ * ## 为什么大小写变体也一律报错
2940
+ *
2941
+ * 做过一版「容忍大小写差异」(把 `DisplayPrecision` 归一到 `displayPrecision`),
2942
+ * 实测发现更糟:**参数被接受,却因为代码只读小写键而完全不生效**,
2943
+ * 输出静默退回 8 位。那不是宽容,那是另一种静默 ——
2944
+ * 比直接报错更难排查,因为连错误信息都没有。
2945
+ *
2946
+ * 所以这里只做**精确匹配**:名字必须是白名单里那个写法,一个字母都不差。
2947
+ *
2948
+ * @param rawArguments - 调用方原样的参数对象
2949
+ * @returns 认不出的键;空数组表示全部可识别
2950
+ */
2951
+ function findUnknownArguments(rawArguments: unknown): string[] {
2952
+ if (rawArguments === null || typeof rawArguments !== 'object' || Array.isArray(rawArguments)) return []
2953
+ const unknown: string[] = []
2954
+ for (const key of Object.keys(rawArguments as Record<string, unknown>)) {
2955
+ if (KNOWN_ARGUMENT_KEYS.has(key)) continue
2956
+ if (WORLD_SHORTHAND_KEYS.includes(key)) continue
2957
+ unknown.push(key)
2958
+ }
2959
+ return unknown.sort()
2960
+ }
2961
+
2962
+ /**
2963
+ * 工具主入口:永不抛异常。
2964
+ *
2965
+ * 所有失败都转成 `{ ok:false, error:{ type, message, hint } }`,
2966
+ * 因为宿主侧把抛异常当成「工具坏了」,而这里的失败几乎都是**输入问题**,
2967
+ * 调用方按 hint 改一下就能过。
2968
+ *
2969
+ * @param rawArguments - 宿主传进来的参数对象(可能是任意形态,这里先做白名单校验)
2970
+ * @returns 永远是一个 MathResult,不会 throw
2971
+ */
1979
2972
  export function runMathTool(rawArguments: unknown): MathResult {
1980
2973
  const args = (rawArguments ?? {}) as Partial<MathArgs>
1981
2974
  const input = typeof args.expression === 'string' ? args.expression.trim() : ''
@@ -1986,6 +2979,22 @@ export function runMathTool(rawArguments: unknown): MathResult {
1986
2979
  let displayPrecision: number | undefined
1987
2980
 
1988
2981
  try {
2982
+ /**
2983
+ * 先把「认不出的参数名」拦下来。
2984
+ *
2985
+ * 不静默继续算 —— 那样调用方会以为某个参数已经生效
2986
+ * (实测见过:`displayPrecision:0`、`format`、`sigfigs` 都是这样被吞的)。
2987
+ */
2988
+ const unknownArguments = findUnknownArguments(rawArguments)
2989
+ if (unknownArguments.length > 0) {
2990
+ throw new CalcError(
2991
+ 'unknown_argument',
2992
+ '这些参数名认不出来,已经被忽略(本次没有生效): ' + unknownArguments.join('、'),
2993
+ '按名字传给 help 可查用法: {operation:"help"}。' +
2994
+ '常见原因:拼写笔误、大小写、把某个 operation 的子字段提到了顶层' +
2995
+ '(如 terrain 的字段要放在 terrain 对象里)。',
2996
+ )
2997
+ }
1989
2998
  precision = clampPrecision(args.precision)
1990
2999
  /**
1991
3000
  * 显示精度:本次显式给 > 工作区固化值 > 沿用计算精度。
@@ -2052,9 +3061,26 @@ export function runMathTool(rawArguments: unknown): MathResult {
2052
3061
  setActiveDisplayPrecision(displayPrecision)
2053
3062
  const variable = normalizeVariable(args.variable)
2054
3063
  const requested = normalizeOperation(args.operation)
2055
- const known = knownNumbers(args.variables)
3064
+ /**
3065
+ * 作用域输入按「本次参数 > 工作区变量」合并。
3066
+ *
3067
+ * 合并只发生在**计算路径**(这一处),且受工作区开关 `useWorkspaceVariables` 控制;
3068
+ * 开关关闭时 `mergeWorkspaceVariables` 原样返回 `args.variables`,
3069
+ * 行为与旧版完全一致。
3070
+ *
3071
+ * 为什么只在这一处合并、而不是每个 operation 各自去读工作区:
3072
+ * 那样每个 op 都要自己处理「合并 + 优先级 + 缓存指纹」,
3073
+ * 迟早有一处漏掉(缓存指纹漏掉就是静默返回旧值)。
3074
+ */
3075
+ const scopeVariables = mergeWorkspaceVariables(args.variables)
3076
+ const known = knownNumbers(scopeVariables)
2056
3077
  const engine = bignum(precision)
2057
- const scope = buildScope(engine, args.variables)
3078
+ /**
3079
+ * 每次调用开头清空「被跳过的变量键」记录,避免串到上一次调用。
3080
+ * 与 `activeDisplayPrecision` 同理:模块级状态必须每次开头复位。
3081
+ */
3082
+ resetSkippedVariableKeys()
3083
+ const scope = buildScope(engine, scopeVariables)
2058
3084
 
2059
3085
  operation = requested === 'auto' ? detectOperation(input, args) : requested
2060
3086
 
@@ -2151,6 +3177,11 @@ export function runMathTool(rawArguments: unknown): MathResult {
2151
3177
  stableArgs(restForCache),
2152
3178
  activeWorkspaceNameForCache(),
2153
3179
  constantsFingerprint(),
3180
+ /**
3181
+ * 变量内容必须进键:见 `workspaceVariablesFingerprint` 的说明。
3182
+ * 开关关闭时这一项是常量 'disabled',等价于旧行为。
3183
+ */
3184
+ workspaceVariablesFingerprint(),
2154
3185
  ])
2155
3186
  const cached = getCached(key)
2156
3187
  if (cached !== undefined) {
@@ -2172,26 +3203,80 @@ export function runMathTool(rawArguments: unknown): MathResult {
2172
3203
  * 缓存它们只会挤占单条计算的位置。
2173
3204
  */
2174
3205
  if (Array.isArray(args.expressions) && args.expressions.length > 0) {
2175
- outcome = opEvaluateBatch(engine, args.expressions, scope, precision)
3206
+ outcome = opEvaluateBatch(engine, args.expressions, scope, precision, args.terse === true)
3207
+ /**
3208
+ * 批量里**有失败**时,顶层 `ok` 必须为 false。
3209
+ *
3210
+ * ## 修的是什么
3211
+ *
3212
+ * 实测反馈(2026-11-06):批量里一条 `unknown_symbol` 失败时,
3213
+ * `result` 里写着「→ 失败[unknown_symbol]」,但 `ok` 仍是 `true`、
3214
+ * `error` 是 `undefined`。于是任何写 `if (r.ok)` 的调用方
3215
+ * **都会把失败当成成功**,再把失败文本当数值用。
3216
+ *
3217
+ * 原话:「我在本次任务中多次写 `if (!r.ok)` 来判断成败,
3218
+ * 得到的结果是『永远成功』——因为 `ok` 永远是 `true`。」
3219
+ *
3220
+ * ## 为什么不是「一条失败就整批失败」
3221
+ *
3222
+ * 「一条失败不影响其它条」是这个批量模式的核心优点,不能丢。
3223
+ * 所以这里分开表达:
3224
+ * · `ok: false` —— 告诉自动化流程「本批**不是**全好」
3225
+ * · `partial: true` —— 告诉它「但有成功的条目可用」
3226
+ * · `failed / succeeded` —— 给出具体数量,便于分流处理
3227
+ *
3228
+ * 顶层 `error` 仍是 `undefined`(这是**批量**失败,不是某个单条的错误;
3229
+ * 每条的原因都在 `value.items` 里)。若硬塞一个 error,
3230
+ * 反而会让「只有第 3 条错」被读成「整个调用都错了」。
3231
+ */
3232
+ const batchValue = (outcome.value ?? {}) as Record<string, unknown>
3233
+ const failedCount = typeof batchValue.failed === 'number' ? batchValue.failed : 0
3234
+ const succeededCount = typeof batchValue.success === 'number' ? batchValue.success : 0
2176
3235
  const batchResult: MathResult = {
2177
- ok: true,
3236
+ ok: failedCount === 0,
2178
3237
  operation: 'evaluate',
2179
3238
  input: args.expressions.join(' ; ').slice(0, 200),
2180
3239
  result: outcome.result,
2181
3240
  precision,
2182
3241
  }
2183
3242
  if (outcome.value !== undefined) batchResult.value = outcome.value
3243
+ if (failedCount > 0) {
3244
+ batchResult.partial = succeededCount > 0
3245
+ batchResult.failed = failedCount
3246
+ batchResult.succeeded = succeededCount
3247
+ }
3248
+ /**
3249
+ * 被跳过的变量键在**批量路径**也要告知。
3250
+ *
3251
+ * 踩过:第一版只在函数末尾的单条路径上组装这条提示,
3252
+ * 而批量在 3078 行就 return 了 —— 于是批量调用永远看不到它。
3253
+ * 这正是实测反馈里的原始场景(一批算式 + 一个中文变量名)。
3254
+ */
3255
+ const skippedInBatch = takeSkippedVariableKeys()
3256
+ if (skippedInBatch.length > 0) {
3257
+ const parts = skippedInBatch.map(function (item) { return item.name + '(' + item.reason + ')' })
3258
+ batchResult.notice =
3259
+ '这些变量名没被采用,已跳过(其余照算): ' + parts.join(';') +
3260
+ '。变量名只能含字母、数字、下划线;中文名可先存进工作区再引用。'
3261
+ }
2184
3262
  return batchResult
2185
3263
  }
2186
3264
  switch (operation) {
2187
3265
  case 'help':
2188
- outcome = opHelp()
3266
+ /** 分节帮助:给了 helpTopic 就回那一节,否则回目录。详见 `src/help.ts`。 */
3267
+ outcome = opHelp(typeof args.helpTopic === 'string' ? args.helpTopic : undefined)
2189
3268
  break
2190
3269
  case 'derivative':
2191
3270
  outcome = opDerivative(input, variable, precision)
2192
3271
  break
2193
3272
  case 'integral':
2194
- outcome = opIntegral(input, variable, precision)
3273
+ outcome = opIntegral(
3274
+ input,
3275
+ variable,
3276
+ precision,
3277
+ typeof args.from === 'number' ? args.from : undefined,
3278
+ typeof args.to === 'number' ? args.to : undefined,
3279
+ )
2195
3280
  break
2196
3281
  case 'simplify':
2197
3282
  outcome = opSimplify(input)
@@ -2298,6 +3383,56 @@ export function runMathTool(rawArguments: unknown): MathResult {
2298
3383
  const hintNote = maybeDisplayPrecisionHint(result.result)
2299
3384
  if (hintNote) result.notice = hintNote
2300
3385
 
3386
+ /**
3387
+ * 三角函数按弧度算时补一句说明。
3388
+ *
3389
+ * 走 `notice` 而不是拼进 `result`:`result` 是数据通道,
3390
+ * 往里拼提示会让依赖精确相等(`sin(30) = -0.98803162`)的调用方失配 ——
3391
+ * 这与「显示精度提示走 notice」是同一条既有约定。
3392
+ *
3393
+ * 实测反馈(2026-11-06 第二份):中文工程语境里 `sin(30)` 的默认读法是 30 度,
3394
+ * 而工具按弧度给出 `-0.988` —— 数字看着正常,用户不会察觉。
3395
+ */
3396
+ if (operation === 'evaluate' && input !== '') {
3397
+ const angleNote = checkAngleUnit(input)
3398
+ if (angleNote) {
3399
+ result.notice = result.notice ? result.notice + '\n' + angleNote : angleNote
3400
+ }
3401
+ }
3402
+
3403
+ /**
3404
+ * 带单位的结果补说明:「这个数有多大、等价于什么」。
3405
+ *
3406
+ * 实测:`evaluate` 带单位的结果只有裸文本(`87.78 Mt`,notice 为空),
3407
+ * 而 `units` 操作有【代入过程】【注意】——两者信息量差得远。
3408
+ * 模型看到 `87.78 Mt` 并不知道它是 8778 万吨。
3409
+ *
3410
+ * 用户原话:「面向 AI 的运算信息最好把信息注释写到位。」
3411
+ */
3412
+ {
3413
+ const unitNote = explainUnitResult(result.result)
3414
+ if (unitNote) {
3415
+ result.notice = result.notice ? result.notice + '\n' + unitNote : unitNote
3416
+ }
3417
+ }
3418
+
3419
+ /**
3420
+ * 被跳过的变量键要告知,不能静默。
3421
+ *
3422
+ * 实测反馈(2026-11-06 第二份):`variables` 里写了一个中文键,
3423
+ * 整批中止、合法算式一条没算。现在改成「跳过 + 告知」——
3424
+ * 算出来的数是对的,但调用方必须知道哪个键没生效,
3425
+ * 否则它会以为 `人口` 已经传进去了。
3426
+ */
3427
+ const skippedVars = takeSkippedVariableKeys()
3428
+ if (skippedVars.length > 0) {
3429
+ const parts = skippedVars.map(function (item) { return item.name + '(' + item.reason + ')' })
3430
+ const skipNote =
3431
+ '这些变量名没被采用,已跳过(其余照算): ' + parts.join(';') +
3432
+ '。变量名只能含字母、数字、下划线;中文名可先存进工作区再引用。'
3433
+ result.notice = result.notice ? result.notice + '\n' + skipNote : skipNote
3434
+ }
3435
+
2301
3436
  /**
2302
3437
  * ── 精度不足提醒 ──
2303
3438
  *
@@ -2316,22 +3451,37 @@ export function runMathTool(rawArguments: unknown): MathResult {
2316
3451
  *
2317
3452
  * 取「两条提示取更强的」:精度损失提示优先于泛泛的「可以调」提示。
2318
3453
  */
2319
- if (!isDisplayPrecisionExplicit()) {
2320
- const loss = consumePrecisionLoss()
2321
- if (loss > PRECISION_LOSS_THRESHOLD) {
2322
- /*
2323
- * 建议的位数:在当前基础上再加 8 位,上限 64。
2324
- * 这样「8 → 16」「16 → 24」都合理,且不会建议一个超范围的值。
2325
- */
2326
- const suggested = Math.min(64, displayPrecision + 8)
2327
- result.notice =
2328
- '注意:本次结果按 ' + displayPrecision + ' 位小数截断后,相对误差约 ' +
2329
- (loss * 100).toFixed(loss > 0.1 ? 0 : 2) + '%。' +
2330
- '若需要精确值,请传 displayPrecision:' + suggested + '(或更高)。'
2331
- }
2332
- } else {
2333
- // 显式指定时也要清空,避免残留到下轮
2334
- consumePrecisionLoss()
3454
+ /**
3455
+ * ── 精度损失:只在**真的丢精度**时提示一句 ──
3456
+ *
3457
+ * 与旧实现的关键区别:旧版只要「未固化 + 结果有小数」就附一段说明,
3458
+ * 一次 60+ 次调用的会话里出现 60+ 次(用户实测反馈:纯噪音)。
3459
+ * 现在判据是**相对误差超过万分之一**,普通计算(`1/7` 误差 1e-9)不提示。
3460
+ *
3461
+ * 两种情况的说法不同:
3462
+ * · 未显式指定 → 已自动改用科学计数法,说明为什么换格式
3463
+ * · 显式指定 → 格式按你要求的保留,但告知这个位数丢了精度
3464
+ */
3465
+ const loss = consumePrecisionLoss()
3466
+ /**
3467
+ * 显式指定时用**更宽松**的提示阈值。
3468
+ *
3469
+ * 理由:调用方既然写了 `displayPrecision:4`,就已经接受「4 位小数的精度」。
3470
+ * `1/7` → `0.1429` 的相对误差是 0.03% —— 那是他要求的结果,不值得提示。
3471
+ * 只有丢到 1% 以上(如 `disp:4` 下的 σ 直接归零)才该说一句。
3472
+ *
3473
+ * 未显式指定时保持万分之一:那时代码替用户做了取舍,要说明。
3474
+ */
3475
+ const noticeThreshold = isDisplayPrecisionExplicit() ? 1e-2 : PRECISION_LOSS_THRESHOLD
3476
+ if (loss > noticeThreshold) {
3477
+ const percent = loss >= 1 ? '>100' : (loss * 100).toFixed(loss > 0.1 ? 0 : 2)
3478
+ result.notice = isDisplayPrecisionExplicit()
3479
+ ? '按你指定的 ' + displayPrecision + ' 位小数定点显示会丢约 ' + percent +
3480
+ '% 的精度,结果已按该位数给出。需要精确值请传 displayPrecision:' +
3481
+ Math.min(64, displayPrecision + 8) + '。'
3482
+ : '本次结果已改用科学计数法:按 ' + displayPrecision + ' 位小数定点显示会丢约 ' +
3483
+ percent + '% 的精度。要定点显示请传 displayPrecision:' +
3484
+ Math.min(64, displayPrecision + 8) + '。'
2335
3485
  }
2336
3486
 
2337
3487
  /**
@@ -2357,7 +3507,8 @@ export function runMathTool(rawArguments: unknown): MathResult {
2357
3507
  'simplify', 'expand', 'roots', 'matrix', 'eigen', 'dist',
2358
3508
  'regression', 'ode', 'formula',
2359
3509
  ]
2360
- if (STRICT_FINITE_OPERATIONS.includes(operation) && containsNonFinite(result.value)) {
3510
+
3511
+ if (STRICT_FINITE_OPERATIONS.includes(operation) && containsNonFinite(result.value)) {
2361
3512
  throw new CalcError(
2362
3513
  'non_finite',
2363
3514
  `计算结果非有限(${outcome.result})`,
@@ -2435,16 +3586,23 @@ function clampPrecision(value: unknown): number {
2435
3586
  */
2436
3587
  function maybeDisplayPrecisionHint(resultText: string): string {
2437
3588
  try {
2438
- // 调用方自己指定了精度就不必再教他怎么指定
2439
- if (isDisplayPrecisionExplicit()) return ''
2440
- // 已固化过就不提示(用户/AI 已经做过选择)
2441
- if (displayPrecisionOf(currentWorkspace()) !== undefined) return ''
2442
- // 没有小数就没有截断问题,不必提示
2443
- if (!/\d\.\d/.test(resultText)) return ''
2444
- return (
2445
- '显示精度 ' + DEFAULT_DISPLAY_PRECISION + ' 位小数,整数不受影响。要更多位就传 displayPrecision,' +
2446
- '或用 workspace set displayPrecision=N 固化 —— 固化后此提示消失。'
2447
- )
3589
+ /**
3590
+ * ── 这条通用提示已被移除(2026-11-02,依据实测反馈)──
3591
+ *
3592
+ * 原实现:「未固化显示精度 + 结果里有小数」就附一行说明。
3593
+ * 实测代价:一次 60+ 次调用的会话里,这条提示出现 **60+ 次** —— 纯噪音。
3594
+ *
3595
+ * 为什么不能改成「只提示前 3 次」:那会破坏「同输入同输出」的确定性
3596
+ * (第一次带提示、第二次不带,输出不再字节一致),本项目把它当硬不变量。
3597
+ *
3598
+ * 现在改为**只在真的丢精度时**提示一句具体的说明(见下方的精度损失分支),
3599
+ * 而且格式本身已自动换成科学计数法,所以那种情况也很少出现。
3600
+ *
3601
+ * 「显示精度可调」这件事已经写在工具描述、系统提示与 playbook 三处,
3602
+ * 不需要每次调用重复提醒。
3603
+ */
3604
+ void resultText
3605
+ return ''
2448
3606
  } catch {
2449
3607
  return ''
2450
3608
  }
@@ -2475,6 +3633,43 @@ function constantsFingerprint(): string {
2475
3633
  }
2476
3634
  }
2477
3635
 
3636
+ /**
3637
+ * 工作区变量指纹(缓存键用)。
3638
+ *
3639
+ * ## 为什么必须有这一条
3640
+ *
3641
+ * 缓存键原本只含**工作区名**与**常数指纹**,不含变量内容。于是:
3642
+ *
3643
+ * ```
3644
+ * 存 BTU_LB=1260000 → 算 30*BTU_LB → 得数,进缓存
3645
+ * 改成 BTU_LB=126000 → 再算 → 命中旧缓存 → 还是按 1260000 的数
3646
+ * ```
3647
+ *
3648
+ * 这是**静默返回改之前的数**,比不缓存危险得多 ——
3649
+ * 与「改常数后必须失效」是同一条道理,只是状态源换成了变量。
3650
+ *
3651
+ * ## 开关关闭时返回固定串(而不是空)
3652
+ *
3653
+ * 空串与「有变量但指纹为空」无法区分。返回一个明确的常量,
3654
+ * 让键的行为在「开关关闭」这件事上也保持稳定。
3655
+ *
3656
+ * @returns 稳定的指纹字符串
3657
+ */
3658
+ function workspaceVariablesFingerprint(): string {
3659
+ if (!useWorkspaceVariables()) return 'disabled'
3660
+ try {
3661
+ const stored = workspaceVariablesOf()
3662
+ const keys = Object.keys(stored).sort()
3663
+ if (keys.length === 0) return 'empty'
3664
+ return keys
3665
+ .map(function (key) { return key + '=' + stored[key].toPrecision(15) })
3666
+ .join(';')
3667
+ } catch {
3668
+ // 读不到就给随机值 —— 宁可慢,不可错(与 constantsFingerprint 同策略)
3669
+ return 'unknown-' + Math.random()
3670
+ }
3671
+ }
3672
+
2478
3673
  /**
2479
3674
  * 当前工作区名(缓存键用)。
2480
3675
  *
@@ -2522,18 +3717,28 @@ function stableArgs(value: unknown): string {
2522
3717
  * 解析**显示**精度。
2523
3718
  *
2524
3719
  * 与 `clampPrecision` 的区别:这个不影响计算,只影响写出来的位数,
2525
- * 所以允许更宽的范围(1—64)并且**允许返回 undefined**(= 不截断)。
3720
+ * 所以允许更宽的范围(**0—64**)并且**允许返回 undefined**(= 没给)。
3721
+ *
3722
+ * ## 0 在这里是「0 位小数」,不是「清除」
3723
+ *
3724
+ * 踩过的坑:这里原先把 `<= 0` 当作未给,于是 `displayPrecision: 0`
3725
+ * 被静默丢弃、照旧输出 8 位小数 —— 而 schema 上白纸黑字写着「0-64」。
3726
+ * 文档与实现对不上,正是这个项目一直要避免的那类问题。
2526
3727
  *
2527
- * 传 0 视为「清除」,与工作区的约定一致。
3728
+ * **两处 0 的语义刻意不同,各有各的理由**:
3729
+ * · 本次参数 `displayPrecision: 0` → `3.14159265` 显示成 `3`
3730
+ * (问的是「这次显示几位」,0 是合法答案)
3731
+ * · 工作区 `set displayPrecision: 0` → 清除固化值
3732
+ * (问的是「要不要保存」,0 表示不要)
2528
3733
  *
2529
3734
  * @param value - 原始参数
2530
- * @returns 显示位数;未给或非法返回 undefined
3735
+ * @returns 显示位数(0—64);未给或非法返回 undefined
2531
3736
  */
2532
3737
  function clampDisplayPrecision(value: unknown): number | undefined {
2533
3738
  if (value === undefined || value === null || value === '') return undefined
2534
3739
  const numeric = typeof value === 'number' ? value : Number(value)
2535
- if (!Number.isFinite(numeric) || numeric <= 0) return undefined
2536
- return Math.min(64, Math.max(1, Math.round(numeric)))
3740
+ if (!Number.isFinite(numeric) || numeric < 0) return undefined
3741
+ return Math.min(64, Math.max(0, Math.round(numeric)))
2537
3742
  }
2538
3743
 
2539
3744
  function normalizeVariable(value: unknown): string {
@@ -2585,6 +3790,17 @@ const OUTPUT_SCHEMA = {
2585
3790
  notice: { type: 'string', description: '附加提示(显示精度/精度不足等)' },
2586
3791
  /** 结果来自缓存(未重算)。同样必须声明,否则命中缓存时调用失败。 */
2587
3792
  cached: { type: 'boolean', description: '是否命中缓存' },
3793
+ /**
3794
+ * 批量模式下「部分成功」。
3795
+ *
3796
+ * 语义:本批**有失败条目**(所以 `ok` 是 false),但**也有成功的**可用。
3797
+ * 没这个字段时,调用方无法区分「整批全废」与「废一条、其余可用」。
3798
+ */
3799
+ partial: { type: 'boolean', description: '批量部分成功(有失败条目但仍有成功结果)' },
3800
+ /** 批量模式:失败条目数。 */
3801
+ failed: { type: 'integer', description: '批量中失败条数' },
3802
+ /** 批量模式:成功条目数。 */
3803
+ succeeded: { type: 'integer', description: '批量中成功条数' },
2588
3804
  error: {
2589
3805
  type: 'object',
2590
3806
  additionalProperties: false,
@@ -2597,6 +3813,50 @@ const OUTPUT_SCHEMA = {
2597
3813
  },
2598
3814
  } as const
2599
3815
 
3816
+ /**
3817
+ * 编译期守卫:`KNOWN_ARGUMENT_KEYS` 必须覆盖 `MathArgs` 的每一个键。
3818
+ *
3819
+ * 手写白名单最大的风险是**与接口漂移** —— 新增一个参数却忘了加进白名单,
3820
+ * 那条新参数就会被自己的校验拦下(比不校验更糟)。
3821
+ * 这里用「键的联合类型必须被白名单收住」的写法把漂移变成编译期报错。
3822
+ *
3823
+ * 少了哪个键,tsc 会直接指出该键名。
3824
+ */
3825
+ type MathArgsKeys = keyof MathArgs
3826
+ type _KeysCovered = MathArgsKeys extends string ? (MathArgsKeys extends KnownKeyUnion ? true : never) : never
3827
+ type KnownKeyUnion =
3828
+ | 'expression' | 'expressions' | 'terse' | 'variables' | 'operation' | 'variable'
3829
+ | 'matrix' | 'vector' | 'precision' | 'displayPrecision'
3830
+ | 'data' | 'dataY' | 'distribution' | 'params' | 'x' | 'degree'
3831
+ | 'formula' | 'overrides' | 'query' | 'customExpression'
3832
+ | 'initial' | 'from' | 'to' | 'steps' | 'samples'
3833
+ | 'world' | 'planetRadius' | 'planetMass' | 'rotationHours' | 'obliquityDeg'
3834
+ | 'terrain' | 'constants' | 'derivation' | 'layer'
3835
+ | 'action2' | 'workspace' | 'name' | 'description' | 'terrainName' | 'workspaceVariables'
3836
+ | 'useWorkspaceVariables'
3837
+ | 'claims' | 'listConstraints'
3838
+ | 'unitValue' | 'unitFrom' | 'unitTo' | 'customUnits' | 'unitScales' | 'unitScale'
3839
+ | 'balance'
3840
+ | 'bodyName' | 'bodyKind' | 'bodyConstant' | 'bodyFactor' | 'bodyType' | 'bodyEvidence'
3841
+ | 'anchor' | 'anchorValue' | 'compareFrom' | 'compareTo'
3842
+ | 'fantasyAbility' | 'fantasyDemand' | 'fantasyPhysical' | 'fantasyBodyArea'
3843
+ | 'lyapunov' | 'inheritDerived' | 'inheritBase' | 'inheritSync' | 'diffA' | 'diffB'
3844
+ | 'observerKind' | 'graphDepths' | 'timelineFrom' | 'timelineTo' | 'timelineSteps'
3845
+ | 'whitepaperMode' | 'traceTarget' | 'costKind' | 'costX' | 'costY'
3846
+ | 'costIterations' | 'costCombos' | 'costWithPipeline'
3847
+ | 'promptMode' | 'helpTopic'
3848
+ | 'tableAction' | 'formulaId' | 'newExpression' | 'newParams' | 'customFormula'
3849
+ | 'customAction' | 'customConstant' | 'customConstantName' | 'expectedDimension'
3850
+ | 'flipClaim' | 'flipEpsilon' | 'flipBase'
3851
+ | 'threshold' | 'worstLimit' | 'variableName' | 'constantName'
3852
+ | 'emptyOnly' | 'dryRun' | 'olderThanDays' | 'keep' | 'tempPrefixes'
3853
+ | 'sensitivityBase' | 'sensitivityExponent'
3854
+
3855
+ /** 运行期再兜一次:白名单里不许有接口没有的键(防止手滑多写)。 */
3856
+ const _KEY_SET_MATCHES_INTERFACE: ReadonlySet<string> = KNOWN_ARGUMENT_KEYS
3857
+ void 0 as unknown as _KeysCovered
3858
+ void _KEY_SET_MATCHES_INTERFACE
3859
+
2600
3860
  function renderResult(value: any): Array<{ type: 'text'; text: string }> {
2601
3861
  const payload = (value ?? {}) as Partial<MathResult>
2602
3862
  if (payload.ok === false) {
@@ -2624,14 +3884,41 @@ const PARAMETER_SPEC = {
2624
3884
  },
2625
3885
  expressions: {
2626
3886
  type: 'array',
2627
- description:
2628
- '批量求值(operation=evaluate):一次算多条,省多次往返。' +
2629
- '逐条独立报错,一条失败不影响其它条。例:["1/7","2^0.5","log(10)"]。',
3887
+ /**
3888
+ * ## 这里声明 items,是推翻了之前「刻意不声明」的决定
3889
+ *
3890
+ * 之前的理由是「不声明也收得进来 + 省 schema 长度」。但那是错的:
3891
+ * **不声明等于模型看不见元素级字段**。上一轮修「元素里的未知键被静默忽略」时
3892
+ * 就撞上了这个 —— 校验现在会拒绝未声明的键,而模型不知道能写什么。
3893
+ *
3894
+ * 加了 `sanity` 之后更明显:一个模型看不见的参数等于不存在。
3895
+ * 所以把 items 加回来,长度从别处腾(`operation` 与 `customExpression` 各压一段)。
3896
+ */
3897
+ items: {
3898
+ type: 'json',
3899
+ description:
3900
+ '字符串("1/7")或对象 {label?, expr, sanity?}。' +
3901
+ 'sanity 给锚点:{max,min,expect,note},例 {max:1e10,note:"帝国总兵力"}。',
3902
+ },
3903
+ description: '批量求值,逐条独立报错。',
3904
+ },
3905
+ terse: {
3906
+ type: 'boolean',
3907
+ description: '省 token:只回「表达式 = 结果」。失败条照常标出原因。',
2630
3908
  },
2631
3909
  variables: {
2632
3910
  type: 'object',
2633
3911
  additionalProperties: true,
2634
- description: '变量字典,注入表达式求值的环境。例:{"m": 500, "v": 30, "c": 299792458}。值可为数字或数字字符串(大数)。',
3912
+ description: '变量字典,注入表达式求值的环境。例:{"m": 500, "v": 30}。值可为数字或数字字符串。',
3913
+ },
3914
+ workspaceVariables: {
3915
+ type: 'object',
3916
+ additionalProperties: true,
3917
+ description: 'workspace/action2=set:把命名常数存进工作区,免去重打。默认不参与计算。',
3918
+ },
3919
+ useWorkspaceVariables: {
3920
+ type: 'boolean',
3921
+ description: 'workspace/action2=set:命名变量是否参与计算(默认 false)。详见 help。',
2635
3922
  },
2636
3923
  operation: {
2637
3924
  type: 'string',
@@ -2685,16 +3972,13 @@ const PARAMETER_SPEC = {
2685
3972
  bodyKind: {
2686
3973
  type: 'string',
2687
3974
  description:
2688
- '按类别筛天体(operation=bodies):star / planet-terrestrial / planet-super-earth / ' +
2689
- 'planet-mini-neptune / planet-gas-giant / moon / dwarf-planet / asteroid。',
3975
+ 'bodies:按类别筛。star / planet-terrestrial / planet-gas-giant / moon / dwarf-planet 等,全文见 help。',
2690
3976
  },
2691
3977
  bodyConstant: {
2692
3978
  type: 'string',
2693
3979
  description:
2694
- '改常数后看天体数据哪些该重算(operation=bodies)。' +
2695
- '例:bodyConstant="alpha_fs", bodyFactor=2 —— α 变成两倍,' +
2696
- '输出分「必须重算的量」与「绝不该动的量」两块。' +
2697
- '后者尤其重要:行星质量、半径、轨道与 α 无关,跟着改就是篡改设定。',
3980
+ 'bodies:改常数后看哪些天体数据该重算。例 bodyConstant="alpha_fs", bodyFactor=2。' +
3981
+ '输出分「必须重算」与「绝不该动」两块(行星质量/半径/轨道与 α 无关)。',
2698
3982
  },
2699
3983
  bodyFactor: {
2700
3984
  type: 'number',
@@ -2702,16 +3986,11 @@ const PARAMETER_SPEC = {
2702
3986
  },
2703
3987
  bodyType: {
2704
3988
  type: 'string',
2705
- description:
2706
- '查天体**类型**(operation=bodies)。与 bodyName 的区别:bodyName 查具名天体(火星、木卫二),' +
2707
- 'bodyType 查类型学条目(中子星、热木星、流浪行星、戴森球)。' +
2708
- '每个类型都标了证据等级,并列明「物理锁死项」(编不了)与「可自由发挥项」。',
3989
+ description: 'bodies:查天体类型学(中子星/热木星/戴森球等),每类标证据等级与物理锁死项。',
2709
3990
  },
2710
3991
  bodyEvidence: {
2711
3992
  type: 'string',
2712
- description:
2713
- '按证据等级筛天体类型(operation=bodies):observed(已观测)/ ' +
2714
- 'theoretical(理论预言,物理必然但未直接观测)/ speculative(纯思辨)。',
3993
+ description: 'bodies:按证据等级筛:observed(已观测)/ theoretical(理论预言)/ speculative(纯思辨)。',
2715
3994
  },
2716
3995
  matrix: {
2717
3996
  type: 'array',
@@ -2724,17 +4003,14 @@ const PARAMETER_SPEC = {
2724
4003
  precision: {
2725
4004
  type: 'integer',
2726
4005
  description:
2727
- '**计算精度**:BigNumber 有效数字位数,默认 64,范围 1-256。' +
2728
- '⚠️ 不要为了「输出好看」调低它——它决定计算的正确性:' +
2729
- '`(10^20+1)-(10^20)` 在 precision=20 时会得 0(应为 1)。' +
2730
- '只想让输出短一些,用 displayPrecision。',
4006
+ '**计算精度**(BigNumber 有效位,默认 64,1-256)。⚠️ 调低会算错大数:' +
4007
+ '`(10^20+1)-(10^20)` 在 precision=20 得 0。只想输出短些用 displayPrecision。',
2731
4008
  },
2732
4009
  displayPrecision: {
2733
4010
  type: 'integer',
2734
4011
  description:
2735
- '**显示精度:小数点后几位**(0-64,默认 8)。只截小数,整数永远完整。' +
2736
- '例 8 → 1/7 得 0.14285714。全局换档用 workspace set displayPrecision=N。' +
2737
- '别用 precision 达此目的(会算错大数)。',
4012
+ '**显示精度:小数点后几位**(0-64,默认 8)。只截小数、整数永远完整,0 = 只留整数。' +
4013
+ '可用 workspace set displayPrecision=N 固化(那里的 0 = 清除)。',
2738
4014
  },
2739
4015
  data: {
2740
4016
  type: 'array',
@@ -2770,15 +4046,12 @@ const PARAMETER_SPEC = {
2770
4046
  type: 'object',
2771
4047
  additionalProperties: true,
2772
4048
  description:
2773
- '覆盖公式里的任意参数 —— 这是架空世界改造的关键。' +
2774
- '例如把重力改成 1.5 倍地球:{"g": 14.71};把光速改成 1e8:{"c": 100000000}。' +
2775
- '任何带默认值的参数都能这样覆盖。',
4049
+ '覆盖公式任意参数 —— 架空世界改造的关键。例:重力 1.5 倍 {"g": 14.71}、光速 {"c": 1e8}。',
2776
4050
  },
2777
4051
  customExpression: {
2778
4052
  type: 'string',
2779
4053
  description:
2780
- 'structure 级改造:临时替换公式的表达式,仍沿用该公式的参数名。' +
2781
- '例如 operation="formula", formula="weight", customExpression="m * g * 1.3" 表示该世界重力异常 +30%。',
4054
+ 'structure 级改造:临时替换公式表达式,沿用其参数名。例 formula="weight", customExpression="m*g*1.3" 表示重力 +30%。',
2782
4055
  },
2783
4056
  query: {
2784
4057
  type: 'string',
@@ -2814,8 +4087,8 @@ const PARAMETER_SPEC = {
2814
4087
  // 真用 world 时才需要。全文见 help(operation="help", query="world")。
2815
4088
  description:
2816
4089
  '星球参数(operation=world),全部可选,默认地球。' +
2817
- '常用键:planetMass/planetRadius(地球倍数)、starMass/starTemp、rotationHours、obliquityDeg、greenhouse。' +
2818
- '完整字段表见 help 的 world 条目。',
4090
+ '常用键:planetMass/planetRadius、starMass/starTemp、rotationHours、obliquityDeg。' +
4091
+ '完整字段表见 help。',
2819
4092
  },
2820
4093
  planetRadius: {
2821
4094
  type: 'number',
@@ -2847,7 +4120,13 @@ const PARAMETER_SPEC = {
2847
4120
  // 旋钮清单移进 help:只有要改常数时才需要,不该每轮付费
2848
4121
  description:
2849
4122
  '要改的物理常数(operation=constants),只能改基础旋钮,派生量自动重算。' +
2850
- '例:{c:1.5e8}。可用旋钮清单见 help 的 constants 条目。',
4123
+ '例:{c:1.5e8}。可用旋钮清单见 help 的 constants 节。',
4124
+ },
4125
+ helpTopic: {
4126
+ type: 'string',
4127
+ description:
4128
+ 'operation="help" 时要取哪一节,省略则返回目录。目录里每节都有简介,' +
4129
+ '按需逐节取比一次读全文省得多(单节约 400 字符,全文 5900)。',
2851
4130
  },
2852
4131
  } as const
2853
4132