dsh-math-calculator 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +103 -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 +623 -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 +652 -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
@@ -0,0 +1,415 @@
1
+ /**
2
+ * operation="bodies" 的实现:天体目录查询与常数传导。
3
+ *
4
+ * ## 三种用法
5
+ *
6
+ * ```jsonc
7
+ * // 1. 列出全部天体(按类别分组)
8
+ * { "operation": "bodies" }
9
+ *
10
+ * // 2. 查某个天体 / 按类别筛
11
+ * { "operation": "bodies", "bodyName": "mars" }
12
+ * { "operation": "bodies", "bodyKind": "planet-terrestrial" }
13
+ *
14
+ * // 3. 改常数后,看天体数据哪些该重算(**这是最容易出错的一步**)
15
+ * { "operation": "bodies", "bodyConstant": "alpha_fs", "bodyFactor": 2 }
16
+ * ```
17
+ *
18
+ * ## 第 3 种为什么重要
19
+ *
20
+ * 作者改了 α 之后,最容易犯两个**静默**的错:
21
+ * · 把行星质量也跟着改(错:质量与 α 无关,那是篡改设定)
22
+ * · 忘了恒星光度要变(错:气候结论还是旧的)
23
+ *
24
+ * 这里把「该变的」和「不该动的」分成两块明确列出来。
25
+ *
26
+ * @module dsh-math-calculator/ops/bodies-op
27
+ */
28
+
29
+ import { toJsonValue } from '../format.js'
30
+ import {
31
+ ALL_NAMED_BODIES,
32
+ BODY_INDEX,
33
+ NAMED_STARS,
34
+ bodiesByKind,
35
+ type BodyEntry,
36
+ type BodyKind,
37
+ type NamedStar,
38
+ } from '../body-catalog.js'
39
+ import {
40
+ BODY_TYPES,
41
+ EVIDENCE_LABEL,
42
+ FAMILY_LABEL,
43
+ FAMILY_ORDER,
44
+ findBodyType,
45
+ typesByEvidence,
46
+ typesByFamily,
47
+ } from '../body-taxonomy.js'
48
+ import {
49
+ SCALING_RULES,
50
+ describeDependence,
51
+ howToTreatValue,
52
+ propagateConstantChange,
53
+ } from '../body-scaling.js'
54
+ import { CalcError, type MathArgs, type Outcome } from './shared.js'
55
+
56
+ /** 类别的中文名(输出用)。 */
57
+ const KIND_LABEL: Record<BodyKind, string> = {
58
+ star: '恒星',
59
+ 'planet-terrestrial': '类地行星',
60
+ 'planet-super-earth': '超级地球',
61
+ 'planet-mini-neptune': '海王星型小行星',
62
+ 'planet-gas-giant': '气态巨行星',
63
+ 'planet-ice-giant': '冰巨星',
64
+ moon: '卫星',
65
+ 'dwarf-planet': '矮行星',
66
+ asteroid: '小行星',
67
+ 'kuiper-object': '柯伊伯带天体',
68
+ }
69
+
70
+ /**
71
+ * 格式化一条带来源的数值。
72
+ *
73
+ * @param label - 中文名
74
+ * @param value - 数值条目
75
+ * @returns 一行文本(没有值时返回空串)
76
+ */
77
+ function showValue(label: string, value: { value: number; unit: string; dependence: string; uncertainty?: number } | undefined): string {
78
+ if (!value) return ''
79
+ const num =
80
+ Math.abs(value.value) >= 1e5 || (Math.abs(value.value) < 1e-3 && value.value !== 0)
81
+ ? value.value.toExponential(3)
82
+ : String(Number(value.value.toPrecision(6)))
83
+ const err = value.uncertainty !== undefined ? ' ± ' + value.uncertainty : ''
84
+ return ' ' + label.padEnd(14) + num + err + ' ' + value.unit +
85
+ ' [' + describeDependence(value.dependence as never) + ']'
86
+ }
87
+
88
+ /**
89
+ * 渲染一个天体。
90
+ *
91
+ * @param body - 天体条目
92
+ * @returns 文本行
93
+ */
94
+ function renderBody(body: NamedStar | BodyEntry): string[] {
95
+ const lines: string[] = []
96
+ const isStar = body.kind === 'star'
97
+ lines.push((isStar ? '★ ' : '● ') + body.name + '(' + body.id + ') [' + KIND_LABEL[body.kind] + ']')
98
+ if (isStar) {
99
+ const star = body as NamedStar
100
+ lines.push(' 光谱型 ' + star.spectralType + '(' + star.luminosityClass + ' 级' +
101
+ (star.luminosityClass === 'V' ? ',主序星' : star.luminosityClass === 'D' ? ',白矮星' : ',非主序——注意与同字母主序星亮度差极大') + ')')
102
+ lines.push(showValue('光度', star.luminositySun))
103
+ lines.push(showValue('质量', star.massSun))
104
+ lines.push(showValue('半径', star.radiusSun))
105
+ lines.push(showValue('有效温度', star.effectiveTempK))
106
+ } else {
107
+ if (body.host) lines.push(' 宿主:' + (BODY_INDEX.get(body.host)?.name || body.host))
108
+ lines.push(showValue('轨道半长轴', body.semiMajorAxisAU))
109
+ lines.push(showValue('质量', body.massEarth))
110
+ lines.push(showValue('半径', body.radiusEarth))
111
+ lines.push(showValue('表面重力', body.surfaceGravityG))
112
+ lines.push(showValue('平衡温度', body.equilibriumTempK))
113
+ lines.push(showValue('表面温度', body.surfaceTempK))
114
+ lines.push(showValue('反照率', body.albedo))
115
+ lines.push(showValue('自转周期', body.rotationHours))
116
+ lines.push(showValue('轴倾角', body.obliquityDeg))
117
+ lines.push(showValue('气压', body.pressureAtm))
118
+ if (body.atmosphere) lines.push(' 大气 ' + body.atmosphere)
119
+ if (body.tidallyLocked) lines.push(' 潮汐锁定 是(一面永朝主星)')
120
+ }
121
+ const filled = lines.filter(function (line) { return line !== '' && line.trim() !== '' })
122
+ filled.push(' 说明:' + body.summary)
123
+ filled.push(' 要点:' + body.notes)
124
+ return filled
125
+ }
126
+
127
+ /**
128
+ * 渲染一个天体类型。
129
+ *
130
+ * 与具名天体的区别:类型没「精确值」,只有典型值与物理约束。
131
+ * 所以这里刻意不写小数点——写 `11 km` 而不是 `11.0 km`,
132
+ * 避免让人以为中子星半径精确到 0.1 公里。
133
+ *
134
+ * @param type - 类型条目
135
+ * @returns 文本行
136
+ */
137
+ function renderType(type: ReturnType<typeof findBodyType> & object): string[] {
138
+ const lines: string[] = []
139
+ const evTag =
140
+ type.evidence === 'observed' ? '✓已观测'
141
+ : type.evidence === 'theoretical' ? '~理论预言' : '?纯思辨'
142
+ lines.push('■ ' + type.name + '(' + type.nameEn + ') [' + FAMILY_LABEL[type.family] + ' · ' + evTag + ']')
143
+ if (type.massKg !== undefined) {
144
+ const inSun = type.massKg / 1.989e30
145
+ const inEarth = type.massKg / 5.972e24
146
+ const shown = inSun >= 0.01
147
+ ? (inSun >= 0.1 ? inSun.toPrecision(3) + ' 太阳质量' : inSun.toExponential(2) + ' 太阳质量')
148
+ : inEarth >= 0.01
149
+ ? inEarth.toPrecision(3) + ' 地球质量'
150
+ : type.massKg.toExponential(2) + ' kg'
151
+ lines.push(' 典型质量 ' + shown)
152
+ }
153
+ if (type.radiusKm !== undefined) {
154
+ lines.push(' 典型半径 ' + type.radiusKm.toPrecision(3) + ' km')
155
+ }
156
+ if (type.gravityG !== undefined) {
157
+ lines.push(' 典型表面重力 ' + type.gravityG.toExponential(2) + ' g(地球 = 1)')
158
+ }
159
+ if (type.densityKgM3 !== undefined) {
160
+ lines.push(' 典型密度 ' + type.densityKgM3.toExponential(2) + ' kg/m³(水 = 1000)')
161
+ }
162
+ if (type.tempK !== undefined && type.tempK > 0) {
163
+ lines.push(' 典型温度 ' + type.tempK.toPrecision(3) + ' K')
164
+ }
165
+ lines.push(' 依据 ' + type.basis)
166
+ lines.push(' 读者感知 ' + type.perception)
167
+ if (type.lockedBy) {
168
+ lines.push(' ▶ 物理锁死(编不了)')
169
+ lines.push(' ' + type.lockedBy)
170
+ }
171
+ if (type.freeToAuthor) {
172
+ lines.push(' ▶ 可自由发挥(编这些不冲突)')
173
+ lines.push(' ' + type.freeToAuthor)
174
+ }
175
+ if (type.pitfall) {
176
+ lines.push(' ▶ 最容易踩的坑')
177
+ lines.push(' ' + type.pitfall)
178
+ }
179
+ if (type.narrativeUse) {
180
+ lines.push(' ▶ 科幻用法')
181
+ lines.push(' ' + type.narrativeUse)
182
+ }
183
+ return lines
184
+ }
185
+
186
+ /**
187
+ * `bodies`:天体目录、类型学与常数传导。
188
+ *
189
+ * @param args - 工具参数
190
+ * @returns 结果文本与结构化值
191
+ */
192
+ export function opBodies(args: MathArgs): Outcome {
193
+ const lines: string[] = []
194
+
195
+ // ── 用法 0:天体类型学(面向宇宙科幻创作,优先于具名天体)──
196
+ if (args.bodyType !== undefined || args.bodyEvidence !== undefined) {
197
+ const lines2 = []
198
+ // 查单个类型
199
+ if (typeof args.bodyType === 'string' && args.bodyType.trim() !== '') {
200
+ const hit = findBodyType(args.bodyType)
201
+ if (!hit) {
202
+ throw new CalcError(
203
+ 'invalid_argument',
204
+ '找不到天体类型「' + args.bodyType + '」',
205
+ '已收录类型:' + BODY_TYPES.map(function (t) { return t.name }).join('、'),
206
+ )
207
+ }
208
+ lines2.push(...renderType(hit as never))
209
+ return { result: lines2.join('\n'), value: toJsonValue({ type: hit } as never) }
210
+ }
211
+
212
+ // 按证据等级筛
213
+ if (typeof args.bodyEvidence === 'string' && args.bodyEvidence.trim() !== '') {
214
+ const level = args.bodyEvidence.trim() as never
215
+ if (!EVIDENCE_LABEL[level]) {
216
+ throw new CalcError(
217
+ 'invalid_argument',
218
+ '不认识的证据等级「' + args.bodyEvidence + '」',
219
+ '可用:' + Object.keys(EVIDENCE_LABEL).join(' / '),
220
+ )
221
+ }
222
+ const list = typesByEvidence(level)
223
+ lines2.push('═══ ' + EVIDENCE_LABEL[level] + '(' + list.length + ' 类)═══')
224
+ lines2.push('')
225
+ for (const type of list) {
226
+ lines2.push(...renderType(type as never))
227
+ lines2.push('')
228
+ }
229
+ return {
230
+ result: lines2.join('\n'),
231
+ value: toJsonValue({ evidence: level, count: list.length, ids: list.map(function (t) { return t.id }) } as never),
232
+ }
233
+ }
234
+
235
+ // 列全部类型(按族分组)
236
+ lines2.push('═══ 天体类型学(' + BODY_TYPES.length + ' 类,面向宇宙科幻创作)═══')
237
+ lines2.push('')
238
+ lines2.push('证据等级决定了这些数字有多可信:')
239
+ lines2.push(' ✓已观测 = 有观测证据;~理论预言 = 物理必然但未直接观测;?纯思辨 = 都没有')
240
+ lines2.push('')
241
+ for (const family of FAMILY_ORDER) {
242
+ const list = typesByFamily(family)
243
+ if (list.length === 0) continue
244
+ lines2.push('【' + FAMILY_LABEL[family] + '】(' + list.length + ')')
245
+ for (const type of list) {
246
+ const tag = type.evidence === 'observed' ? '✓' : type.evidence === 'theoretical' ? '~' : '?'
247
+ lines2.push(' ' + tag + ' ' + type.name.padEnd(14) + ' ' + type.nameEn)
248
+ }
249
+ lines2.push('')
250
+ }
251
+ lines2.push('【用法】')
252
+ lines2.push(' bodyType="中子星" 查单个类型的物理锁死项与可编项')
253
+ lines2.push(' bodyEvidence="speculative" 只看纯思辨类')
254
+ lines2.push(' 不带参数 列出全部类型')
255
+ return {
256
+ result: lines2.join('\n'),
257
+ value: toJsonValue({
258
+ total: BODY_TYPES.length,
259
+ families: FAMILY_ORDER.map(function (family) {
260
+ return { family: family, label: FAMILY_LABEL[family], count: typesByFamily(family).length }
261
+ }),
262
+ ids: BODY_TYPES.map(function (t) { return t.id }),
263
+ } as never),
264
+ }
265
+ }
266
+
267
+ // ── 用法 3:常数传导(最重要)──
268
+ if (typeof args.bodyConstant === 'string' && args.bodyConstant.trim() !== '') {
269
+ const constant = args.bodyConstant.trim()
270
+ const factor = typeof args.bodyFactor === 'number' ? args.bodyFactor : 2
271
+ if (!(factor > 0)) {
272
+ throw new CalcError('invalid_argument', 'bodyFactor 必须大于 0', '例如 2 表示常数变成原来的两倍')
273
+ }
274
+ const outcome = propagateConstantChange(constant, factor)
275
+
276
+ lines.push('═══ 改常数后的天体传导:' + constant + ' × ' + factor + ' ═══')
277
+ lines.push('')
278
+ if (outcome.effects.length === 0 && outcome.unchanged.length === 0) {
279
+ lines.push('没有关于「' + constant + '」的传导规则。已知常数:' +
280
+ [...new Set(SCALING_RULES.map(function (r) { return r.constant }))].join(' / '))
281
+ } else {
282
+ if (outcome.effects.length > 0) {
283
+ lines.push('【需要重算的量】(有幂次,可算倍数)')
284
+ for (const effect of outcome.effects) {
285
+ const tag =
286
+ effect.confidence === 'exact' ? '[精确]'
287
+ : effect.confidence === 'empirical' ? '[经验]'
288
+ : effect.confidence === 'order-of-magnitude' ? '[量级]' : '[仅方向]'
289
+ lines.push(' ' + tag + ' ' + effect.text)
290
+ }
291
+ lines.push('')
292
+ }
293
+ if (outcome.indirect.length > 0) {
294
+ lines.push('【也要重算,但没有幂次可算】')
295
+ for (const item of outcome.indirect) {
296
+ lines.push(' · ' + item.quantity)
297
+ lines.push(' ' + item.reason)
298
+ }
299
+ lines.push(' → 这一类改完常数后要用 operation="world" 重跑才拿得到新值,')
300
+ lines.push(' 不能拿旧数字凑合——旧值属于旧宇宙。')
301
+ lines.push('')
302
+ }
303
+ if (outcome.unchanged.length > 0) {
304
+ lines.push('【绝不该动的量】(改了就是篡改设定)')
305
+ for (const item of outcome.unchanged) lines.push(' · ' + item)
306
+ lines.push('')
307
+ }
308
+ lines.push('【提醒】')
309
+ lines.push(' 改常数只影响「由公式决定」的量。行星质量、半径、轨道半长轴由引力与')
310
+ lines.push(' 初始条件决定,与电磁类常数无关 —— 同一天体在新宇宙里不会变重变轻。')
311
+ lines.push(' 大气成分、反照率、云量是历史偶然,永远没有公式可依,只能由作者设定。')
312
+ }
313
+
314
+ return {
315
+ result: lines.join('\n'),
316
+ value: toJsonValue({
317
+ constant: constant,
318
+ factor: factor,
319
+ effects: outcome.effects.map(function (item) {
320
+ return {
321
+ quantity: item.quantity,
322
+ multiplier: Number.isFinite(item.multiplier) ? item.multiplier : String(item.multiplier),
323
+ confidence: item.confidence,
324
+ }
325
+ }),
326
+ unchanged: outcome.unchanged,
327
+ } as never),
328
+ }
329
+ }
330
+
331
+ // ── 用法 2a:按名字查单个天体 ──
332
+ if (typeof args.bodyName === 'string' && args.bodyName.trim() !== '') {
333
+ const query = args.bodyName.trim()
334
+ const exact = BODY_INDEX.get(query)
335
+ // 模糊:id 或中文名包含
336
+ const matched = exact ?? ALL_NAMED_BODIES.find(function (body) {
337
+ return body.id.includes(query.toLowerCase()) || body.name.includes(query)
338
+ })
339
+ if (!matched) {
340
+ throw new CalcError(
341
+ 'invalid_argument',
342
+ '找不到天体「' + query + '」',
343
+ '可用 operation="bodies"(不带参数)列出全部。已收录:' +
344
+ ALL_NAMED_BODIES.map(function (body) { return body.name }).join('、'),
345
+ )
346
+ }
347
+ lines.push(...renderBody(matched))
348
+ // 结构化值里带上该天体的所有数值与依赖标记
349
+ return {
350
+ result: lines.join('\n'),
351
+ value: toJsonValue({ body: matched } as never),
352
+ }
353
+ }
354
+
355
+ // ── 用法 2b:按类别筛 ──
356
+ if (typeof args.bodyKind === 'string' && args.bodyKind.trim() !== '') {
357
+ const kind = args.bodyKind.trim() as BodyKind
358
+ if (!(kind in KIND_LABEL)) {
359
+ throw new CalcError(
360
+ 'invalid_argument',
361
+ '不认识的类别「' + kind + '」',
362
+ '可用:' + Object.keys(KIND_LABEL).join(' / '),
363
+ )
364
+ }
365
+ const list = bodiesByKind(kind)
366
+ lines.push('═══ ' + KIND_LABEL[kind] + '(' + list.length + ' 个)═══')
367
+ lines.push('')
368
+ for (const body of list) {
369
+ lines.push(...renderBody(body))
370
+ lines.push('')
371
+ }
372
+ return {
373
+ result: lines.join('\n'),
374
+ value: toJsonValue({ kind: kind, count: list.length, ids: list.map(function (b) { return b.id }) } as never),
375
+ }
376
+ }
377
+
378
+ // ── 用法 1:列全部 ──
379
+ lines.push('═══ 天体目录(' + ALL_NAMED_BODIES.length + ' 个)═══')
380
+ lines.push('')
381
+ lines.push('【恒星】(' + NAMED_STARS.length + ')含主序、巨星、超巨星、白矮星')
382
+ for (const star of NAMED_STARS) {
383
+ lines.push(' · ' + star.name.padEnd(16) + star.spectralType.padEnd(10) +
384
+ star.luminositySun.value + ' L☉')
385
+ }
386
+ lines.push('')
387
+ const groups = Object.keys(KIND_LABEL).filter(function (kind) { return kind !== 'star' })
388
+ for (const kind of groups) {
389
+ const list = bodiesByKind(kind as BodyKind)
390
+ if (list.length === 0) continue
391
+ lines.push('【' + KIND_LABEL[kind as BodyKind] + '】(' + list.length + ')')
392
+ for (const body of list) {
393
+ const hostName = body.host ? (BODY_INDEX.get(body.host)?.name || body.host) : ''
394
+ lines.push(' · ' + body.name.padEnd(16) + (hostName ? '绕' + hostName + ' ' : '') +
395
+ (body.radiusEarth ? ' R=' + body.radiusEarth.value + ' R⊕' : ''))
396
+ }
397
+ lines.push('')
398
+ }
399
+ lines.push('【用法】')
400
+ lines.push(' 查单个:bodyName="mars"(中文也可以:bodyName="火星")')
401
+ lines.push(' 按类筛:bodyKind="moon"')
402
+ lines.push(' 改常数看传导:bodyConstant="alpha_fs", bodyFactor=2')
403
+ lines.push('')
404
+ lines.push('【注意】每个数值都标了依赖类别:')
405
+ lines.push(' 物理必然 / 随常数变 / 地球标定 / 历史偶然 —— 这决定了改常数后它该不该动。')
406
+
407
+ return {
408
+ result: lines.join('\n'),
409
+ value: toJsonValue({
410
+ total: ALL_NAMED_BODIES.length,
411
+ stars: NAMED_STARS.map(function (s) { return { id: s.id, spectralType: s.spectralType } }),
412
+ ids: ALL_NAMED_BODIES.map(function (b) { return b.id }),
413
+ } as never),
414
+ }
415
+ }
package/src/ops/shared.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { create, all } from 'mathjs'
16
16
 
17
17
  import type { JsonValue } from '../format.js'
18
+ import { reportPrecisionLoss } from '../precision-report.js'
18
19
  import type { TerrainParams } from '../terrain.js'
19
20
  import type { WorldParams } from '../world.js'
20
21
 
@@ -58,6 +59,7 @@ export type MathOperation =
58
59
  | 'custom'
59
60
  | 'units'
60
61
  | 'balance'
62
+ | 'bodies'
61
63
  | 'help'
62
64
 
63
65
  /**
@@ -74,6 +76,49 @@ export interface MathArgs {
74
76
  matrix?: number[][]
75
77
  vector?: number[]
76
78
  precision?: number
79
+ /**
80
+ * **显示**精度:**小数点后几位**(与计算精度分开)。
81
+ *
82
+ * ## 为什么要分开
83
+ *
84
+ * `precision` 是**计算**精度:它决定 BigNumber 的有效位数,
85
+ * 调低会真的把大数算错 —— 实测 `(10^20+1)-(10^20)`:
86
+ *
87
+ * precision=64 → 1(正确)
88
+ * precision=20 → 0(算错了)
89
+ *
90
+ * 所以它不能为了让输出好看而调低。而 `displayPrecision` 只影响**写出来的位数**,
91
+ * 计算仍然按 `precision`(默认 64)进行 —— 大数运算照样精确,只是显示得短些。
92
+ *
93
+ * ## 语义是「小数点后几位」,不是「有效数字」
94
+ *
95
+ * 这个区别很大:
96
+ * 8 位小数 → 12345.678901234 得 12345.67890123
97
+ * 8 位有效数字 → 同一个数得 12345.679(只剩 3 位小数)
98
+ *
99
+ * 说「保留 8 位」时想的是小数位,所以按小数位实现。
100
+ *
101
+ * ## 怎么用
102
+ *
103
+ * 一次设定即固化到当前工作区(后续调用自动沿用),也可每次覆盖:
104
+ * { operation:'workspace', action2:'set', displayPrecision:8 } ← 固化
105
+ * { operation:'evaluate', expression:'1/7', displayPrecision:6 } ← 临时
106
+ *
107
+ * 默认 8 位小数;工程核对可给 12—16;整数永远完整,不受此参数影响。
108
+ */
109
+ displayPrecision?: number
110
+ /**
111
+ * 批量表达式(operation=evaluate 的批量模式)。
112
+ *
113
+ * 一次算多条,省掉多次工具往返的开销。每条独立报错,
114
+ * 不会因为一条失败就中断整批。
115
+ *
116
+ * 例:`{operation:'evaluate', expressions:['1/7','2^0.5','log(10)']}`
117
+ *
118
+ * 为什么值得:每次工具调用都有固定开销(往返 + 结果文本进入上下文),
119
+ * 算 5 条就多付 5 次。批量把 5 次往返压成 1 次。
120
+ */
121
+ expressions?: string[]
77
122
  data?: number[]
78
123
  dataY?: number[]
79
124
  distribution?: string
@@ -207,6 +252,32 @@ export interface MathArgs {
207
252
  */
208
253
  balance?: Record<string, { demand: number; supply: number; unit?: string }>
209
254
 
255
+ // ── operation=bodies(天体目录与常数传导)──
256
+ /** 查单个天体:id(mars)或中文名(火星)。 */
257
+ bodyName?: string
258
+ /** 按类别筛:star / planet-terrestrial / moon / dwarf-planet … */
259
+ bodyKind?: string
260
+ /**
261
+ * 改常数后看天体数据哪些该重算。
262
+ *
263
+ * 例:`bodyConstant:"alpha_fs", bodyFactor:2` 表示 α 变成两倍,
264
+ * 输出会分成「必须重算的量」与「绝不该动的量」两块——
265
+ * 后者尤其重要:行星质量、半径、轨道与 α 无关,改了就是篡改设定。
266
+ */
267
+ bodyConstant?: string
268
+ /** 常数变化倍数(默认 2)。 */
269
+ bodyFactor?: number
270
+ /**
271
+ * 查天体**类型**(operation=bodies)。
272
+ *
273
+ * 与 bodyName 的区别:bodyName 查具名天体(火星、木卫二),
274
+ * bodyType 查类型学条目(中子星、热木星、戴森球)——
275
+ * 面向宇宙科幻创作,含理论预言与纯思辨类。
276
+ */
277
+ bodyType?: string
278
+ /** 按证据等级筛类型:observed / theoretical / speculative。 */
279
+ bodyEvidence?: string
280
+
210
281
  // ── operation=derivation 的逆推与档案 ──
211
282
  /** 叙事锚点 id(如 creatureHeight)。 */
212
283
  anchor?: string
@@ -469,12 +540,78 @@ export function normalizeSource(input: string): string {
469
540
  return output.replace(/;{2,}/g, ';').replace(/^;+|;+$/g, '').trim()
470
541
  }
471
542
 
472
- /** 数字修剪:去掉浮点噪声。 */
473
- export function trimNumber(value: number, precision: number): string {
543
+ /**
544
+ * 当前显示精度(模块级,每次工具调用开头设置一次)。
545
+ *
546
+ * 为什么用模块级变量而不是逐层传参:`trimNumber` 有四十多个调用点,
547
+ * 分散在 solve / matrix / stats / eigen / roots 各处。逐个加参数会让函数签名
548
+ * 全都变形,而且容易漏。而显示精度是**一次调用内全局一致**的,
549
+ * 正好适合「入口设一次,各层读取」。
550
+ *
551
+ * 并发风险:若宿主并行调用本工具,两条调用会互相覆盖。
552
+ * 实测宿主是串行调用(tool_call 逐个执行),且这个值只影响显示不影响计算 ——
553
+ * 即使竞争,最坏结果是某次输出位数不对,不会算错数。
554
+ */
555
+ let activeDisplayPrecision: number | undefined
556
+
557
+ /**
558
+ * 设置本次调用的显示精度(在工具入口调用)。
559
+ *
560
+ * @param value - 显示有效位数;undefined = 不截断
561
+ */
562
+ export function setActiveDisplayPrecision(value: number | undefined): void {
563
+ activeDisplayPrecision = value
564
+ }
565
+
566
+ /**
567
+ * 取当前显示精度。
568
+ *
569
+ * @returns 显示有效位数;未设置返回 undefined
570
+ */
571
+ export function currentDisplayPrecision(): number | undefined {
572
+ return activeDisplayPrecision
573
+ }
574
+
575
+ /**
576
+ * 数字修剪:去掉浮点噪声,按**小数点后几位**保留。
577
+ *
578
+ * 语义与 `formatValue` 的 `displayPrecision` 保持一致:**小数位**,不是有效数字。
579
+ *
580
+ * 为什么统一成小数位:说「保留 8 位」时想的是小数位。
581
+ * 有效数字语义下 `12345.678901234` 只给 3 位小数(12345.679),与直觉不符。
582
+ *
583
+ * @param value - 数值
584
+ * @param precision - 计算精度(未设显示精度时作为退路)
585
+ * @param displayPrecision - **小数点后保留几位**;省略则用模块级当前值
586
+ * @returns 显示串
587
+ */
588
+ export function trimNumber(
589
+ value: number,
590
+ precision: number,
591
+ displayPrecision?: number,
592
+ ): string {
474
593
  if (!Number.isFinite(value)) return String(value)
594
+ // 整数一律完整输出:`n = 8`、`10^20+5` 都不该被截断
475
595
  if (Number.isInteger(value)) return String(value)
476
- const significant = Math.min(Math.max(precision, 1), 15)
477
- return Number(value.toPrecision(significant)).toString()
596
+ const effective = displayPrecision !== undefined ? displayPrecision : activeDisplayPrecision
597
+ const decimals = effective !== undefined && Number.isFinite(effective)
598
+ ? Math.max(0, Math.min(15, Math.round(effective)))
599
+ : Math.min(Math.max(precision, 1), 15)
600
+ const abs = Math.abs(value)
601
+ // 极小值:会被四舍五入成 0 时必须用科学计数法(否则非零值显示成 0,是数据丢失)
602
+ if (abs !== 0 && abs < Math.pow(10, -decimals)) {
603
+ return value.toExponential(Math.max(decimals, 3))
604
+ }
605
+ const fixed = value.toFixed(decimals)
606
+ // 用文本去掉尾随 0(不要 `Number()` 再 `String()`,否则 0.00000081 会变 "8.1e-7")
607
+ const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed
608
+ const text = trimmed === '-0' ? '0' : trimmed
609
+ const shown = Number(text)
610
+ // 顺手记录相对误差,供「精度不够就提醒」使用
611
+ if (abs > 0 && Number.isFinite(shown)) {
612
+ reportPrecisionLoss(Math.abs(shown - value) / abs)
613
+ }
614
+ return text
478
615
  }
479
616
 
480
617
  /** 把数字安全地塞进 JsonValue(非有限值转字符串)。 */
@@ -213,10 +213,30 @@ export function opWorkspace(args: MathArgs): Outcome {
213
213
  next.variables = { ...next.variables, ...args.workspaceVariables }
214
214
  }
215
215
  if (args.description) next.description = args.description
216
+ /**
217
+ * 显示精度固化。
218
+ *
219
+ * 这是「让 AI 自己选一个够用的精度并在对话里固定下来」的入口:
220
+ * 定一次,后续所有调用自动沿用,不必每轮重传。
221
+ * 传 0 或 null 表示清除(回到完整输出)。
222
+ */
223
+ if (args.displayPrecision !== undefined) {
224
+ const raw = Number(args.displayPrecision)
225
+ if (!Number.isFinite(raw) || raw < 0 || raw > 64) {
226
+ throw new CalcError(
227
+ 'invalid_argument',
228
+ 'displayPrecision 要在 0—64 之间(0 = 清除,恢复完整输出)',
229
+ '建议值:文学创作 6—8;工程核对 10—12;数论/大整数保持不设(64)',
230
+ )
231
+ }
232
+ if (raw === 0) delete next.displayPrecision
233
+ else next.displayPrecision = Math.round(raw)
234
+ }
216
235
  return next
217
236
  })
218
237
  return {
219
- result: `✓ 已更新工作区 "${name}"(星球参数 ${Object.keys(data.world).length} 项,变量 ${Object.keys(data.variables).length} 个)`,
238
+ result: `✓ 已更新工作区 "${name}"(星球参数 ${Object.keys(data.world).length} 项,变量 ${Object.keys(data.variables).length} 个)` +
239
+ (data.displayPrecision ? `,显示精度 ${data.displayPrecision} 位` : ''),
220
240
  value: toJsonValue({ action: 'set', workspace: data }),
221
241
  }
222
242
  }
@@ -56,6 +56,24 @@ export function opWorld(args: MathArgs, precision: number): Outcome {
56
56
  lines.push('══════ 星球体感画像 ══════')
57
57
  lines.push('')
58
58
 
59
+ /**
60
+ * 假设声明放在**最前面**。
61
+ *
62
+ * 为什么不能放末尾:默认恒星是太阳时,整份输出看起来一切正常
63
+ * (14°C、宜居、天空蓝、四季分明)。而"我把你的 K 型星当成了太阳"
64
+ * 这类消息如果压在一百多行之后,读前 20 行的人和 AI 都看不到 ——
65
+ * 于是拿着一份基于错误恒星的结论去写设定。
66
+ *
67
+ * 凡是**替代了用户设定**或**补了默认值**的,都必须在这里说清。
68
+ */
69
+ const starNotes = (profile as unknown as { starNotes?: string[] }).starNotes
70
+ if (Array.isArray(starNotes) && starNotes.length > 0) {
71
+ lines.push('【⚠ 先说清楚这些假设】')
72
+ for (const note of starNotes) lines.push(' · ' + note)
73
+ lines.push(' 以下结论都建立在这些假设之上;换掉假设,结论会变。')
74
+ lines.push('')
75
+ }
76
+
59
77
  // 读者最先感知的
60
78
  lines.push('【最直观的几件事】')
61
79
  for (const item of profile.highlights) lines.push(` · ${item}`)
@@ -294,6 +312,8 @@ export function opWorld(args: MathArgs, precision: number): Outcome {
294
312
  calendar: profile.calendar,
295
313
  warnings: profile.warnings,
296
314
  highlights: profile.highlights,
315
+ // 恒星假设必须进结构化输出:AI 读的是 value,不是人读的文本
316
+ starNotes: profile.starNotes,
297
317
  provenance: { summary: provenanceSummary(), constants: MODEL_CONSTANTS },
298
318
  }) as { [key: string]: JsonValue }
299
319
 
@@ -327,6 +347,8 @@ export function opWorld(args: MathArgs, precision: number): Outcome {
327
347
  calendar: profile.calendar,
328
348
  warnings: profile.warnings,
329
349
  highlights: profile.highlights,
350
+ // 恒星假设必须进结构化输出:AI 读的是 value,不是人读的文本
351
+ starNotes: profile.starNotes,
330
352
  provenance: { summary: provenanceSummary(), constants: MODEL_CONSTANTS },
331
353
  }),
332
354
  }