dsh-metaphysics 0.50.1 → 0.55.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/engine/index.js CHANGED
@@ -24,10 +24,13 @@ import { buildStar } from './star.js';
24
24
  import { buildNumerology } from './numerology.js';
25
25
  import { buildMbti, mbtiPaper } from './mbti.js';
26
26
  import { buildConvert } from './convert.js';
27
- import { classicsFor, classicsText, suiyunClassicOf, CLASSIC_BOOKS } from './classics.js';
27
+ import { classicsFor, classicsText, suiyunClassicOf, classicOf, CLASSIC_BOOKS } from './classics.js';
28
+ import { diffObjects, summarize, sortDiffs, labelOf, rootOf, categoryOf, formatValue, CALIBER_SWITCHES } from './diff.js';
29
+ import { canonicalJson, fingerprintOf } from './fingerprint.js';
28
30
  import { buildTrend } from './trend.js';
29
31
  import { buildFengshui } from './fengshui.js';
30
32
  import { buildName } from './name.js';
33
+ import { buildTrace, SYSTEM_LABEL } from './trace.js';
31
34
  import { fail } from './fail.js';
32
35
  import { assertCivil } from './validate.js';
33
36
 
@@ -127,15 +130,21 @@ function baziText(r) {
127
130
  `${g.cat}|` + g.items.map((i) => `${i.name}(${i.pillars.map((k) => SHORT[k]).join('')})`).join('、')).join(';')
128
131
  : '神煞:—';
129
132
  const ry = r.renyuan;
133
+ const rySrc = classicOf('smt-renyuan');
130
134
  const renyuanLine = ry
131
- ? `人元:${ry.month}月第${ry.day}日,${ry.active}${ry.activeElement}司权(${ry.segments.map((s) => `${s.stem}${s.from}–${s.to}`).join(' ')})${ry.beyond ? ';已过 30 日表,按本气算' : ''}`
135
+ ? `人元:${ry.month}月第${ry.day}日,${ry.active}${ry.activeElement}司权(${ry.segments.map((s) => `${s.stem}${s.from}–${s.to}`).join(' ')})${ry.beyond ? ';已过 30 日表,按本气算' : ''}${rySrc ? `|原文见《${CLASSIC_BOOKS[rySrc.book].name}·${rySrc.chapter}》` : ''}`
132
136
  : '';
137
+ const ysd = (r.frame && r.frame.yongshen && r.frame.yongshen.disputed) || [];
138
+ const disText = ysd.length
139
+ ? ` 有争议:${ysd.map((d) => `${d.element}(${d.favorRole};${d.climate}盘调候忌${d.element})`).join('、')}`
140
+ : '';
141
+ const disEls = new Set(ysd.map((d) => d.element));
133
142
  const gejuLine = r.frame ? [
134
143
  `格局:${r.frame.primary ? `${r.frame.primary.name}(${r.frame.primary.evidence})` : '—'}`,
135
144
  `   ${r.frame.geju.map((g) => `${g.name}[${g.kind}]`).join(' ')}`,
136
- `用神:喜${r.frame.yongshen.favor.join('')}(${r.frame.yongshen.favorTenGods.join('、')}) 忌${r.frame.yongshen.avoid.join('')}(${r.frame.yongshen.avoidTenGods.join('、')}) ${r.frame.yongshen.method}`,
145
+ `用神:喜${r.frame.yongshen.favor.join('')}(${r.frame.yongshen.favorTenGods.join('、')}) 忌${r.frame.yongshen.avoid.join('')}(${r.frame.yongshen.avoidTenGods.join('、')})${disText} ${r.frame.yongshen.method}`,
137
146
  `   ${r.frame.yongshen.detail}`,
138
- `寒暖:${r.climate ? `${r.climate.label}(${(r.climate.evidence || []).join('、')})${r.climate.need ? ` 调候取${r.climate.need.stem}${r.climate.need.element} 忌${r.climate.avoidElement}` : ''}` : '—'}` +
147
+ `寒暖:${r.climate ? `${r.climate.label}(${(r.climate.evidence || []).join('、')})${r.climate.need ? ` 调候取${r.climate.need.stem}${r.climate.need.element} 忌${r.climate.avoidElement}${disEls.has(r.climate.avoidElement) ? '(有争议)' : ''}` : ''}` : '—'}` +
139
148
  (r.frame.yongshen.qt ? ` 穷通:${r.frame.yongshen.qt.month}${r.frame.yongshen.qt.stem}取${r.frame.yongshen.qt.yong.join('、')}(首取${r.frame.yongshen.qt.primary})` : ''),
140
149
  `十神权重:${Object.entries(r.frame.godStats.groups).map(([k, v]) => `${k}${v}`).join(' ')}`,
141
150
  ] : [];
@@ -158,8 +167,8 @@ function baziText(r) {
158
167
  `流年:${r.liunian.slice(0, 10).map((l) => `${l.year}${l.ganzhi}(${l.stemTenGod})${l.luck ? `·${l.luck.tier}` : ''}`).join(' ')}`,
159
168
  (() => {
160
169
  const hits = r.liunian.filter((l) => l.suiyun && l.suiyun.signals && l.suiyun.signals.length);
161
- if (!hits.length) return '岁运提示:近十年无岁运并临、运年反吟或流年伏吟/反吟(只标干支,不判吉凶)';
162
- const label = (s) => `${s.type}${s.type.startsWith('流年') && s.target ? `(${s.target})` : ''}`;
170
+ if (!hits.length) return '岁运提示:近十年无岁运并临、运年反吟或大运/流年伏吟、反吟(只标干支,不判吉凶)';
171
+ const label = (s) => `${s.type}${(s.type.startsWith('流年') || s.type.startsWith('大运')) && s.target ? `(${s.target})` : ''}`;
163
172
  const e = suiyunClassicOf(r);
164
173
  const src = e ? `|古籍见《${CLASSIC_BOOKS[e.book].name}·${e.chapter}》` : '';
165
174
  return '岁运提示(只标干支变动,不判吉凶):' + hits
@@ -423,9 +432,259 @@ function liuyaoText(r) {
423
432
  return out.filter(Boolean).join('\n');
424
433
  }
425
434
 
426
- /** 工具输出必须是无损 JSON:去掉 undefined / 函数等不可序列化的值 */
435
+ /**
436
+ * 工具输出必须是无损 JSON:去掉 undefined / 函数等不可序列化的值。
437
+ * 语义与 JSON.parse(JSON.stringify(v)) 一致(NaN→null、-0→0、Date→ISO、数组空位→null、
438
+ * 对象里的 undefined/函数丢键、循环引用抛错),但不建中间字符串——结果动辄几百 KB,
439
+ * 每次 compute 都序列化一遍纯属浪费。
440
+ */
427
441
  function jsonSafe(value) {
428
- return JSON.parse(JSON.stringify(value));
442
+ const seen = new WeakSet();
443
+ const walk = (v, key) => {
444
+ if (v instanceof Number || v instanceof String || v instanceof Boolean) v = v.valueOf();
445
+ if (v && typeof v === 'object' && typeof v.toJSON === 'function') v = v.toJSON(key);
446
+ switch (typeof v) {
447
+ case 'string':
448
+ case 'boolean':
449
+ return v;
450
+ case 'number':
451
+ return Number.isFinite(v) ? (Object.is(v, -0) ? 0 : v) : null;
452
+ case 'bigint':
453
+ throw new TypeError('Do not know how to serialize a BigInt');
454
+ case 'object': {
455
+ if (v === null) return null;
456
+ if (seen.has(v)) throw new TypeError('Converting circular structure to JSON');
457
+ seen.add(v);
458
+ let out;
459
+ if (Array.isArray(v)) {
460
+ out = new Array(v.length);
461
+ for (let i = 0; i < v.length; i++) {
462
+ const c = walk(v[i], String(i));
463
+ out[i] = c === undefined ? null : c;
464
+ }
465
+ } else {
466
+ out = {};
467
+ for (const k of Object.keys(v)) {
468
+ const c = walk(v[k], k);
469
+ if (c !== undefined) out[k] = c;
470
+ }
471
+ }
472
+ seen.delete(v);
473
+ return out;
474
+ }
475
+ default:
476
+ return undefined;
477
+ }
478
+ };
479
+ return walk(value, '');
480
+ }
481
+
482
+ // ── 影子盘:同一组归一化参数 + 同一版本 → 同一个短 ID ──────────────
483
+ // 只收影响盘面的参数:地址换成有效经度后不再把原文算进去,问事/方向按体系保留。
484
+ const SHADOW_IGNORE = new Set([
485
+ 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'cross', 'verifyPast', 'rectify', 'events',
486
+ 'geocode', 'ask', 'followup', 'caliber', 'seed', 'asOf',
487
+ ]);
488
+
489
+ const num6 = (n) => {
490
+ if (n === undefined || n === null || n === '') return null;
491
+ const v = Number(n);
492
+ return Number.isFinite(v) ? Number(v.toFixed(6)) : null;
493
+ };
494
+
495
+ function shadowDateTime(value) {
496
+ if (value === undefined || value === null || value === '') return null;
497
+ try {
498
+ const d = parseDateTime(value);
499
+ return { y: d.year, m: d.month, d: d.day, h: d.hour, mi: d.minute, precision: d.precision };
500
+ } catch {
501
+ return String(value).trim();
502
+ }
503
+ }
504
+
505
+ function shadowPerson(person = {}) {
506
+ const out = {};
507
+ if (person.datetime) out.datetime = shadowDateTime(person.datetime);
508
+ if (person.gender) out.gender = String(person.gender).toLowerCase();
509
+ if (person.longitude !== undefined) out.longitude = num6(person.longitude);
510
+ else if (person.address) out.address = String(person.address).trim();
511
+ if (person.lateZi !== undefined) out.lateZi = person.lateZi !== false;
512
+ if (person.trueSolar !== undefined) out.trueSolar = person.trueSolar !== false;
513
+ if (person.dst !== undefined) out.dst = person.dst !== false;
514
+ return out;
515
+ }
516
+
517
+ function qimenLateMode(args) {
518
+ if (args.lateZiHourMode === 'next-day' || args.lateZi === true) return 'next-day';
519
+ return 'same-day';
520
+ }
521
+
522
+ function shadowParams(args, result) {
523
+ const system = String((result && result.system) || args.system || '');
524
+ const input = result && result.input && typeof result.input === 'object' ? result.input : null;
525
+ if (system === 'bazi' && input) {
526
+ return {
527
+ civil: input.civil,
528
+ gender: input.gender,
529
+ longitude: num6(input.longitude),
530
+ lateZi: input.lateZi !== false,
531
+ trueSolar: input.trueSolar !== false,
532
+ dst: !!(input.dst && input.dst.applied),
533
+ };
534
+ }
535
+ if (system === 'ziwei' && input) {
536
+ return {
537
+ civil: { year: input.year, month: input.month, day: input.day, hour: input.hour, minute: input.minute },
538
+ gender: input.gender,
539
+ longitude: num6(input.longitude),
540
+ leapMonth: input.leapMonth || 'split',
541
+ leapAsNext: input.leapAsNext === true,
542
+ schools: input.schools || ZIWEI_SCHOOL_DEFAULTS,
543
+ trueSolar: input.trueSolar !== false,
544
+ dst: !!(input.dst && input.dst.applied),
545
+ };
546
+ }
547
+ if (system === 'qimen') {
548
+ return {
549
+ datetime: shadowDateTime(args.datetime),
550
+ juMethod: args.juMethod || 'chaibu',
551
+ lateZiHourMode: qimenLateMode(args),
552
+ };
553
+ }
554
+ if (system === 'hepan') {
555
+ return {
556
+ chart: args.chart === 'ziwei' ? 'ziwei' : 'bazi',
557
+ a: shadowPerson({ ...args, datetime: args.datetime }),
558
+ b: shadowPerson(args.partner || {}),
559
+ };
560
+ }
561
+
562
+ // 其余体系:只剔除解析/附加模块参数,其余原样归一化
563
+ const params = {};
564
+ for (const [key, value] of Object.entries(args)) {
565
+ if (SHADOW_IGNORE.has(key) || value === undefined) continue;
566
+ if (key === 'datetime') params.datetime = shadowDateTime(value);
567
+ else if (key === 'partner') params.partner = shadowPerson(value);
568
+ else if (key === 'gender') params.gender = String(value).toLowerCase();
569
+ else if (key === 'address') continue; // 调用侧先把地址换成经度,再由 longitude 参与;这里不收原文
570
+ else if (typeof value === 'number') params[key] = Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : null;
571
+ else if (typeof value === 'string') params[key] = value.trim();
572
+ else params[key] = value;
573
+ }
574
+ if (system === 'daliuren' && params.lateZi === undefined) params.lateZi = true;
575
+ if (system === 'tarot' && params.count === undefined) params.count = 3;
576
+ if (system === 'meihua' && params.mode === undefined) params.mode = 'time';
577
+ if (system === 'xiaoliuren' && params.mode === undefined) params.mode = 'time';
578
+ if (system === 'convert' && params.mode === undefined) params.mode = 'solar2lunar';
579
+ if (system === 'fengshui' && params.action === undefined) params.action = 'natal';
580
+ // 随机起卦 / 抽牌:同参数不等于同一副牌,把实际抽到的牌面 / 爻值算进来,
581
+ // 否则两次不同的抽牌会撞成同一个 ID(有 seed 时本来就能复现,这里只是多一层同构校验)。
582
+ if (system === 'tarot' && result && Array.isArray(result.cards) && result.cards.length) {
583
+ params.cards = result.cards.map((c) => `${c.name || ''}|${c.upright ? '正' : '逆'}`);
584
+ }
585
+ if (system === 'liuyao' && result && result.cast && Array.isArray(result.cast.lines)) {
586
+ params.cast = result.cast.lines.join('');
587
+ }
588
+ return params;
589
+ }
590
+
591
+ function shadowMeta(system, args) {
592
+ const seed = args.seed;
593
+ const seeded = !(seed === undefined || seed === null || seed === '');
594
+ // 六爻给定了六爻值就是确定性起卦,不需要 seed 也能复算
595
+ if (system === 'liuyao' && Array.isArray(args.lines) && args.lines.length === 6) {
596
+ return { reproducible: true };
597
+ }
598
+ if ((system === 'tarot' || system === 'liuyao') && !seeded) {
599
+ return { reproducible: false, reason: '未给 seed:重算会变成另一副牌 / 另一卦' };
600
+ }
601
+ if (system === 'bazi' || system === 'ziwei' || system === 'trend' || system === 'fengshui') {
602
+ return { reproducible: true, asOfPinned: !!args.asOf };
603
+ }
604
+ return { reproducible: true };
605
+ }
606
+
607
+ /**
608
+ * 影子盘指纹。版本优先级:显式 opts.version → 构建注入的全局 __MP_VER → dev。
609
+ * @returns {{id:string, baseId:string, version:string, system:string, payload:string,
610
+ * reproducible:boolean, asOfPinned?:boolean, reason?:string}}
611
+ */
612
+ export function chartFingerprint(args = {}, opts = {}) {
613
+ const result = opts.result || null;
614
+ const system = String((result && result.system) || args.system || '');
615
+ const version = String(opts.version || (typeof globalThis !== 'undefined' && globalThis.__MP_VER) || 'dev');
616
+ const fp = fingerprintOf({
617
+ system,
618
+ params: shadowParams(args, result),
619
+ seed: args.seed,
620
+ asOf: args.asOf ? String(args.asOf) : null,
621
+ version,
622
+ });
623
+ return { ...fp, ...shadowMeta(system, args), system, version };
624
+ }
625
+
626
+ const SHADOW_DIFF_IGNORE = new Set([
627
+ 'shadow', 'trace', 'traceError', 'interpret', 'summary', 'classics', 'suiyunClassic', 'renyuanClassic', 'cross',
628
+ 'past', 'rectify', 'followup', 'caliber', 'input', 'reading', 'knowledge', 'outline',
629
+ 'sections', 'answer', 'plain', 'tables', 'glossary', 'caveats', 'lead', 'dimensions',
630
+ 'moduleType', 'technique', 'disclaimer', 'ask', 'followups', 'knowledgeCount', 'title',
631
+ 'systemLabel',
632
+ ]);
633
+
634
+ function shadowChart(result) {
635
+ const out = {};
636
+ for (const [key, value] of Object.entries(result || {})) {
637
+ if (!SHADOW_DIFF_IGNORE.has(key)) out[key] = value;
638
+ }
639
+ return out;
640
+ }
641
+
642
+ /**
643
+ * 存档用的轻量快照:只留影子盘信息与盘面字段(解析/古籍/验前事等不参与 diff)。
644
+ * 页面侧把它写进本地存储,下次同 baseId 重排时直接与它比对。
645
+ */
646
+ export function shadowSnapshot(result) {
647
+ if (!result || !result.shadow || !result.shadow.baseId) return null;
648
+ return { snapshot: true, shadow: result.shadow, data: shadowChart(result) };
649
+ }
650
+
651
+ /**
652
+ * 同一 baseId 的两次结果对比;版本不同时自动落到具体字段。
653
+ * previous / current 可以是完整结果,也可以是 shadowSnapshot() 的存档。
654
+ */
655
+ export function shadowDiff(previous, current, opts = {}) {
656
+ const a = previous && previous.shadow;
657
+ const b = current && current.shadow;
658
+ if (!a || !b) return { sameId: false, total: 0, reason: '缺少影子盘信息' };
659
+ if (a.baseId && b.baseId && a.baseId !== b.baseId) {
660
+ return { sameId: false, total: 0, fromId: a.id, toId: b.id, fromBaseId: a.baseId, toBaseId: b.baseId, reason: '基础指纹不同,不是同一张盘' };
661
+ }
662
+ const pick = (x) => (x.snapshot === true && x.data && typeof x.data === 'object' ? x.data : shadowChart(x));
663
+ const diffs = diffObjects(pick(previous), pick(current));
664
+ const roots = sortDiffs(diffs.filter((d) => rootOf(d.path))).map((d) => ({
665
+ path: d.path, label: labelOf(d.path), from: formatValue(d.from), to: formatValue(d.to),
666
+ }));
667
+ const counts = new Map();
668
+ for (const d of diffs) {
669
+ const cat = categoryOf(d.path) || '其他';
670
+ counts.set(cat, (counts.get(cat) || 0) + 1);
671
+ }
672
+ const categories = [...counts.entries()]
673
+ .map(([name, count]) => ({ name, count }))
674
+ .sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, 'zh-Hans-CN'));
675
+ const fromVersion = opts.fromVersion || a.version || '';
676
+ const toVersion = opts.toVersion || b.version || '';
677
+ return {
678
+ sameId: true,
679
+ versionChanged: fromVersion !== toVersion,
680
+ fromVersion,
681
+ toVersion,
682
+ total: diffs.length,
683
+ summary: summarize(diffs),
684
+ roots,
685
+ categories,
686
+ diffs,
687
+ };
429
688
  }
430
689
 
431
690
  /**
@@ -443,6 +702,11 @@ export function compute(args = {}) {
443
702
  // 岁运提示的原文不占五条上限:只在盘面真有岁运信号时挂到结果上,摘要、解析层与面板各自取用
444
703
  const syc = suiyunClassicOf(r);
445
704
  if (syc) r.suiyunClassic = { ...syc, bookName: CLASSIC_BOOKS[syc.book].name };
705
+ // 人元司事的原文同样不进五条取条表:每张八字盘都有人元,随「人元司权」一节给出
706
+ if (r.renyuan) {
707
+ const ryc = classicOf('smt-renyuan');
708
+ if (ryc) r.renyuanClassic = { ...ryc, bookName: CLASSIC_BOOKS[ryc.book].name };
709
+ }
446
710
  }
447
711
  // 超出 ΔT 覆盖范围时把精度提示顶到摘要最前,别让远期盘悄悄出错
448
712
  if (r && r.precision && r.precision.level === 'approx' && r.summary) {
@@ -519,6 +783,23 @@ export function compute(args = {}) {
519
783
  }
520
784
  // 追问:围绕已算好的盘回答「某一年 / 某方面」的具体问题
521
785
  if (typeof args.ask === 'string' && args.ask.trim()) r.followup = followup(r, args.ask);
786
+ // 推导链:只读结果、不重算;生成失败不影响盘面,单独留一条错误信息
787
+ if (args.trace === true) {
788
+ try {
789
+ const layers = buildTrace(r, { direction: args.direction, gender: args.gender });
790
+ if (layers) r.trace = layers;
791
+ } catch (e) {
792
+ r.traceError = String((e && e.message) || e);
793
+ }
794
+ }
795
+ // 影子盘 ID:同一组归一化参数 + 同一版本 → 同一个短 ID;显式要时才挂,避免默认输出变长
796
+ if (args.shadow === true) {
797
+ try {
798
+ r.shadow = chartFingerprint(args, { result: r, version: args.shadowVersion });
799
+ } catch (e) {
800
+ r.shadow = { error: String((e && e.message) || e) };
801
+ }
802
+ }
522
803
  return r;
523
804
  }
524
805
 
@@ -563,6 +844,70 @@ export function interpretText(i) {
563
844
  return out.join('\n');
564
845
  }
565
846
 
847
+ /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
848
+ // shadow / trace 是结果的派生展示,不是盘面口径;带进来会让影子盘回显把每组开关都算成「有变化」
849
+ const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
850
+
851
+ /**
852
+ * 口径对照:同一组输入,只把某一个口径开关换成它的另一档,逐字段列出变化。
853
+ * 这一步不新增断法,也不改盘;返回根因字段、分类计数与一句话总结,供面板/独立页/工具共用。
854
+ */
855
+ export function caliberCompare(args = {}, opts = {}) {
856
+ const system = opts.system || args.system;
857
+ const switches = opts.switches || CALIBER_SWITCHES[system] || [];
858
+ if (!switches.length) return { system, switches: [], affected: 0, total: 0 };
859
+ const base = { ...args };
860
+ for (const k of CALIBER_IGNORE_ARGS) delete base[k];
861
+ const rows = switches.map((sw) => {
862
+ const current = sw.read(base);
863
+ const alt = sw.alt(current);
864
+ let diffs = [];
865
+ let error = '';
866
+ try {
867
+ diffs = diffObjects(
868
+ compute({ ...base, [sw.key]: current }),
869
+ compute({ ...base, [sw.key]: alt }),
870
+ );
871
+ } catch (e) {
872
+ error = (e && e.message) || String(e);
873
+ }
874
+ const roots = sortDiffs(diffs.filter((d) => rootOf(d.path))).map((d) => ({
875
+ path: d.path,
876
+ label: labelOf(d.path),
877
+ from: formatValue(d.from),
878
+ to: formatValue(d.to),
879
+ }));
880
+ const counts = new Map();
881
+ for (const d of diffs) {
882
+ const c = categoryOf(d.path) || '其他';
883
+ counts.set(c, (counts.get(c) || 0) + 1);
884
+ }
885
+ const categories = [...counts.entries()]
886
+ .map(([name, count]) => ({ name, count }))
887
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh-Hans-CN'));
888
+ return {
889
+ key: sw.key,
890
+ title: sw.title,
891
+ short: sw.short,
892
+ current,
893
+ alt,
894
+ currentLabel: sw.label(current),
895
+ altLabel: sw.label(alt),
896
+ total: diffs.length,
897
+ summary: error ? '' : summarize(diffs),
898
+ roots,
899
+ categories,
900
+ error,
901
+ };
902
+ });
903
+ return {
904
+ system,
905
+ switches: rows,
906
+ affected: rows.filter((r) => !r.error && r.total > 0).length,
907
+ total: rows.reduce((n, r) => n + r.total, 0),
908
+ };
909
+ }
910
+
566
911
  const JU_METHODS = ['chaibu', 'chaoshen', 'zhirun'];
567
912
  const TAROT_COUNTS = [1, 3, 5];
568
913
  const LATE_ZI_MODES = ['same-day', 'next-day'];
@@ -1183,11 +1528,31 @@ function nameText(r) {
1183
1528
  export function renderText(result) {
1184
1529
  const base = result.summary ?? JSON.stringify(result, null, 2);
1185
1530
  const parts = [base];
1531
+ if (result.shadow && result.shadow.id) {
1532
+ const s = result.shadow;
1533
+ parts.push(`【影子盘】${s.id}(同参数同 ID;版本 ${s.version})`
1534
+ + `\n 基础指纹 ${s.baseId}(不含版本,跨版本可比对)`
1535
+ + (s.reproducible === false ? `\n ⚠ 不可复算:${s.reason}` : '')
1536
+ + (s.reproducible === true && s.asOfPinned === false && (s.system === 'bazi' || s.system === 'ziwei') ? '\n 提示:未固定 asOf,流年/流月随「此刻」变化;要比对请传 asOf。' : ''));
1537
+ }
1538
+ if (Array.isArray(result.trace) && result.trace.length) {
1539
+ parts.push(`【推导链】${SYSTEM_LABEL[result.system] || result.system} · ${result.trace.length} 层\n`
1540
+ + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
1541
+ }
1186
1542
  if (result.interpret) {
1187
1543
  const extra = interpretText(result.interpret);
1188
1544
  if (extra) parts.push(extra);
1189
1545
  }
1190
1546
  if (result.cross) parts.push(`【八字 × 紫微 交叉验证】\n${crossText(result.cross)}`);
1547
+ if (result.caliber && Array.isArray(result.caliber.switches) && result.caliber.switches.length) {
1548
+ const c = result.caliber;
1549
+ parts.push('【口径对照】同一组输入,只换一个口径(不判哪套更准,也不改盘面)\n'
1550
+ + c.switches.map((s) => {
1551
+ if (s.error) return `· ${s.title}:未能对照(${s.error})`;
1552
+ if (!s.total) return `· ${s.title}:${s.currentLabel} → ${s.altLabel},本盘无变化`;
1553
+ return `· ${s.title}:${s.currentLabel} → ${s.altLabel}\n ${s.summary}`;
1554
+ }).join('\n'));
1555
+ }
1191
1556
  if (result.followup) {
1192
1557
  const f = result.followup;
1193
1558
  parts.push(`【追问】${f.question}\n${f.answer}${f.evidence && f.evidence.length ? `\n 依据:${f.evidence.join(';')} 置信度:${f.confidence}` : ''}`);
@@ -1217,6 +1582,7 @@ export { pastSignals, verifyFit, rectifyBirth };
1217
1582
  export { crossCheck, crossText, crossSection };
1218
1583
  export { buildAlmanac, buildZeri, buildChenggu, buildXiaoliuren, buildStar, buildNumerology, buildConvert };
1219
1584
  export { buildTrend, buildFengshui, buildName };
1585
+ export { buildTrace };
1220
1586
 
1221
1587
 
1222
1588