dsh-math-calculator 2.0.0 → 2.1.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 (58) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +52 -10
  3. package/dsh.plugin.json +15 -8
  4. package/lib/awareness.js +16 -13
  5. package/lib/body-catalog.js +488 -0
  6. package/lib/body-scaling.js +288 -0
  7. package/lib/body-taxonomy.js +719 -0
  8. package/lib/detector.js +361 -16
  9. package/lib/empirical-decomposition.js +321 -0
  10. package/lib/format.js +113 -5
  11. package/lib/formula-library-generic.js +226 -0
  12. package/lib/formulas.js +71 -13
  13. package/lib/index.js +613 -77
  14. package/lib/ops/bodies-op.js +368 -0
  15. package/lib/ops/shared.js +64 -4
  16. package/lib/ops/workspace-op.js +19 -1
  17. package/lib/ops/world-op.js +22 -0
  18. package/lib/playbook.js +6 -1
  19. package/lib/precision-report.js +80 -0
  20. package/lib/result-cache.js +144 -0
  21. package/lib/types/awareness.d.ts +10 -4
  22. package/lib/types/body-catalog.d.ts +156 -0
  23. package/lib/types/body-scaling.d.ts +123 -0
  24. package/lib/types/body-taxonomy.d.ts +95 -0
  25. package/lib/types/detector.d.ts +18 -0
  26. package/lib/types/empirical-decomposition.d.ts +107 -0
  27. package/lib/types/format.d.ts +33 -4
  28. package/lib/types/formula-library-generic.d.ts +37 -0
  29. package/lib/types/formulas.d.ts +35 -7
  30. package/lib/types/index.d.ts +16 -0
  31. package/lib/types/ops/bodies-op.d.ts +35 -0
  32. package/lib/types/ops/shared.d.ts +94 -3
  33. package/lib/types/precision-report.d.ts +59 -0
  34. package/lib/types/result-cache.d.ts +78 -0
  35. package/lib/types/workspace.d.ts +24 -0
  36. package/lib/types/world.d.ts +8 -0
  37. package/lib/workspace.js +22 -0
  38. package/lib/world.js +38 -3
  39. package/package.json +1 -1
  40. package/src/awareness.ts +16 -13
  41. package/src/body-catalog.ts +619 -0
  42. package/src/body-scaling.ts +357 -0
  43. package/src/body-taxonomy.ts +806 -0
  44. package/src/detector.ts +403 -15
  45. package/src/empirical-decomposition.ts +411 -0
  46. package/src/format.ts +123 -5
  47. package/src/formula-library-generic.ts +242 -0
  48. package/src/formulas.ts +79 -13
  49. package/src/index.ts +642 -64
  50. package/src/ops/bodies-op.ts +415 -0
  51. package/src/ops/shared.ts +141 -4
  52. package/src/ops/workspace-op.ts +21 -1
  53. package/src/ops/world-op.ts +22 -0
  54. package/src/playbook.ts +6 -1
  55. package/src/precision-report.ts +87 -0
  56. package/src/result-cache.ts +157 -0
  57. package/src/workspace.ts +34 -0
  58. package/src/world.ts +51 -4
package/src/playbook.ts CHANGED
@@ -281,9 +281,11 @@ export function buildPlaybook(state: {
281
281
  out.push('- ' + item.step + ':' + item.do + '(' + item.skip + ')')
282
282
  }
283
283
  out.push('')
284
- out.push('### 两条底线')
284
+ out.push('### 三条底线')
285
285
  out.push('1. **不要反驳用户的设定。** 插件指出「按这个参数会怎样」,你转述,并给出「想保留的话可以怎么调」。')
286
286
  out.push('2. **不要编数字。** 算不出来的(混沌、超出现有物理、插件没覆盖的),标待定或问用户。')
287
+ out.push('3. **不要照抄全精度。** 工具输出保留几十位小数,是为了**让你有得选**,不是让你全部抄出去。转述时保留几位,由你判断——依据是这个数在文中要干什么:读者只需要量级感就给少的,要拿去核对数字就给多的,要参与后续计算就给够。插件不给固定档位,因为「几位合适」是语境问题,不是物理问题。')
288
+ out.push(' 唯一硬边界:**该精确的整数不能截断**(`10^20+5` 就是 `100000000000000000005`,写成 `1e20` 是丢信息,不是简洁)。')
287
289
  return out.join(String.fromCharCode(10))
288
290
  }
289
291
 
@@ -303,6 +305,8 @@ export function buildBriefPlaybook(state: {
303
305
  out.push('- 该写的(结语、描写、叙事判断)由你生成,别照抄工具输出')
304
306
  out.push('- 硬约束(物质不存在等)如实说;叙事选择(要巨兽、要灵能)不反驳,只讲代价')
305
307
  out.push('- 算不出来的别编,标待定或问用户')
308
+ out.push('- 工具输出默认是**全精度**(可能几十位小数),是供你取舍的原料。**保留几位你来定**:读者只要感觉就少给,要核对就多给,要接着算就管够。别把 60 位抄给读者,也别把该精确的整数(如 10^20+5)截成科学计数法')
309
+ out.push('- 工具输出默认按**小数点后 8 位**显示(整数永远完整,如 10^20+5 不会被截)。需要更多位就单次传 `displayPrecision`;想全局换一档用 `{operation:"workspace", action2:"set", displayPrecision:N}` 固化一次。注意 `precision`(计算精度)不要调低,它一低大数就会算错')
306
310
  if ((state.changedConstants ?? []).length > 0) {
307
311
  out.push('- 本区改过常数,先跑 dimensionless 确认不是单位重定义')
308
312
  }
@@ -420,5 +424,6 @@ export function buildConfiguredPlaybook(state: {
420
424
  out.push('### 底线')
421
425
  out.push('1. 不要反驳用户的设定——说清「按这个参数会怎样」,并给出「想保留可以怎么调」。')
422
426
  out.push('2. 不要编数字——算不出来的标待定或问用户。')
427
+ out.push('3. 不要照抄全精度——工具输出保留几十位小数是为让你有得选,不是让你全抄。保留几位由你判断:读者只要量级就少给,要核对就多给,要参与后续计算就管够。唯一硬边界是该精确的整数不能截断(`10^20+5` 写成 `1e20` 是丢信息)。')
423
428
  return out.join(String.fromCharCode(10))
424
429
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 显示精度的损失追踪:判断「当前位数够不够用」。
3
+ *
4
+ * ## 为什么需要这个模块
5
+ *
6
+ * 显示精度截断是**悄悄发生**的,作者与模型都不会察觉:
7
+ *
8
+ * 1/7 8 位小数 → 0.14285714 损失约 1e-9(无感)
9
+ * 123456.789012345 8 位小数 → 123456.78901235 损失约 5e-9(无感)
10
+ * 0.00000123456789 8 位小数 → 0.00000123 相对损失约 0.3%(有感!)
11
+ *
12
+ * 最后一个例子是关键:数值很小时,8 位小数只留下 3 位有效数字,
13
+ * 相对误差 0.3% —— 作者以为拿到的是精确值,实际已经偏了。
14
+ *
15
+ * 本模块只做一件事:记住本轮格式化过程中最大的相对误差,
16
+ * 让工具层能据此提醒「该调高 displayPrecision 了」。
17
+ *
18
+ * ## 为什么不写在 format.ts
19
+ *
20
+ * `format.ts` 不依赖任何其它模块,而 `shared.ts` 已经导入它。
21
+ * 把状态放进 `format.ts` 就得反向导入,形成循环。
22
+ * 单独一个模块两边都能用,依赖方向干净。
23
+ *
24
+ * ## 阈值
25
+ *
26
+ * 取 1e-4(万分之一):低于它的损失对创作与工程核对都无影响;
27
+ * 高于它说明显示值已经明显偏离真值,值得提醒一次。
28
+ *
29
+ * @module dsh-math-calculator/precision-report
30
+ */
31
+
32
+ /** 允许的最大相对误差(万分之一)。超过就提醒。 */
33
+ export const PRECISION_LOSS_THRESHOLD = 1e-4
34
+
35
+ /** 本轮调用中已记录的最大相对误差。 */
36
+ let maxRelativeLoss = 0
37
+
38
+ /**
39
+ * 记录一次截断造成的相对误差。
40
+ *
41
+ * @param relativeLoss - |显示值−真值| / |真值|
42
+ */
43
+ export function reportPrecisionLoss(relativeLoss: number): void {
44
+ if (Number.isFinite(relativeLoss) && relativeLoss > maxRelativeLoss) {
45
+ maxRelativeLoss = relativeLoss
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 取出并清空本轮的最大相对误差。
51
+ *
52
+ * 清空是必要的:每次工具调用开始都要重新统计,
53
+ * 否则上一轮的损失会一直挂在本轮结果上。
54
+ *
55
+ * @returns 最大相对误差;0 表示没有可感知的损失
56
+ */
57
+ export function consumePrecisionLoss(): number {
58
+ const value = maxRelativeLoss
59
+ maxRelativeLoss = 0
60
+ return value
61
+ }
62
+
63
+ /**
64
+ * 本轮显示精度是否由调用方**显式**指定。
65
+ *
66
+ * 为什么要区分:作者自己写 `displayPrecision:2` 时,截断是他的意图,
67
+ * 再提醒「精度不够」就是啰嗦。只有用默认值时才值得提醒。
68
+ */
69
+ let displayPrecisionExplicit = false
70
+
71
+ /**
72
+ * 标记本轮显示精度是否来自显式参数。
73
+ *
74
+ * @param explicit - 是否显式
75
+ */
76
+ export function setDisplayPrecisionExplicit(explicit: boolean): void {
77
+ displayPrecisionExplicit = explicit
78
+ }
79
+
80
+ /**
81
+ * 当前显示精度是否来自显式参数。
82
+ *
83
+ * @returns 是否显式
84
+ */
85
+ export function isDisplayPrecisionExplicit(): boolean {
86
+ return displayPrecisionExplicit
87
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 计算结果缓存:同样的输入不再算第二遍。
3
+ *
4
+ * ## 为什么值得做(而不只是优化)
5
+ *
6
+ * 反复算同一个式子在真实使用里很常见:
7
+ * · AI 校验自己的结论(「再算一次确认」)——输入完全相同
8
+ * · 同一批数据换着角度问(同一条 `1/7` 在几个不同问题里出现)
9
+ * · 推演时反复引用同一个常量式
10
+ *
11
+ * 每次重算的代价不只是 CPU,还有**结果文本再次进入上下文**:
12
+ * 一个 64 位小数的返回值约 65 字符,重复十次就是 650 字符的 token。
13
+ * 缓存命中时仍要返回结果(AI 需要看到值),但省掉了重算,
14
+ * 更重要的是对**批量和重复调用**能显著降低总开销。
15
+ *
16
+ * ## 缓存键必须包含什么
17
+ *
18
+ * 少放一个就会返回**错的结果**——这比不缓存危险得多:
19
+ * · 规范化后的表达式(`1/7` 与 `1 / 7` 应算同一条,但不能把 `1/7` 和 `2/7` 撞车)
20
+ * · operation(`evaluate "1+1"` 与 `solve "1+1"` 结果不同)
21
+ * · precision(计算精度不同,结果不同)
22
+ * · displayPrecision(只影响显示,但也进键——否则命中后位数不对)
23
+ * · **当前常数状态**(改了 c/G 之后同样的式子结果不同)
24
+ * · **工作区**(不同世界的公式表/变量可能不同)
25
+ *
26
+ * 最后两项最容易漏。实测过:改常数后若缓存不失效,
27
+ * 会返回「改之前」的数——那是静默错误。
28
+ *
29
+ * ## 容量与淘汰
30
+ *
31
+ * 固定容量 + LRU。不设 TTL:常数或工作区一变,键就变了,旧条目自然不再命中;
32
+ * 保留它们没有危害,只是占内存,所以用容量上限控制。
33
+ *
34
+ * @module dsh-math-calculator/result-cache
35
+ */
36
+
37
+ /** 一条缓存记录。 */
38
+ interface CacheEntry {
39
+ /** 缓存的返回值(不深拷贝,调用方不得修改)。 */
40
+ value: unknown
41
+ /** 命中次数(供统计)。 */
42
+ hits: number
43
+ }
44
+
45
+ /** 缓存容量上限。 */
46
+ const MAX_ENTRIES = 256
47
+
48
+ /** 主存储。Map 保留插入顺序,正好用来做 LRU。 */
49
+ const store = new Map<string, CacheEntry>()
50
+
51
+ /** 命中/未命中统计(供诊断)。 */
52
+ let hits = 0
53
+ let misses = 0
54
+
55
+ /**
56
+ * 把一个值规范成稳定的字符串,用于参与缓存键。
57
+ *
58
+ * 为什么不用 JSON.stringify:对象键的顺序不保证一致
59
+ * (`{a:1,b:2}` 与 `{b:2,a:1}` 应视为同一个键),
60
+ * 且浮点表示可能因来源不同而不一致。这里按键名排序后拼接。
61
+ *
62
+ * @param value - 任意值
63
+ * @returns 稳定字符串
64
+ */
65
+ function stableStringify(value: unknown): string {
66
+ if (value === null || value === undefined) return String(value)
67
+ const kind = typeof value
68
+ if (kind === 'number') {
69
+ // 数字统一用 15 位有效数字,避免 -0 / 0.1+0.2 这类表示差异
70
+ return Number.isFinite(value as number) ? (value as number).toPrecision(15) : String(value)
71
+ }
72
+ if (kind === 'string' || kind === 'boolean') return String(value)
73
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
74
+ if (kind === 'object') {
75
+ const object = value as Record<string, unknown>
76
+ const keys = Object.keys(object).sort()
77
+ return '{' + keys.map(function (key) {
78
+ return key + ':' + stableStringify(object[key])
79
+ }).join(',') + '}'
80
+ }
81
+ return String(value)
82
+ }
83
+
84
+ /**
85
+ * 计算缓存键。
86
+ *
87
+ * @param parts - 参与键的各个部分(按固定顺序传入)
88
+ * @returns 键字符串
89
+ */
90
+ export function cacheKey(parts: readonly unknown[]): string {
91
+ return parts.map(stableStringify).join('\u0001')
92
+ }
93
+
94
+ /**
95
+ * 取缓存。
96
+ *
97
+ * @param key - 缓存键
98
+ * @returns 命中则返回缓存值;未命中返回 undefined
99
+ */
100
+ export function getCached(key: string): unknown {
101
+ const entry = store.get(key)
102
+ if (entry === undefined) {
103
+ misses++
104
+ return undefined
105
+ }
106
+ hits++
107
+ entry.hits++
108
+ // LRU:命中后移到队尾(最新)
109
+ store.delete(key)
110
+ store.set(key, entry)
111
+ return entry.value
112
+ }
113
+
114
+ /**
115
+ * 存缓存。
116
+ *
117
+ * @param key - 缓存键
118
+ * @param value - 要缓存的值
119
+ */
120
+ export function setCached(key: string, value: unknown): void {
121
+ if (store.has(key)) store.delete(key)
122
+ store.set(key, { value, hits: 0 })
123
+ // 超容量时淘汰最久未使用的(队首)
124
+ while (store.size > MAX_ENTRIES) {
125
+ const oldest = store.keys().next()
126
+ if (oldest.done) break
127
+ store.delete(oldest.value)
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 清空缓存(改常数、切工作区时应当调用)。
133
+ *
134
+ * 为什么需要显式清空:缓存的键里已经含常数与工作区,
135
+ * 理论上不会串;但**清空是个安全网**——
136
+ * 万一某条路径漏把常数放进键里,清空能兜住,而不是静默返回旧值。
137
+ */
138
+ export function clearCache(): void {
139
+ store.clear()
140
+ }
141
+
142
+ /**
143
+ * 缓存统计(供诊断与测试)。
144
+ *
145
+ * @returns 条目数、命中、未命中
146
+ */
147
+ export function cacheStats(): { size: number; hits: number; misses: number } {
148
+ return { size: store.size, hits: hits, misses: misses }
149
+ }
150
+
151
+ /**
152
+ * 重置统计(不影响已存条目)。
153
+ */
154
+ export function resetCacheStats(): void {
155
+ hits = 0
156
+ misses = 0
157
+ }
package/src/workspace.ts CHANGED
@@ -68,6 +68,19 @@ export interface WorkspaceData {
68
68
  * 用发行前的量纲检查发现「公式算出来的不是想要的量」。
69
69
  */
70
70
  customConstants?: CustomConstant[]
71
+ /**
72
+ * 本工作区的**显示**有效位数(不影响计算精度)。
73
+ *
74
+ * 为什么存在这里:AI 在对话里读到的每个数字都带着几十位小数,
75
+ * 逐个自己截断既费 token 又容易出错。定一次、存下来、后续沿用,
76
+ * 是「让 AI 估计一个够用的精度并固化」的实现方式。
77
+ *
78
+ * 计算仍然按 `precision`(默认 64)走,只有输出被截断 ——
79
+ * 所以 `(10^20+1)-(10^20)` 照样得 1,不会因为显示短了就算错。
80
+ *
81
+ * 未设置时沿用计算精度(即完整输出,旧行为)。
82
+ */
83
+ displayPrecision?: number
71
84
  /** 创建时间。 */
72
85
  createdAt: string
73
86
  /** 最后修改时间。 */
@@ -875,3 +888,24 @@ export function bindHostWorkspace(
875
888
 
876
889
  return { bound: true, name, workspaceId: hostId, created: !existed, workspace: data }
877
890
  }
891
+
892
+ /**
893
+ * 读取当前工作区的显示精度(未设置返回 undefined = 沿用计算精度)。
894
+ *
895
+ * 为什么单独做一个函数:工具入口每轮都要问一次「现在显示几位」,
896
+ * 而这里要吸收掉「工作区文件损坏 / 不存在」等各种异常 ——
897
+ * 读取失败不该让整个计算挂掉,退回完整输出即可。
898
+ *
899
+ * @param name - 工作区名;省略用当前活动工作区
900
+ * @returns 显示有效位数;未设置返回 undefined
901
+ */
902
+ export function displayPrecisionOf(name?: string): number | undefined {
903
+ try {
904
+ const data = loadWorkspace(name ?? activeWorkspaceName)
905
+ const value = data.displayPrecision
906
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
907
+ return value
908
+ } catch {
909
+ return undefined
910
+ }
911
+ }
package/src/world.ts CHANGED
@@ -260,6 +260,14 @@ export interface WorldProfile {
260
260
  warnings: string[]
261
261
  /** 读者能直接感知的要点(供主模型二创)。 */
262
262
  highlights: string[]
263
+ /**
264
+ * 恒星假设说明:哪些地方**替代了用户设定**或**补了默认值**。
265
+ *
266
+ * 与 `warnings` 的区别:`warnings` 说「你给的世界有物理问题」,
267
+ * 这里说「我动了你的输入」。后者更要紧——整份结论都建立在它之上,
268
+ * 所以必须显式返回、由上层放到输出最前面。
269
+ */
270
+ starNotes: string[]
263
271
  }
264
272
 
265
273
  const EARTH = {
@@ -404,6 +412,14 @@ export function generateWorld(params: WorldParams, options: WorldOptions = {}):
404
412
  // a) starClass='M5V' → 按光谱型基准表填充(省去查表)
405
413
  // b) 直接给 starMass/starRadius/starTemp
406
414
  // 显式参数优先于 starClass —— 用户想微调时不会被基准值覆盖。
415
+ //
416
+ // ⚠️ 目录里**只有主序星(V)**:O5V…M5V 共 9 种,没有巨星(III)、次巨星、白矮星。
417
+ // 同样光谱字母的巨星比主序星亮 20—100 倍,用主序值顶替会让平衡温度差出几百开。
418
+ //
419
+ // 实测踩到过:传 starClass='F5III'(某设定里的 F5-IIIa 巨星)时,旧实现静默回退到
420
+ // 最近的主序型 G2V(太阳),而那句提示被推到最后一百多行之后——读前 20 行的人永远看不到。
421
+ // 现在这类替换会记进 starNotes,并被放到输出的**最前面**。
422
+ const starNotes: string[] = []
407
423
  let baseStar: StarClass
408
424
  if (typeof params.starClass === 'string' && params.starClass.trim() !== '') {
409
425
  const requested = STAR_INDEX.get(params.starClass.trim())
@@ -412,17 +428,40 @@ export function generateWorld(params: WorldParams, options: WorldOptions = {}):
412
428
  // 允许简写(如 'G2' → 'G2V')
413
429
  const upper = params.starClass.trim().toUpperCase()
414
430
  const prefixed = STAR_CLASSES.find((s) => s.id.startsWith(upper))
415
- baseStar = prefixed ?? closestStarClass(num(params.starTemp, 5778))
416
- if (!prefixed) {
417
- warnings.push(
418
- `未找到光谱型 "${params.starClass}",按给定温度推断为 ${baseStar.id}。` +
431
+ if (prefixed) {
432
+ baseStar = prefixed
433
+ } else {
434
+ baseStar = closestStarClass(num(params.starTemp, 5778))
435
+ starNotes.push(
436
+ `未找到光谱型「${params.starClass}」,已按温度推断为 ${baseStar.id}(主序星)。` +
419
437
  `可选值:${STAR_CLASSES.map((s) => s.id).join(', ')}`,
420
438
  )
439
+ // 亮度级 I/II/III/IV = 巨/次巨星:同样的光谱字母,亮度高出一个数量级
440
+ const lumMatch = params.starClass.trim().match(/(III|II|IV|I)\b/i)
441
+ const lum = lumMatch ? lumMatch[1].toUpperCase() : undefined
442
+ if (lum !== undefined && lum !== 'V') {
443
+ starNotes.push(
444
+ `⚠ 你给的是「${lum}」级(巨/次巨星),而本模型只支持主序星(V)。` +
445
+ '同光谱字母的巨星比主序星亮 20—100 倍,因此温度、宜居带、日照量都会显著偏低——' +
446
+ '这个结果不能直接采信。要接近真实,请显式给 starMass 与 starRadius。',
447
+ )
448
+ }
421
449
  }
422
450
  }
423
451
  } else {
424
452
  // 没给 starClass:按温度反查最接近的光谱型(用于补全半径/质量)
425
453
  baseStar = closestStarClass(num(params.starTemp, 5778))
454
+ // 完全没给恒星信息 = 默认太阳,这必须说在最前面
455
+ const gaveAnyStar =
456
+ params.starMass !== undefined ||
457
+ params.starRadius !== undefined ||
458
+ params.starTemp !== undefined
459
+ if (!gaveAnyStar) {
460
+ starNotes.push(
461
+ '没有给任何恒星参数,已默认成太阳(G2V,1 L☉,5778 K)。' +
462
+ '若这颗行星绕的不是太阳型恒星(例如 K 型橙星或 F 型巨星),温度与宜居带会差很多。',
463
+ )
464
+ }
426
465
  }
427
466
 
428
467
  const starMassSun = num(params.starMass, baseStar.massSun)
@@ -937,5 +976,13 @@ export function generateWorld(params: WorldParams, options: WorldOptions = {}):
937
976
  },
938
977
  warnings,
939
978
  highlights,
979
+ /**
980
+ * 恒星假设说明(替代了用户设定、或用了默认值)。
981
+ *
982
+ * 与 `warnings` 分开:`warnings` 是「你给的世界有个物理问题」,
983
+ * `starNotes` 是「我改了/补了你的输入」。两者性质不同,呈现位置也不同——
984
+ * `starNotes` 必须出现在输出最前面,因为整份结论都建立在它之上。
985
+ */
986
+ starNotes,
940
987
  }
941
988
  }