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.
- package/CHANGELOG.md +921 -0
- package/README.md +64 -50
- package/dsh.plugin.json +2 -2
- package/lib/__wtest.txt +1 -0
- package/lib/awareness.js +69 -20
- package/lib/format.js +522 -13
- package/lib/formula-library-em.js +428 -0
- package/lib/formulas.js +28 -1
- package/lib/guards.js +653 -0
- package/lib/help.js +509 -0
- package/lib/index.js +1357 -212
- package/lib/ops/shared.js +547 -6
- package/lib/ops/units-op.js +116 -10
- package/lib/ops/workspace-op.js +10 -0
- package/lib/playbook.js +32 -7
- package/lib/types/format.d.ts +31 -1
- package/lib/types/formula-library-em.d.ts +28 -0
- package/lib/types/formulas.d.ts +2 -0
- package/lib/types/guards.d.ts +255 -0
- package/lib/types/help.d.ts +70 -0
- package/lib/types/index.d.ts +34 -1
- package/lib/types/ops/shared.d.ts +87 -3
- package/lib/types/playbook.d.ts +28 -0
- package/lib/types/units-data.d.ts +253 -0
- package/lib/types/units.d.ts +54 -4
- package/lib/types/workspace.d.ts +50 -0
- package/lib/units-data.js +1037 -0
- package/lib/units.js +82 -117
- package/lib/workspace.js +54 -0
- package/package.json +1 -1
- package/src/awareness.ts +69 -20
- package/src/format.ts +520 -12
- package/src/formula-library-em.ts +435 -0
- package/src/formulas.ts +35 -4
- package/src/guards.ts +673 -0
- package/src/help.ts +543 -0
- package/src/index.ts +1492 -213
- package/src/ops/shared.ts +589 -9
- package/src/ops/units-op.ts +125 -9
- package/src/ops/workspace-op.ts +10 -0
- package/src/playbook.ts +32 -7
- package/src/units-data.ts +1128 -0
- package/src/units.ts +149 -130
- 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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
1054
|
+
skippedVariableKeys.push({ name: key, reason: '标识符只能含字母、数字、下划线' })
|
|
1055
|
+
continue
|
|
487
1056
|
}
|
|
488
1057
|
if (typeof raw === 'number') {
|
|
489
|
-
if (!Number.isFinite(raw))
|
|
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
|
-
|
|
1068
|
+
skippedVariableKeys.push({ name: key, reason: '无法解析为数字' })
|
|
497
1069
|
}
|
|
498
1070
|
} else if (typeof raw === 'boolean') {
|
|
499
1071
|
scope[key] = raw
|
|
500
1072
|
} else {
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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, '',
|
|
648
|
-
value: toJsonValue({
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
-
*
|
|
3720
|
+
* 所以允许更宽的范围(**0—64**)并且**允许返回 undefined**(= 没给)。
|
|
3721
|
+
*
|
|
3722
|
+
* ## 0 在这里是「0 位小数」,不是「清除」
|
|
3723
|
+
*
|
|
3724
|
+
* 踩过的坑:这里原先把 `<= 0` 当作未给,于是 `displayPrecision: 0`
|
|
3725
|
+
* 被静默丢弃、照旧输出 8 位小数 —— 而 schema 上白纸黑字写着「0-64」。
|
|
3726
|
+
* 文档与实现对不上,正是这个项目一直要避免的那类问题。
|
|
2526
3727
|
*
|
|
2527
|
-
*
|
|
3728
|
+
* **两处 0 的语义刻意不同,各有各的理由**:
|
|
3729
|
+
* · 本次参数 `displayPrecision: 0` → `3.14159265` 显示成 `3`
|
|
3730
|
+
* (问的是「这次显示几位」,0 是合法答案)
|
|
3731
|
+
* · 工作区 `set displayPrecision: 0` → 清除固化值
|
|
3732
|
+
* (问的是「要不要保存」,0 表示不要)
|
|
2528
3733
|
*
|
|
2529
3734
|
* @param value - 原始参数
|
|
2530
|
-
* @returns
|
|
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
|
|
2536
|
-
return Math.min(64, Math.max(
|
|
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
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
2695
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
2818
|
-
'完整字段表见 help
|
|
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
|
|