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
@@ -0,0 +1,368 @@
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
+ import { toJsonValue } from '../format.js';
29
+ import { ALL_NAMED_BODIES, BODY_INDEX, NAMED_STARS, bodiesByKind, } from '../body-catalog.js';
30
+ import { BODY_TYPES, EVIDENCE_LABEL, FAMILY_LABEL, FAMILY_ORDER, findBodyType, typesByEvidence, typesByFamily, } from '../body-taxonomy.js';
31
+ import { SCALING_RULES, describeDependence, propagateConstantChange, } from '../body-scaling.js';
32
+ import { CalcError } from './shared.js';
33
+ /** 类别的中文名(输出用)。 */
34
+ const KIND_LABEL = {
35
+ star: '恒星',
36
+ 'planet-terrestrial': '类地行星',
37
+ 'planet-super-earth': '超级地球',
38
+ 'planet-mini-neptune': '海王星型小行星',
39
+ 'planet-gas-giant': '气态巨行星',
40
+ 'planet-ice-giant': '冰巨星',
41
+ moon: '卫星',
42
+ 'dwarf-planet': '矮行星',
43
+ asteroid: '小行星',
44
+ 'kuiper-object': '柯伊伯带天体',
45
+ };
46
+ /**
47
+ * 格式化一条带来源的数值。
48
+ *
49
+ * @param label - 中文名
50
+ * @param value - 数值条目
51
+ * @returns 一行文本(没有值时返回空串)
52
+ */
53
+ function showValue(label, value) {
54
+ if (!value)
55
+ return '';
56
+ const num = Math.abs(value.value) >= 1e5 || (Math.abs(value.value) < 1e-3 && value.value !== 0)
57
+ ? value.value.toExponential(3)
58
+ : String(Number(value.value.toPrecision(6)));
59
+ const err = value.uncertainty !== undefined ? ' ± ' + value.uncertainty : '';
60
+ return ' ' + label.padEnd(14) + num + err + ' ' + value.unit +
61
+ ' [' + describeDependence(value.dependence) + ']';
62
+ }
63
+ /**
64
+ * 渲染一个天体。
65
+ *
66
+ * @param body - 天体条目
67
+ * @returns 文本行
68
+ */
69
+ function renderBody(body) {
70
+ const lines = [];
71
+ const isStar = body.kind === 'star';
72
+ lines.push((isStar ? '★ ' : '● ') + body.name + '(' + body.id + ') [' + KIND_LABEL[body.kind] + ']');
73
+ if (isStar) {
74
+ const star = body;
75
+ lines.push(' 光谱型 ' + star.spectralType + '(' + star.luminosityClass + ' 级' +
76
+ (star.luminosityClass === 'V' ? ',主序星' : star.luminosityClass === 'D' ? ',白矮星' : ',非主序——注意与同字母主序星亮度差极大') + ')');
77
+ lines.push(showValue('光度', star.luminositySun));
78
+ lines.push(showValue('质量', star.massSun));
79
+ lines.push(showValue('半径', star.radiusSun));
80
+ lines.push(showValue('有效温度', star.effectiveTempK));
81
+ }
82
+ else {
83
+ if (body.host)
84
+ lines.push(' 宿主:' + (BODY_INDEX.get(body.host)?.name || body.host));
85
+ lines.push(showValue('轨道半长轴', body.semiMajorAxisAU));
86
+ lines.push(showValue('质量', body.massEarth));
87
+ lines.push(showValue('半径', body.radiusEarth));
88
+ lines.push(showValue('表面重力', body.surfaceGravityG));
89
+ lines.push(showValue('平衡温度', body.equilibriumTempK));
90
+ lines.push(showValue('表面温度', body.surfaceTempK));
91
+ lines.push(showValue('反照率', body.albedo));
92
+ lines.push(showValue('自转周期', body.rotationHours));
93
+ lines.push(showValue('轴倾角', body.obliquityDeg));
94
+ lines.push(showValue('气压', body.pressureAtm));
95
+ if (body.atmosphere)
96
+ lines.push(' 大气 ' + body.atmosphere);
97
+ if (body.tidallyLocked)
98
+ lines.push(' 潮汐锁定 是(一面永朝主星)');
99
+ }
100
+ const filled = lines.filter(function (line) { return line !== '' && line.trim() !== ''; });
101
+ filled.push(' 说明:' + body.summary);
102
+ filled.push(' 要点:' + body.notes);
103
+ return filled;
104
+ }
105
+ /**
106
+ * 渲染一个天体类型。
107
+ *
108
+ * 与具名天体的区别:类型没「精确值」,只有典型值与物理约束。
109
+ * 所以这里刻意不写小数点——写 `11 km` 而不是 `11.0 km`,
110
+ * 避免让人以为中子星半径精确到 0.1 公里。
111
+ *
112
+ * @param type - 类型条目
113
+ * @returns 文本行
114
+ */
115
+ function renderType(type) {
116
+ const lines = [];
117
+ const evTag = type.evidence === 'observed' ? '✓已观测'
118
+ : type.evidence === 'theoretical' ? '~理论预言' : '?纯思辨';
119
+ lines.push('■ ' + type.name + '(' + type.nameEn + ') [' + FAMILY_LABEL[type.family] + ' · ' + evTag + ']');
120
+ if (type.massKg !== undefined) {
121
+ const inSun = type.massKg / 1.989e30;
122
+ const inEarth = type.massKg / 5.972e24;
123
+ const shown = inSun >= 0.01
124
+ ? (inSun >= 0.1 ? inSun.toPrecision(3) + ' 太阳质量' : inSun.toExponential(2) + ' 太阳质量')
125
+ : inEarth >= 0.01
126
+ ? inEarth.toPrecision(3) + ' 地球质量'
127
+ : type.massKg.toExponential(2) + ' kg';
128
+ lines.push(' 典型质量 ' + shown);
129
+ }
130
+ if (type.radiusKm !== undefined) {
131
+ lines.push(' 典型半径 ' + type.radiusKm.toPrecision(3) + ' km');
132
+ }
133
+ if (type.gravityG !== undefined) {
134
+ lines.push(' 典型表面重力 ' + type.gravityG.toExponential(2) + ' g(地球 = 1)');
135
+ }
136
+ if (type.densityKgM3 !== undefined) {
137
+ lines.push(' 典型密度 ' + type.densityKgM3.toExponential(2) + ' kg/m³(水 = 1000)');
138
+ }
139
+ if (type.tempK !== undefined && type.tempK > 0) {
140
+ lines.push(' 典型温度 ' + type.tempK.toPrecision(3) + ' K');
141
+ }
142
+ lines.push(' 依据 ' + type.basis);
143
+ lines.push(' 读者感知 ' + type.perception);
144
+ if (type.lockedBy) {
145
+ lines.push(' ▶ 物理锁死(编不了)');
146
+ lines.push(' ' + type.lockedBy);
147
+ }
148
+ if (type.freeToAuthor) {
149
+ lines.push(' ▶ 可自由发挥(编这些不冲突)');
150
+ lines.push(' ' + type.freeToAuthor);
151
+ }
152
+ if (type.pitfall) {
153
+ lines.push(' ▶ 最容易踩的坑');
154
+ lines.push(' ' + type.pitfall);
155
+ }
156
+ if (type.narrativeUse) {
157
+ lines.push(' ▶ 科幻用法');
158
+ lines.push(' ' + type.narrativeUse);
159
+ }
160
+ return lines;
161
+ }
162
+ /**
163
+ * `bodies`:天体目录、类型学与常数传导。
164
+ *
165
+ * @param args - 工具参数
166
+ * @returns 结果文本与结构化值
167
+ */
168
+ export function opBodies(args) {
169
+ const lines = [];
170
+ // ── 用法 0:天体类型学(面向宇宙科幻创作,优先于具名天体)──
171
+ if (args.bodyType !== undefined || args.bodyEvidence !== undefined) {
172
+ const lines2 = [];
173
+ // 查单个类型
174
+ if (typeof args.bodyType === 'string' && args.bodyType.trim() !== '') {
175
+ const hit = findBodyType(args.bodyType);
176
+ if (!hit) {
177
+ throw new CalcError('invalid_argument', '找不到天体类型「' + args.bodyType + '」', '已收录类型:' + BODY_TYPES.map(function (t) { return t.name; }).join('、'));
178
+ }
179
+ lines2.push(...renderType(hit));
180
+ return { result: lines2.join('\n'), value: toJsonValue({ type: hit }) };
181
+ }
182
+ // 按证据等级筛
183
+ if (typeof args.bodyEvidence === 'string' && args.bodyEvidence.trim() !== '') {
184
+ const level = args.bodyEvidence.trim();
185
+ if (!EVIDENCE_LABEL[level]) {
186
+ throw new CalcError('invalid_argument', '不认识的证据等级「' + args.bodyEvidence + '」', '可用:' + Object.keys(EVIDENCE_LABEL).join(' / '));
187
+ }
188
+ const list = typesByEvidence(level);
189
+ lines2.push('═══ ' + EVIDENCE_LABEL[level] + '(' + list.length + ' 类)═══');
190
+ lines2.push('');
191
+ for (const type of list) {
192
+ lines2.push(...renderType(type));
193
+ lines2.push('');
194
+ }
195
+ return {
196
+ result: lines2.join('\n'),
197
+ value: toJsonValue({ evidence: level, count: list.length, ids: list.map(function (t) { return t.id; }) }),
198
+ };
199
+ }
200
+ // 列全部类型(按族分组)
201
+ lines2.push('═══ 天体类型学(' + BODY_TYPES.length + ' 类,面向宇宙科幻创作)═══');
202
+ lines2.push('');
203
+ lines2.push('证据等级决定了这些数字有多可信:');
204
+ lines2.push(' ✓已观测 = 有观测证据;~理论预言 = 物理必然但未直接观测;?纯思辨 = 都没有');
205
+ lines2.push('');
206
+ for (const family of FAMILY_ORDER) {
207
+ const list = typesByFamily(family);
208
+ if (list.length === 0)
209
+ continue;
210
+ lines2.push('【' + FAMILY_LABEL[family] + '】(' + list.length + ')');
211
+ for (const type of list) {
212
+ const tag = type.evidence === 'observed' ? '✓' : type.evidence === 'theoretical' ? '~' : '?';
213
+ lines2.push(' ' + tag + ' ' + type.name.padEnd(14) + ' ' + type.nameEn);
214
+ }
215
+ lines2.push('');
216
+ }
217
+ lines2.push('【用法】');
218
+ lines2.push(' bodyType="中子星" 查单个类型的物理锁死项与可编项');
219
+ lines2.push(' bodyEvidence="speculative" 只看纯思辨类');
220
+ lines2.push(' 不带参数 列出全部类型');
221
+ return {
222
+ result: lines2.join('\n'),
223
+ value: toJsonValue({
224
+ total: BODY_TYPES.length,
225
+ families: FAMILY_ORDER.map(function (family) {
226
+ return { family: family, label: FAMILY_LABEL[family], count: typesByFamily(family).length };
227
+ }),
228
+ ids: BODY_TYPES.map(function (t) { return t.id; }),
229
+ }),
230
+ };
231
+ }
232
+ // ── 用法 3:常数传导(最重要)──
233
+ if (typeof args.bodyConstant === 'string' && args.bodyConstant.trim() !== '') {
234
+ const constant = args.bodyConstant.trim();
235
+ const factor = typeof args.bodyFactor === 'number' ? args.bodyFactor : 2;
236
+ if (!(factor > 0)) {
237
+ throw new CalcError('invalid_argument', 'bodyFactor 必须大于 0', '例如 2 表示常数变成原来的两倍');
238
+ }
239
+ const outcome = propagateConstantChange(constant, factor);
240
+ lines.push('═══ 改常数后的天体传导:' + constant + ' × ' + factor + ' ═══');
241
+ lines.push('');
242
+ if (outcome.effects.length === 0 && outcome.unchanged.length === 0) {
243
+ lines.push('没有关于「' + constant + '」的传导规则。已知常数:' +
244
+ [...new Set(SCALING_RULES.map(function (r) { return r.constant; }))].join(' / '));
245
+ }
246
+ else {
247
+ if (outcome.effects.length > 0) {
248
+ lines.push('【需要重算的量】(有幂次,可算倍数)');
249
+ for (const effect of outcome.effects) {
250
+ const tag = effect.confidence === 'exact' ? '[精确]'
251
+ : effect.confidence === 'empirical' ? '[经验]'
252
+ : effect.confidence === 'order-of-magnitude' ? '[量级]' : '[仅方向]';
253
+ lines.push(' ' + tag + ' ' + effect.text);
254
+ }
255
+ lines.push('');
256
+ }
257
+ if (outcome.indirect.length > 0) {
258
+ lines.push('【也要重算,但没有幂次可算】');
259
+ for (const item of outcome.indirect) {
260
+ lines.push(' · ' + item.quantity);
261
+ lines.push(' ' + item.reason);
262
+ }
263
+ lines.push(' → 这一类改完常数后要用 operation="world" 重跑才拿得到新值,');
264
+ lines.push(' 不能拿旧数字凑合——旧值属于旧宇宙。');
265
+ lines.push('');
266
+ }
267
+ if (outcome.unchanged.length > 0) {
268
+ lines.push('【绝不该动的量】(改了就是篡改设定)');
269
+ for (const item of outcome.unchanged)
270
+ lines.push(' · ' + item);
271
+ lines.push('');
272
+ }
273
+ lines.push('【提醒】');
274
+ lines.push(' 改常数只影响「由公式决定」的量。行星质量、半径、轨道半长轴由引力与');
275
+ lines.push(' 初始条件决定,与电磁类常数无关 —— 同一天体在新宇宙里不会变重变轻。');
276
+ lines.push(' 大气成分、反照率、云量是历史偶然,永远没有公式可依,只能由作者设定。');
277
+ }
278
+ return {
279
+ result: lines.join('\n'),
280
+ value: toJsonValue({
281
+ constant: constant,
282
+ factor: factor,
283
+ effects: outcome.effects.map(function (item) {
284
+ return {
285
+ quantity: item.quantity,
286
+ multiplier: Number.isFinite(item.multiplier) ? item.multiplier : String(item.multiplier),
287
+ confidence: item.confidence,
288
+ };
289
+ }),
290
+ unchanged: outcome.unchanged,
291
+ }),
292
+ };
293
+ }
294
+ // ── 用法 2a:按名字查单个天体 ──
295
+ if (typeof args.bodyName === 'string' && args.bodyName.trim() !== '') {
296
+ const query = args.bodyName.trim();
297
+ const exact = BODY_INDEX.get(query);
298
+ // 模糊:id 或中文名包含
299
+ const matched = exact ?? ALL_NAMED_BODIES.find(function (body) {
300
+ return body.id.includes(query.toLowerCase()) || body.name.includes(query);
301
+ });
302
+ if (!matched) {
303
+ throw new CalcError('invalid_argument', '找不到天体「' + query + '」', '可用 operation="bodies"(不带参数)列出全部。已收录:' +
304
+ ALL_NAMED_BODIES.map(function (body) { return body.name; }).join('、'));
305
+ }
306
+ lines.push(...renderBody(matched));
307
+ // 结构化值里带上该天体的所有数值与依赖标记
308
+ return {
309
+ result: lines.join('\n'),
310
+ value: toJsonValue({ body: matched }),
311
+ };
312
+ }
313
+ // ── 用法 2b:按类别筛 ──
314
+ if (typeof args.bodyKind === 'string' && args.bodyKind.trim() !== '') {
315
+ const kind = args.bodyKind.trim();
316
+ if (!(kind in KIND_LABEL)) {
317
+ throw new CalcError('invalid_argument', '不认识的类别「' + kind + '」', '可用:' + Object.keys(KIND_LABEL).join(' / '));
318
+ }
319
+ const list = bodiesByKind(kind);
320
+ lines.push('═══ ' + KIND_LABEL[kind] + '(' + list.length + ' 个)═══');
321
+ lines.push('');
322
+ for (const body of list) {
323
+ lines.push(...renderBody(body));
324
+ lines.push('');
325
+ }
326
+ return {
327
+ result: lines.join('\n'),
328
+ value: toJsonValue({ kind: kind, count: list.length, ids: list.map(function (b) { return b.id; }) }),
329
+ };
330
+ }
331
+ // ── 用法 1:列全部 ──
332
+ lines.push('═══ 天体目录(' + ALL_NAMED_BODIES.length + ' 个)═══');
333
+ lines.push('');
334
+ lines.push('【恒星】(' + NAMED_STARS.length + ')含主序、巨星、超巨星、白矮星');
335
+ for (const star of NAMED_STARS) {
336
+ lines.push(' · ' + star.name.padEnd(16) + star.spectralType.padEnd(10) +
337
+ star.luminositySun.value + ' L☉');
338
+ }
339
+ lines.push('');
340
+ const groups = Object.keys(KIND_LABEL).filter(function (kind) { return kind !== 'star'; });
341
+ for (const kind of groups) {
342
+ const list = bodiesByKind(kind);
343
+ if (list.length === 0)
344
+ continue;
345
+ lines.push('【' + KIND_LABEL[kind] + '】(' + list.length + ')');
346
+ for (const body of list) {
347
+ const hostName = body.host ? (BODY_INDEX.get(body.host)?.name || body.host) : '';
348
+ lines.push(' · ' + body.name.padEnd(16) + (hostName ? '绕' + hostName + ' ' : '') +
349
+ (body.radiusEarth ? ' R=' + body.radiusEarth.value + ' R⊕' : ''));
350
+ }
351
+ lines.push('');
352
+ }
353
+ lines.push('【用法】');
354
+ lines.push(' 查单个:bodyName="mars"(中文也可以:bodyName="火星")');
355
+ lines.push(' 按类筛:bodyKind="moon"');
356
+ lines.push(' 改常数看传导:bodyConstant="alpha_fs", bodyFactor=2');
357
+ lines.push('');
358
+ lines.push('【注意】每个数值都标了依赖类别:');
359
+ lines.push(' 物理必然 / 随常数变 / 地球标定 / 历史偶然 —— 这决定了改常数后它该不该动。');
360
+ return {
361
+ result: lines.join('\n'),
362
+ value: toJsonValue({
363
+ total: ALL_NAMED_BODIES.length,
364
+ stars: NAMED_STARS.map(function (s) { return { id: s.id, spectralType: s.spectralType }; }),
365
+ ids: ALL_NAMED_BODIES.map(function (b) { return b.id; }),
366
+ }),
367
+ };
368
+ }
package/lib/ops/shared.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * @module dsh-math-calculator/ops/shared
13
13
  */
14
14
  import { create, all } from 'mathjs';
15
+ import { reportPrecisionLoss } from '../precision-report.js';
15
16
  // ────────────────────────────────────────────────────────────────────────────
16
17
  // 错误
17
18
  // ────────────────────────────────────────────────────────────────────────────
@@ -153,14 +154,73 @@ export function normalizeSource(input) {
153
154
  }
154
155
  return output.replace(/;{2,}/g, ';').replace(/^;+|;+$/g, '').trim();
155
156
  }
156
- /** 数字修剪:去掉浮点噪声。 */
157
- export function trimNumber(value, precision) {
157
+ /**
158
+ * 当前显示精度(模块级,每次工具调用开头设置一次)。
159
+ *
160
+ * 为什么用模块级变量而不是逐层传参:`trimNumber` 有四十多个调用点,
161
+ * 分散在 solve / matrix / stats / eigen / roots 各处。逐个加参数会让函数签名
162
+ * 全都变形,而且容易漏。而显示精度是**一次调用内全局一致**的,
163
+ * 正好适合「入口设一次,各层读取」。
164
+ *
165
+ * 并发风险:若宿主并行调用本工具,两条调用会互相覆盖。
166
+ * 实测宿主是串行调用(tool_call 逐个执行),且这个值只影响显示不影响计算 ——
167
+ * 即使竞争,最坏结果是某次输出位数不对,不会算错数。
168
+ */
169
+ let activeDisplayPrecision;
170
+ /**
171
+ * 设置本次调用的显示精度(在工具入口调用)。
172
+ *
173
+ * @param value - 显示有效位数;undefined = 不截断
174
+ */
175
+ export function setActiveDisplayPrecision(value) {
176
+ activeDisplayPrecision = value;
177
+ }
178
+ /**
179
+ * 取当前显示精度。
180
+ *
181
+ * @returns 显示有效位数;未设置返回 undefined
182
+ */
183
+ export function currentDisplayPrecision() {
184
+ return activeDisplayPrecision;
185
+ }
186
+ /**
187
+ * 数字修剪:去掉浮点噪声,按**小数点后几位**保留。
188
+ *
189
+ * 语义与 `formatValue` 的 `displayPrecision` 保持一致:**小数位**,不是有效数字。
190
+ *
191
+ * 为什么统一成小数位:说「保留 8 位」时想的是小数位。
192
+ * 有效数字语义下 `12345.678901234` 只给 3 位小数(12345.679),与直觉不符。
193
+ *
194
+ * @param value - 数值
195
+ * @param precision - 计算精度(未设显示精度时作为退路)
196
+ * @param displayPrecision - **小数点后保留几位**;省略则用模块级当前值
197
+ * @returns 显示串
198
+ */
199
+ export function trimNumber(value, precision, displayPrecision) {
158
200
  if (!Number.isFinite(value))
159
201
  return String(value);
202
+ // 整数一律完整输出:`n = 8`、`10^20+5` 都不该被截断
160
203
  if (Number.isInteger(value))
161
204
  return String(value);
162
- const significant = Math.min(Math.max(precision, 1), 15);
163
- return Number(value.toPrecision(significant)).toString();
205
+ const effective = displayPrecision !== undefined ? displayPrecision : activeDisplayPrecision;
206
+ const decimals = effective !== undefined && Number.isFinite(effective)
207
+ ? Math.max(0, Math.min(15, Math.round(effective)))
208
+ : Math.min(Math.max(precision, 1), 15);
209
+ const abs = Math.abs(value);
210
+ // 极小值:会被四舍五入成 0 时必须用科学计数法(否则非零值显示成 0,是数据丢失)
211
+ if (abs !== 0 && abs < Math.pow(10, -decimals)) {
212
+ return value.toExponential(Math.max(decimals, 3));
213
+ }
214
+ const fixed = value.toFixed(decimals);
215
+ // 用文本去掉尾随 0(不要 `Number()` 再 `String()`,否则 0.00000081 会变 "8.1e-7")
216
+ const trimmed = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
217
+ const text = trimmed === '-0' ? '0' : trimmed;
218
+ const shown = Number(text);
219
+ // 顺手记录相对误差,供「精度不够就提醒」使用
220
+ if (abs > 0 && Number.isFinite(shown)) {
221
+ reportPrecisionLoss(Math.abs(shown - value) / abs);
222
+ }
223
+ return text;
164
224
  }
165
225
  /** 把数字安全地塞进 JsonValue(非有限值转字符串)。 */
166
226
  export function safeNumberForJson(value) {
@@ -174,10 +174,28 @@ export function opWorkspace(args) {
174
174
  }
175
175
  if (args.description)
176
176
  next.description = args.description;
177
+ /**
178
+ * 显示精度固化。
179
+ *
180
+ * 这是「让 AI 自己选一个够用的精度并在对话里固定下来」的入口:
181
+ * 定一次,后续所有调用自动沿用,不必每轮重传。
182
+ * 传 0 或 null 表示清除(回到完整输出)。
183
+ */
184
+ if (args.displayPrecision !== undefined) {
185
+ const raw = Number(args.displayPrecision);
186
+ if (!Number.isFinite(raw) || raw < 0 || raw > 64) {
187
+ throw new CalcError('invalid_argument', 'displayPrecision 要在 0—64 之间(0 = 清除,恢复完整输出)', '建议值:文学创作 6—8;工程核对 10—12;数论/大整数保持不设(64)');
188
+ }
189
+ if (raw === 0)
190
+ delete next.displayPrecision;
191
+ else
192
+ next.displayPrecision = Math.round(raw);
193
+ }
177
194
  return next;
178
195
  });
179
196
  return {
180
- result: `✓ 已更新工作区 "${name}"(星球参数 ${Object.keys(data.world).length} 项,变量 ${Object.keys(data.variables).length} 个)`,
197
+ result: `✓ 已更新工作区 "${name}"(星球参数 ${Object.keys(data.world).length} 项,变量 ${Object.keys(data.variables).length} 个)` +
198
+ (data.displayPrecision ? `,显示精度 ${data.displayPrecision} 位` : ''),
181
199
  value: toJsonValue({ action: 'set', workspace: data }),
182
200
  };
183
201
  }
@@ -51,6 +51,24 @@ export function opWorld(args, precision) {
51
51
  const f = (value, digits = 2) => Number(value.toFixed(digits)).toString();
52
52
  lines.push('══════ 星球体感画像 ══════');
53
53
  lines.push('');
54
+ /**
55
+ * 假设声明放在**最前面**。
56
+ *
57
+ * 为什么不能放末尾:默认恒星是太阳时,整份输出看起来一切正常
58
+ * (14°C、宜居、天空蓝、四季分明)。而"我把你的 K 型星当成了太阳"
59
+ * 这类消息如果压在一百多行之后,读前 20 行的人和 AI 都看不到 ——
60
+ * 于是拿着一份基于错误恒星的结论去写设定。
61
+ *
62
+ * 凡是**替代了用户设定**或**补了默认值**的,都必须在这里说清。
63
+ */
64
+ const starNotes = profile.starNotes;
65
+ if (Array.isArray(starNotes) && starNotes.length > 0) {
66
+ lines.push('【⚠ 先说清楚这些假设】');
67
+ for (const note of starNotes)
68
+ lines.push(' · ' + note);
69
+ lines.push(' 以下结论都建立在这些假设之上;换掉假设,结论会变。');
70
+ lines.push('');
71
+ }
54
72
  // 读者最先感知的
55
73
  lines.push('【最直观的几件事】');
56
74
  for (const item of profile.highlights)
@@ -264,6 +282,8 @@ export function opWorld(args, precision) {
264
282
  calendar: profile.calendar,
265
283
  warnings: profile.warnings,
266
284
  highlights: profile.highlights,
285
+ // 恒星假设必须进结构化输出:AI 读的是 value,不是人读的文本
286
+ starNotes: profile.starNotes,
267
287
  provenance: { summary: provenanceSummary(), constants: MODEL_CONSTANTS },
268
288
  });
269
289
  return {
@@ -295,6 +315,8 @@ export function opWorld(args, precision) {
295
315
  calendar: profile.calendar,
296
316
  warnings: profile.warnings,
297
317
  highlights: profile.highlights,
318
+ // 恒星假设必须进结构化输出:AI 读的是 value,不是人读的文本
319
+ starNotes: profile.starNotes,
298
320
  provenance: { summary: provenanceSummary(), constants: MODEL_CONSTANTS },
299
321
  }),
300
322
  };
package/lib/playbook.js CHANGED
@@ -245,9 +245,11 @@ export function buildPlaybook(state) {
245
245
  out.push('- ' + item.step + ':' + item.do + '(' + item.skip + ')');
246
246
  }
247
247
  out.push('');
248
- out.push('### 两条底线');
248
+ out.push('### 三条底线');
249
249
  out.push('1. **不要反驳用户的设定。** 插件指出「按这个参数会怎样」,你转述,并给出「想保留的话可以怎么调」。');
250
250
  out.push('2. **不要编数字。** 算不出来的(混沌、超出现有物理、插件没覆盖的),标待定或问用户。');
251
+ out.push('3. **不要照抄全精度。** 工具输出保留几十位小数,是为了**让你有得选**,不是让你全部抄出去。转述时保留几位,由你判断——依据是这个数在文中要干什么:读者只需要量级感就给少的,要拿去核对数字就给多的,要参与后续计算就给够。插件不给固定档位,因为「几位合适」是语境问题,不是物理问题。');
252
+ out.push(' 唯一硬边界:**该精确的整数不能截断**(`10^20+5` 就是 `100000000000000000005`,写成 `1e20` 是丢信息,不是简洁)。');
251
253
  return out.join(String.fromCharCode(10));
252
254
  }
253
255
  /**
@@ -263,6 +265,8 @@ export function buildBriefPlaybook(state) {
263
265
  out.push('- 该写的(结语、描写、叙事判断)由你生成,别照抄工具输出');
264
266
  out.push('- 硬约束(物质不存在等)如实说;叙事选择(要巨兽、要灵能)不反驳,只讲代价');
265
267
  out.push('- 算不出来的别编,标待定或问用户');
268
+ out.push('- 工具输出默认是**全精度**(可能几十位小数),是供你取舍的原料。**保留几位你来定**:读者只要感觉就少给,要核对就多给,要接着算就管够。别把 60 位抄给读者,也别把该精确的整数(如 10^20+5)截成科学计数法');
269
+ out.push('- 工具输出默认按**小数点后 8 位**显示(整数永远完整,如 10^20+5 不会被截)。需要更多位就单次传 `displayPrecision`;想全局换一档用 `{operation:"workspace", action2:"set", displayPrecision:N}` 固化一次。注意 `precision`(计算精度)不要调低,它一低大数就会算错');
266
270
  if ((state.changedConstants ?? []).length > 0) {
267
271
  out.push('- 本区改过常数,先跑 dimensionless 确认不是单位重定义');
268
272
  }
@@ -348,5 +352,6 @@ export function buildConfiguredPlaybook(state) {
348
352
  out.push('### 底线');
349
353
  out.push('1. 不要反驳用户的设定——说清「按这个参数会怎样」,并给出「想保留可以怎么调」。');
350
354
  out.push('2. 不要编数字——算不出来的标待定或问用户。');
355
+ out.push('3. 不要照抄全精度——工具输出保留几十位小数是为让你有得选,不是让你全抄。保留几位由你判断:读者只要量级就少给,要核对就多给,要参与后续计算就管够。唯一硬边界是该精确的整数不能截断(`10^20+5` 写成 `1e20` 是丢信息)。');
351
356
  return out.join(String.fromCharCode(10));
352
357
  }
@@ -0,0 +1,80 @@
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
+ export const PRECISION_LOSS_THRESHOLD = 1e-4;
33
+ /** 本轮调用中已记录的最大相对误差。 */
34
+ let maxRelativeLoss = 0;
35
+ /**
36
+ * 记录一次截断造成的相对误差。
37
+ *
38
+ * @param relativeLoss - |显示值−真值| / |真值|
39
+ */
40
+ export function reportPrecisionLoss(relativeLoss) {
41
+ if (Number.isFinite(relativeLoss) && relativeLoss > maxRelativeLoss) {
42
+ maxRelativeLoss = relativeLoss;
43
+ }
44
+ }
45
+ /**
46
+ * 取出并清空本轮的最大相对误差。
47
+ *
48
+ * 清空是必要的:每次工具调用开始都要重新统计,
49
+ * 否则上一轮的损失会一直挂在本轮结果上。
50
+ *
51
+ * @returns 最大相对误差;0 表示没有可感知的损失
52
+ */
53
+ export function consumePrecisionLoss() {
54
+ const value = maxRelativeLoss;
55
+ maxRelativeLoss = 0;
56
+ return value;
57
+ }
58
+ /**
59
+ * 本轮显示精度是否由调用方**显式**指定。
60
+ *
61
+ * 为什么要区分:作者自己写 `displayPrecision:2` 时,截断是他的意图,
62
+ * 再提醒「精度不够」就是啰嗦。只有用默认值时才值得提醒。
63
+ */
64
+ let displayPrecisionExplicit = false;
65
+ /**
66
+ * 标记本轮显示精度是否来自显式参数。
67
+ *
68
+ * @param explicit - 是否显式
69
+ */
70
+ export function setDisplayPrecisionExplicit(explicit) {
71
+ displayPrecisionExplicit = explicit;
72
+ }
73
+ /**
74
+ * 当前显示精度是否来自显式参数。
75
+ *
76
+ * @returns 是否显式
77
+ */
78
+ export function isDisplayPrecisionExplicit() {
79
+ return displayPrecisionExplicit;
80
+ }