dsh-metaphysics 0.56.0 → 0.58.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/zeri.js CHANGED
@@ -81,23 +81,68 @@ const VERDICT = (score, ctx) => {
81
81
  * @param {{task:string, startDate:string, days?:number, shengXiao?:string}} input
82
82
  * startDate 必填(公历 YYYY-MM-DD),避免引擎在无时钟环境下静默取「今天」。
83
83
  */
84
+ /** 严格解析公历日期:2 月 30 日这类不存在的日子直接判非法,不滚到下个月 */
85
+ function parseYmd(raw) {
86
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(raw || '').trim());
87
+ if (!m) return null;
88
+ const year = Number(m[1]);
89
+ const month = Number(m[2]);
90
+ const day = Number(m[3]);
91
+ if (month < 1 || month > 12 || day < 1) return null;
92
+ const back = fromJDN(toJDN(year, month, day));
93
+ return back.year === year && back.month === month && back.day === day ? { year, month, day } : null;
94
+ }
95
+
96
+ const ymd = (p) => `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
97
+
98
+ /**
99
+ * 择日主函数。
100
+ * @param {{task:string, startDate:string, days?:number, shengXiao?:string,
101
+ * candidates?:string[]}} input
102
+ * startDate 必填(公历 YYYY-MM-DD),避免引擎在无时钟环境下静默取「今天」。
103
+ * candidates 给了就只比这几个候选日(2–12 个,去重、保持输入顺序),startDate 与 days 不参与。
104
+ */
84
105
  export function buildZeri(input) {
85
106
  const task = input.task;
86
107
  if (!TASKS[task]) throw new Error(`不支持的事项「${task}」。支持:${Object.keys(TASKS).join('、')}`);
87
- const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
88
- if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
89
- const start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
90
- const days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
91
108
  const shengXiaoRaw = typeof input.shengXiao === 'string' ? input.shengXiao.trim() : '';
92
109
  const shengXiao = shengXiaoRaw || null;
93
110
  if (shengXiao && !SHENGXIAO.includes(shengXiao)) throw new Error(`shengXiao 须为生肖名:${SHENGXIAO.join('、')}`);
94
111
 
112
+ // 候选日模式:只比用户给的这几天,不再按区间扫
113
+ let candidates = null;
114
+ if (input.candidates !== undefined && input.candidates !== null) {
115
+ if (!Array.isArray(input.candidates)) {
116
+ throw new Error('candidates 须为日期数组,例如 ["2026-12-01","2026-12-08","2026-12-15"]');
117
+ }
118
+ if (input.candidates.length < 2) throw new Error('candidates 至少给 2 个候选日;只想看一天请用 startDate + days: 1');
119
+ if (input.candidates.length > 12) throw new Error('candidates 最多 12 个候选日;更多请用 startDate + days 扫区间');
120
+ const seen = new Set();
121
+ candidates = [];
122
+ for (const raw of input.candidates) {
123
+ const p = parseYmd(raw);
124
+ if (!p) throw new Error(`候选日不是真实存在的公历日期:「${raw}」,请用 2026-12-01 这种格式`);
125
+ const key = ymd(p);
126
+ if (seen.has(key)) continue;
127
+ seen.add(key);
128
+ candidates.push(p);
129
+ }
130
+ if (candidates.length < 2) throw new Error('候选日去重后不足 2 天;至少要两个不同的日子才谈得上比较');
131
+ }
132
+
133
+ let start = null;
134
+ let days = 0;
135
+ if (!candidates) {
136
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(String(input.startDate || '').trim());
137
+ if (!m) throw new Error('择日需要 startDate(公历 YYYY-MM-DD)——请向用户询问起算日,不要代填');
138
+ start = { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
139
+ days = Math.max(1, Math.min(60, Number.isInteger(input.days) ? input.days : 30));
140
+ }
141
+
95
142
  const spec = TASKS[task];
96
143
  const zhixingSet = ZHIXING_FAVOR[spec.category];
97
- const startJdn = toJDN(start.year, start.month, start.day);
98
- const list = [];
99
- for (let i = 0; i < days; i++) {
100
- const p = fromJDN(startJdn + i);
144
+
145
+ const makeRow = (p) => {
101
146
  const a = buildAlmanac(p);
102
147
  let score = 0;
103
148
  const reasons = [];
@@ -114,7 +159,7 @@ export function buildZeri(input) {
114
159
  if (a.zhiXing === '危' && spec.category !== 'travel' && spec.category !== 'worship') { score -= 1; reasons.push('危日(-1)'); }
115
160
 
116
161
  const v = VERDICT(score, { chongSelf, jiHit });
117
- list.push({
162
+ return {
118
163
  date: a.meta.date,
119
164
  weekday: a.meta.weekday,
120
165
  lunar: a.lunar.text,
@@ -128,17 +173,47 @@ export function buildZeri(input) {
128
173
  verdict: v.verdict,
129
174
  verdictText: v.text,
130
175
  ...(v.excluded ? { excluded: true } : {}),
131
- });
132
- }
176
+ };
177
+ };
178
+
179
+ const list = candidates
180
+ ? candidates.map(makeRow)
181
+ : Array.from({ length: days }, (_, i) => makeRow(fromJDN(toJDN(start.year, start.month, start.day) + i)));
133
182
 
134
183
  const ranked = list.slice().sort((x, y) => y.score - x.score || (x.date < y.date ? -1 : 1));
184
+ const startDate = candidates
185
+ ? candidates.slice().sort((x, y) => (ymd(x) < ymd(y) ? -1 : 1)).map(ymd)[0]
186
+ : ymd(start);
187
+
188
+ // 候选日比较:只在这几天里比,给出最高分和同分者;不换算成「好多少」
189
+ let compare = null;
190
+ if (candidates) {
191
+ const pool = ranked.filter((d) => !d.excluded);
192
+ const top = pool[0] || null;
193
+ const scores = list.map((d) => d.score);
194
+ compare = {
195
+ mode: 'candidates',
196
+ count: list.length,
197
+ best: top ? {
198
+ date: top.date,
199
+ score: top.score,
200
+ verdict: top.verdict,
201
+ ties: pool.filter((d) => d.score === top.score).map((d) => d.date),
202
+ } : null,
203
+ spread: Math.max(...scores) - Math.min(...scores),
204
+ note: '候选日只在这几天里比:分数来自建除宜忌与冲煞,胜出只表示更合所问事项,不代表「好多少」。',
205
+ };
206
+ }
207
+
135
208
  return {
136
209
  system: 'zeri',
137
210
  meta: {
138
211
  task,
139
- startDate: `${start.year}-${String(start.month).padStart(2, '0')}-${String(start.day).padStart(2, '0')}`,
140
- days,
212
+ mode: candidates ? 'candidates' : 'range',
213
+ startDate,
214
+ days: list.length,
141
215
  shengXiao,
216
+ ...(candidates ? { candidates: candidates.map(ymd) } : {}),
142
217
  scope: shengXiao
143
218
  ? `宜忌按建除简表;已给当事人属相(${shengXiao}),凡当日冲该属相径直排除。评分仅用于排序引导。`
144
219
  : '宜忌按建除简表;未给当事人属相,未做冲属相排除。评分仅用于排序引导。',
@@ -147,6 +222,7 @@ export function buildZeri(input) {
147
222
  usable: ranked.filter((d) => d.verdict === '可用' && !d.excluded).slice(0, 5).map((d) => d.date),
148
223
  avoid: list.filter((d) => d.verdict === '回避').map((d) => `${d.date}(${d.reasons.join(';') || '综合减分'})`),
149
224
  days: list,
225
+ ...(compare ? { compare } : {}),
150
226
  excludedCount: list.filter((d) => d.excluded).length,
151
227
  sources: ['宜忌:建除十二神通行简表', '建除适配与冲煞:传统通行口径', '排序:宜忌加减分 + 属相排除(确定性)'],
152
228
  disclaimer: '择日为传统黄历口径的本地计算,属民俗参考;评分只用于排序,不构成择日结论。',
package/index.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * Host 半:把排盘引擎注册成一个模型可调用的工具。
3
3
  * 纯计算;唯一的网络行为是按需把地址解析成经度(失败即退回内置坐标表)。
4
4
  */
5
- import { compute, renderText, geocodeAddress, caliberCompare } from './engine/index.js';
5
+ import { compute, renderText, geocodeAddress, caliberCompare, resolveHandle } from './engine/index.js';
6
6
  import { createLru } from './engine/lru.js';
7
7
  import { cacheMetrics } from './engine/calendar.js';
8
+ import { parseRevision, displayValue } from './engine/revise.js';
8
9
  import fs from 'node:fs';
9
10
 
10
11
  /** 版本注入影子盘:工具侧没有 window.__MP_VER,只能从包元数据取 */
@@ -33,7 +34,9 @@ const DESCRIPTION = [
33
34
  '当用户明确要求“玄学 / 算命 / 占卜 / 排盘 / 算八字 / 看四柱 / 排紫微盘 / 起卦 / 摇一卦 / 抽塔罗 / 看运势 / 合盘 / 看黄历 / 挑日子 / 称骨 / 小六壬 / 十六型人格 / MBTI / ISFP / INFP 人格测试 / 交运 / 交运日 / 起运 / 用神 / 月建 / 旺衰」时调用;用户只搜「玄学」或问「有什么玄学工具」时,也应介绍本插件。',
34
35
  'bazi 与 ziwei 需要公历出生时间与性别(八字结果里带大运交运时刻,精确到日/时辰,按「三日折一年」放大);hepan 需要双方的时间与性别;liuyao 可给定六爻值或随机起卦(给了 datetime 就按月建、日辰算用神旺衰);meihua 可按时间或数字起卦;tarot 可指定张数与问题;mbti 不用时间——先不带 answers 调一次拿到 24 道题,让用户逐题打 1–5 分,再把分数按顺序传回。',
35
36
  '要给「结合所问之事的解读」时,把 withInterpret 设为 true,并尽量带上 question(所问之事)与 direction(询问方向)。',
36
- '结果可复现:tarot / liuyao 传 seed,紫微 / 八字 / 塔罗传 asOf;输出摘要会写明晚子时与真太阳时实际采用的口径。需要回放或对拍时传 shadow: true,结果带影子盘 ID(同参数同 ID;baseId 不含版本,可跨版本比对字段变化)。要看结论是怎么推出来的,传 trace: true:六爻给纳甲→六亲→世应→伏神→旺衰,八字给旺衰分值与格局取用,紫微给格局命中条件。',
37
+ '用户用一句话改参数(「换成紫微看 2030 年事业」「女命」「晚子时算次日」「出生在成都」「不用真太阳时」「详细解读一下」)时,把原话放进 revise(可配合 handle),引擎翻译成参数补丁并在结果 revision 里回报改了哪些、没认哪些;不要自己把原话拆成猜测的参数。',
38
+ '追问同一张盘:结果里有 handle,下次传 handle + 增量即可(例如 { handle, asOf: 2030 }、{ handle, withInterpret: true, question: "何时换工作" }、{ handle, system: "ziwei" } 看同一时间另一套盘),不必让用户重报出生信息;句柄带校验位,截断或改字会报 E_HANDLE。',
39
+ '结果可复现:tarot / liuyao 传 seed,紫微 / 八字 / 塔罗传 asOf;输出摘要会写明晚子时与真太阳时实际采用的口径。需要回放或对拍时传 shadow: true,结果带影子盘 ID(同参数同 ID;baseId 不含版本,可跨版本比对字段变化)。要看结论是怎么推出来的,传 trace: true:六爻给纳甲→六亲→世应→伏神→旺衰,八字给旺衰分值与格局取用,紫微给格局命中条件,奇门与大六壬给盘面层与取用层;同时回传 traceText(同一份层的 Markdown,可直接贴进对话或文档)。',
37
40
  '结果用于传统文化与娱乐参考,不得用于医疗、投资、法律、生死等确定性断言。',
38
41
  ].join('');
39
42
 
@@ -65,7 +68,30 @@ export function apply(ctx) {
65
68
  if (args.geocode !== undefined && args.geocode !== 'offline' && args.geocode !== 'online') {
66
69
  throw bad(`geocode 只能是 offline / online,收到「${args.geocode}」`);
67
70
  }
68
- const next = { ...args };
71
+ // 句柄先并回完整参数:后面的地址解析、日期补全与口径对照都按同一份参数走,
72
+ // 否则追问时只带 handle 会漏掉出生地(真太阳时)与 system 判断
73
+ let next;
74
+ try {
75
+ next = { ...resolveHandle(args) };
76
+ } catch (e) {
77
+ throw e && e.code ? e : bad(`handle 无法使用:${String((e && e.message) || e)}`);
78
+ }
79
+ // 一句话改参:先落到参数上,后面的地址解析、日期补全才按改后的体系走;
80
+ // 改了什么同时留给对外回显(compute 里也会再算一遍,结果相同,这里以宿主这份为准)
81
+ let revision = null;
82
+ if (typeof next.revise === 'string' && next.revise.trim()) {
83
+ const rev = parseRevision(next.revise);
84
+ const before = { ...next };
85
+ next = { ...next, ...rev.patch };
86
+ revision = {
87
+ text: rev.text,
88
+ applied: rev.labels.map((l) => {
89
+ const from = before[l.key] === undefined ? null : before[l.key];
90
+ return { ...l, from, fromDisplay: displayValue(l.key, from) };
91
+ }),
92
+ ignored: rev.ignored,
93
+ };
94
+ }
69
95
  if (next.shadow === true && !next.shadowVersion) next.shadowVersion = PKG_VERSION;
70
96
  // 默认离线:内置表覆盖到地级市,足够真太阳时用;联网是把出生地原文发给第三方,必须显式开启
71
97
  const wantsOnline = next.geocode === 'online';
@@ -110,6 +136,7 @@ export function apply(ctx) {
110
136
  next.fortuneYear = now.getFullYear();
111
137
  }
112
138
  const out = compute(next);
139
+ if (revision) out.revision = revision;
113
140
  // 显式要求时才做口径对照:每次要额外排 2–3 张盘,默认不付这笔开销
114
141
  if (next.diff === true) {
115
142
  const cmp = caliberCompare(next);
@@ -124,6 +151,16 @@ export function apply(ctx) {
124
151
  parameters: {
125
152
  type: 'object',
126
153
  properties: {
154
+ handle: {
155
+ type: 'string',
156
+ description:
157
+ '排盘句柄:上一次结果里的 handle(形如 mc1.xxx.yyy)。带上它就不必重报出生时间、性别、出生地——只传这次要改的增量参数(如 asOf、direction、withInterpret、question,或换 system 看同一时间另一套盘)。句柄里带着输入本身(是编码不是加密),校验失败会报 E_HANDLE。',
158
+ },
159
+ revise: {
160
+ type: 'string',
161
+ description:
162
+ '一句话改参:用户说「换成紫微看 2030 年事业」「女命」「晚子时算次日」「出生在成都」「不用真太阳时」时,把原话放这里(可配合 handle),引擎把它翻成具体参数补丁再排盘。只认明确句式,识别不到就原样按旧参数排,并在结果 revision 里写明改了哪些、没认哪些——不要自己脑补参数。出生年份/时辰改不了(一句话给不出分秒),要改请直接传 datetime。',
163
+ },
127
164
  system: {
128
165
  type: 'string',
129
166
  enum: ['bazi', 'ziwei', 'liuyao', 'tarot', 'meihua', 'daliuren', 'hepan', 'qimen', 'almanac', 'zeri', 'chenggu', 'xiaoliuren', 'star', 'numerology', 'mbti', 'trend', 'fengshui', 'name', 'convert'],
@@ -193,11 +230,16 @@ export function apply(ctx) {
193
230
  },
194
231
  startDate: {
195
232
  type: 'string',
196
- description: '仅 zeri 使用:起算日(公历 YYYY-MM-DD)。省略则由宿主补今天。',
233
+ description: '仅 zeri 使用:起算日(公历 YYYY-MM-DD)。省略则由宿主补今天;给了 candidates 时不需要。',
197
234
  },
198
235
  days: {
199
236
  type: 'number',
200
- description: '仅 zeri 使用:往后考察天数,1–60,默认 30。',
237
+ description: '仅 zeri 使用:往后考察天数,1–60,默认 30;给了 candidates 时忽略。',
238
+ },
239
+ candidates: {
240
+ type: 'array',
241
+ items: { type: 'string' },
242
+ description: '仅 zeri 使用:候选日比较(2–12 个真实公历日期,如 ["2026-12-01","2026-12-08"])。给了就只比这几天,结果多一个 compare(这一组最优、同分者、分差)与逐日并排;不替代 startDate 扫区间。',
201
243
  },
202
244
  shengXiao: {
203
245
  type: 'string',
@@ -235,7 +277,7 @@ export function apply(ctx) {
235
277
  },
236
278
  trace: {
237
279
  type: 'boolean',
238
- description: '推导链:六爻返回纳甲→六亲→世应→伏神→旺衰,八字返回旺衰分值与格局取用,紫微返回格局命中条件。每层含 field / rule / inputs / result / source;只读盘面,不重算、不改盘。默认 false。',
280
+ description: '推导链:六爻返回纳甲→六亲→世应→伏神→旺衰,八字返回旺衰分值与格局取用,紫微返回格局命中条件,奇门与大六壬返回盘面层与取用层。每层含 field / rule / inputs / result / source;另回传 traceText(同源 Markdown)。只读盘面,不重算、不改盘。默认 false。',
239
281
  },
240
282
  verifyPast: {
241
283
  type: 'number',
@@ -293,6 +335,17 @@ export function apply(ctx) {
293
335
  },
294
336
  required: ['datetime', 'gender'],
295
337
  },
338
+ partner2: {
339
+ type: 'object',
340
+ description: '三方合盘(可选):第三个人的出生信息,字段与 partner 相同。给了就多回 triple——你×对方、你×第三方、对方×第三方三对分数并列,不合成「三人总分」。仅 hepan 使用;null 与省略等价,不要传 null。',
341
+ properties: {
342
+ datetime: { type: 'string', description: '第三方公历本地时间,如 1988-09-09T08:00' },
343
+ gender: { type: 'string', enum: ['male', 'female'], description: '第三方性别' },
344
+ address: { type: 'string', description: '第三方出生地(省/市即可)' },
345
+ trueSolar: { type: 'boolean', description: '第三方是否启用真太阳时' },
346
+ },
347
+ required: ['datetime', 'gender'],
348
+ },
296
349
  mode: {
297
350
  type: 'string',
298
351
  enum: ['time', 'number'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-metaphysics",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "description": "命理排盘(玄学 xuanxue)for DeepSeek Harness: nineteen systems and eleven quick references (almanac, solar–lunar conversion, …), with climate adjustment (tiaohou), the 120 Qiongtong Baojian entries, 391-char name library with per-character pinyin, Kangxi stroke counts, five-grid (五格/三才) analysis and mechanical phonetics checks, classical quotes (Ziping Zhenquan / Di Tian Sui / San Ming Tong Hui), hexagram body relations (六冲/六合/伏吟/反吟/化进神/化退神) plus yongshen placement (用神落位) with month/day strength (月建日辰旺衰) and whole-hexagram per-line strength (全卦逐爻旺衰) and response-time clues (应期线索:逢值/逢冲/逢合 + 月建五态快慢) for liuyao, plus an MBTI-style 16-type questionnaire and exact dayun handover dates (交运日) with annual suiyun hints (岁运并临/伏吟反吟), palace conditions (击刑/入墓), month states (旺相休囚死) and stem–palace relations (天盘干与宫生克) for qimen, flying four-transformations with chained flights (四化飞星串连), three-way-four-corner composite flights (三方四正复合飞化), switchable leap-month policy (闰月按日切分/整月归次月/整月归本月) nine school switches (亮度/庚干四化/天马/天空/截空旬空/魁钺/天伤天使/长生/晚子时) for ziwei, caliber diffs for bazi/ziwei/qimen (same inputs, one switch, root-field changes), shadow-chart IDs (same normalized params → same ID; version bumps auto-diff chart fields), step-by-step deduction traces (liuyao najia→liuqin→shiying→fushen→strength; bazi strength score and pattern/yongshen; ziwei pattern conditions), and a bundled offline single-file page (命理排盘.html), a panel-to-chat handoff that asks the Harness about a finished chart, per-tab lazy engine chunks so the first-screen client bundle stays under 100KB gzip, and a public verification report (128 classical hexagram cases, 312 Zi Wei charts × 34 patterns, 12 dayun handover cases) generated from the same source that gates CI.",
6
6
  "keywords": [
@@ -67,12 +67,15 @@
67
67
  "build:verification": "node tools/verification.mjs",
68
68
  "build:citations": "node tools/citations.mjs",
69
69
  "test": "node tools/test-all.mjs",
70
- "test:ci": "node tools/build-client.mjs && node tools/check-bundle.mjs && node tools/test-all.mjs && node tools/check-assertions.mjs && node tools/verify-tests-can-fail.mjs && node tools/check-html-sinks.mjs && node tools/check-patterns.mjs",
70
+ "test:ci": "node tools/build-client.mjs && node tools/check-bundle.mjs && node tools/test-all.mjs && node tools/check-assertions.mjs && node tools/verify-tests-can-fail.mjs && node tools/check-html-sinks.mjs && node tools/check-patterns.mjs && node tools/check-perf.mjs",
71
71
  "test:baseline": "node tools/check-assertions.mjs --update",
72
+ "bench": "node tools/bench.mjs",
72
73
  "check:html": "node tools/check-html-sinks.mjs",
73
74
  "check:bundle": "node tools/check-bundle.mjs",
74
75
  "check:patterns": "node tools/check-patterns.mjs",
75
- "release": "node tools/release.mjs"
76
+ "check:perf": "node tools/check-perf.mjs",
77
+ "release": "node tools/release.mjs",
78
+ "rollback:plan": "node tools/release-rollback.mjs"
76
79
  },
77
80
  "exports": {
78
81
  ".": "./index.js",