feat(consult): Rath running MD on every domain card, budget 12,500 / 18,500 (BUG-1221)

Product decision 2026-10-03 (changes the BUG-1160/1161 budget): every
domain card carries the Sanjay Rath Narayana running MD beside the legacy
one, in its short form (label, start sign, MD sign, start / end age).
Card budget 12,000 -> 12,500, with the method checklist 18,000 -> 18,500;
three budget assertions rewritten with old / new / reason notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-03 22:49:30 +08:00
co-authored by Claude Opus 5.5
parent 8ca06c4f20
commit 11225171ee
7 changed files with 117 additions and 54 deletions
+17
View File
@@ -16467,3 +16467,20 @@
- 相关记录:BUG-1095、BUG-1026~1028、ERR-109。
- 复发自:无(ERR-109 同类漏同步)
- 修复版本:staging `01929bae`(生产待提升)
## BUG-1221 | Rath 版 Narayana 当前大运只上年运、时运卡,婚恋、事业等领域卡看不到
- 状态:resolved(分支 `codex/astrologer-rulings-batch4-20261003`,未推送;待验收)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-03
- 来源:第三批进度记录「下轮问占星师」第 9 条(产品问题);产品负责人 2026-10-03 决定;任务书 `TASK-astrologer-rulings-batch4-20261003` T1。
- 影响面:`frontend/src/lib/consultation-evidence-card.ts` `baseTiming`(`base.timing.narayana_rath`);三份字数断言(`consult-evidence-card-20260927`、`consult-evidence-card-v2-20260927`、`consult-condensed-checklist-20260927`)。
- 现象:BUG-1218 T4 把 Rath 当前大运只放进 timing / annual 卡(`RATH_NARAYANA_CARD_DOMAINS`),婚恋、事业、财富、健康、父母等单领域卡只有旧算法 Narayana。
- 触发条件:任何非年运 / 时运的单领域或双领域对话。
- 根因:字数预算。单领域卡最大 11,979 / 12,000(乔布斯婚恋,含清单),Rath 大运约 +174 字会超(BUG-1160/1161 的 12,000 / 18,000 口径)。
- 决策(产品负责人 2026-10-03,修改 BUG-1160/1161 的预算决定):全部领域卡都加 Rath 当前大运;卡片预算 12,000 → 12,500,加方法清单的总量 18,000 → 18,500。
- 修复:删掉 `RATH_NARAYANA_CARD_DOMAINS` 与 `withRath`,`baseTiming` 对所有卡都放 Rath;字段按任务书红线 2 收短为 `algorithm`(引擎短标签「Sanjay Rath 版(并列参考,未进打分与时间轴结论)」)、`seed_sign`、`md: {sign, start_age, end_age}`;大运宫主、年数(= 止 − 起)、起运宫第几级、子运留在报告。旧算法 `narayana` 与「旧算法」标签不变。不改提示词。三份字数断言改为 12,500 / 18,500(三栏写在测试注释与进度记录)。
- 验证:`frontend/tests/consult-narayana-rath-card-20261003.test.ts` 三条:timing / annual 卡、三张 golden 盘 × 12 个领域、9 位公开名人回测 golden × 4 条路由 × 12 个领域,每张卡都带 Rath 当前大运、字段只有上述三项且取自引擎原值、不含子运、旧算法标签仍在。9 盘 × 12 领域卡长度表(改前 / 改后 / 余量)见进度记录:契约 + 卡最大 10,284 → 10,244(年运卡因收短字段反而少 40),含清单 total 最大 13,734 → 13,694;单领域非清单重口径最大 11,913(巴拉克·奥巴马综合);乔布斯婚恋 total 11,979 → 12,113。
- 防复发:上述测试;预算断言 12,500 / 18,500。
- 相关记录:BUG-1160、BUG-1161、BUG-1218。
- 复发自:无
- 修复版本:待发布
+19 -20
View File
@@ -585,15 +585,16 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
}
/**
* BUG-1218 (T4): domains whose card carries the Sanjay Rath Narayana beside
* the legacy one. Other single-domain cards sit at up to 11,979 of the 12,000
* characters (Jobs / marriage golden) and the Rath MD adds about 174, so it
* rides only on the timing-led cards (own 12,000 / 18,000 budget); the report
* shows the full Rath table for every chart.
* BUG-1218 (T4) / BUG-1221: every domain card carries the Sanjay Rath
* Narayana beside the legacy one, running MD only and in its short form:
* label, start sign, MD sign and its start / end age. The MD lord, its years
* (end_age − start_age), the deciding level and the sub-periods stay in the
* report. Product 2026-10-03: card budget 12,500, with the method checklist
* 18,500 (was 12,000 / 18,000, BUG-1160/1161).
*/
const RATH_NARAYANA_CARD_DOMAINS: ReadonlySet<ConsultationDomain> = new Set(["timing", "annual"]);
const RATH_MD_CARD_KEYS = ["sign", "start_age", "end_age"] as const;
function baseTiming(timing: Record<string, unknown>, gaps: string[], withRath = false) {
function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
const sub = record(timing.dasha_sub_periods);
const current = record(sub.current);
const pratyantar = record(sub.pratyantar_dasha_timeline);
@@ -634,17 +635,16 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], withRath =
const algorithmLabel = copy(record(timing.narayana_dasha).algorithm_label);
if (narayana.md && typeof algorithmLabel === "string" && algorithmLabel) narayana.algorithm = algorithmLabel;
if (!narayana.md) gaps.push("base.narayana.md");
// BUG-1218 (T4): the Sanjay Rath version rides beside the legacy one — its
// label, start sign, the level that decided it and the running MD only
// (sub-periods stay in the report; 12,000-character card budget).
// BUG-1218 (T4) / BUG-1221: the Sanjay Rath version rides beside the
// legacy one on every card — its label, start sign and the running MD only
// (sub-periods stay in the report; 12,500-character card budget).
const rath = record(record(timing.narayana_dasha).rath);
const rathMd = pickKeys(record(rath.current_dasha).md, NARAYANA_PERIOD_KEYS);
if (withRath && nonEmpty(rathMd) && typeof rath.algorithm_label === "string") {
const narayanaRath: JsonRecord = { algorithm: rath.algorithm_label, md: rathMd };
for (const [from, to] of [["seed_sign", "seed_sign"], ["strength_level_decided", "seed_level"]] as const) {
const value = copy(rath[from]);
if (value !== undefined && value !== null) narayanaRath[to] = value;
}
const rathMd = pickKeys(record(rath.current_dasha).md, RATH_MD_CARD_KEYS);
if (nonEmpty(rathMd) && typeof rath.algorithm_label === "string") {
const narayanaRath: JsonRecord = { algorithm: rath.algorithm_label };
const seedSign = copy(rath.seed_sign);
if (seedSign !== undefined && seedSign !== null) narayanaRath.seed_sign = seedSign;
narayanaRath.md = rathMd;
return { vimshottari, narayana, narayana_rath: narayanaRath };
}
return { vimshottari, narayana };
@@ -1021,10 +1021,9 @@ export function buildEvidenceCard(
if (sources.length === 0) throw new Error("evidence_card_needs_a_domain");
const gaps: string[] = [];
const first = sources[0]!;
const withRath = sources.some((item) => RATH_NARAYANA_CARD_DOMAINS.has(item.domain));
const base = {
natal: baseNatal(claimEvidence(first.packet, "natal_foundation"), gaps),
timing: baseTiming(claimEvidence(first.packet, "timing"), gaps, withRath),
timing: baseTiming(claimEvidence(first.packet, "timing"), gaps),
};
const domainSections: EvidenceCard["domain_sections"] = {};
const domains: ConsultationDomain[] = [];
@@ -1049,7 +1048,7 @@ export function buildEvidenceCard(
const ownGaps: string[] = [];
const candidate = {
natal: baseNatal(claimEvidence(source.packet, "natal_foundation"), ownGaps),
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps, withRath),
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps),
};
if (JSON.stringify(candidate) !== JSON.stringify(base)) {
own = candidate;
@@ -69,6 +69,8 @@ const domainMethodology: Readonly<Record<ConsultationDomain, { strictRoute: stri
// annual list on a 12,000-character ceiling (BUG-1160/1161). Product decision
// 2026-10-02: they take the career / marriage / wealth budget instead (contract
// + card <= 12,000, total with the checklist <= 18,000), so every turn gets both.
// 2026-10-03 (BUG-1221): every card carries the Rath running MD; the budget is
// now 12,500 / 18,500.
/** Domains whose list was written by the affliction-reading brief rather than condensed from the router. */
const BRIEF_WRITTEN_DOMAINS: ReadonlySet<ConsultationDomain> = new Set([
@@ -177,7 +177,10 @@ test("only the consultation tool builds the methodology: reports and other surfa
// 原值: 标题「every single-domain turn stays within 12,000 characters, contract and checklist included」;全部 12 个领域 total ≤ 12,000
// 新值: 年运、时运按事业 / 婚姻 / 财富同口径:契约 + 卡 ≤ 12,000、含清单 total ≤ 18,000;其余领域 total 仍 ≤ 12,000
// 原因: 产品 2026-10-02 决定(BUG-1160/1161):年运 / 时运单领域轮加通用读法与年运清单后约 12,980,原口径无余量
test("size: single-domain turns stay within 12,000 characters with the checklist; annual and timing within 12,000 without it and 18,000 with it", async () => {
// 原值(BUG-1221 前): 上限 12,000 / 12,000 / 18,000;标题同上一组三栏的新值
// 新值: 其余领域 total ≤ 12,500;年运、时运 契约 + 卡 ≤ 12,500、含清单 ≤ 18,500;标题改为 12,500 / 18,500
// 原因: 产品 2026-10-03 决定全部领域卡都放 Rath 当前大运(乔布斯婚恋 total 11,979 → 12,113),卡片预算 12,000 → 12,500、含清单 18,000 → 18,500(BUG-1221,修改 BUG-1160/1161 的预算决定)
test("size: single-domain turns stay within 12,500 characters with the checklist; annual and timing within 12,500 without it and 18,500 with it", async () => {
const serverChart = {
name: "public",
toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" },
@@ -200,10 +203,10 @@ test("size: single-domain turns stay within 12,000 characters with the checklist
assert.equal(state.modelVisibleChars, total);
if (domain === "annual" || domain === "timing") {
const contractAndCard = JSON.stringify({ ...(view as Json), methodology: undefined }).length;
assert.ok(contractAndCard <= 12_000, `${chart.id}/${domain}: contract + card ${contractAndCard}`);
assert.ok(total <= 18_000, `${chart.id}/${domain}: ${total}`);
assert.ok(contractAndCard <= 12_500, `${chart.id}/${domain}: contract + card ${contractAndCard}`);
assert.ok(total <= 18_500, `${chart.id}/${domain}: ${total}`);
} else {
assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`);
assert.ok(total <= 12_500, `${chart.id}/${domain}: ${total}`);
}
}
}
@@ -275,7 +275,10 @@ test("the 「本轮技法」 rows are exactly the rows the full result produced
}
});
test("model-visible size falls to card scale; only the method checklist can push it past 12,000", async () => {
// 原值: 上限 12,000(契约 + 卡;非清单重领域 total)/ 18,000(含清单 total);标题「past 12,000」
// 新值: 上限 12,500 / 18,500;标题「past 12,500」
// 原因: 产品 2026-10-03 决定全部领域卡都放 Rath 当前大运,卡片预算 12,000 → 12,500、含清单 18,000 → 18,500(BUG-1221,修改 BUG-1160/1161 的预算决定)
test("model-visible size falls to card scale; only the method checklist can push it past 12,500", async () => {
// 原值: new Set(["career", "marriage", "wealth"])
// 新值: 加 annual、timing(契约 + 卡 ≤ 12,000,含清单 ≤ 18,000)
// 原因: 产品 2026-10-02 决定年运 / 时运与事业 / 婚姻 / 财富同口径,通用读法与年运清单下发(BUG-1160/1161)
@@ -287,9 +290,9 @@ test("model-visible size falls to card scale; only the method checklist can push
const { methodology, ...rest } = view;
const withoutMethod = JSON.stringify(rest).length;
assert.equal(state.modelVisibleChars, total);
assert.ok(withoutMethod <= 12_000, `${chart.id}/${domain}: contract + card ${withoutMethod}`);
if (!methodHeavy.has(domain)) assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`);
assert.ok(total <= 18_000, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`);
assert.ok(withoutMethod <= 12_500, `${chart.id}/${domain}: contract + card ${withoutMethod}`);
if (!methodHeavy.has(domain)) assert.ok(total <= 12_500, `${chart.id}/${domain}: ${total}`);
assert.ok(total <= 18_500, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`);
}
}
});
@@ -336,7 +336,10 @@ test("the lookup enum carries the optional layers and returns the engine's own v
assert.equal(dig(result, "data", "note"), EVIDENCE_LOOKUP_KP_NOTE);
});
test("size: contract + card stay under 12,000 characters without the checklist, annual and timing included", async () => {
// 原值: 契约 + 卡 ≤ 12,000;标题「under 12,000」
// 新值: 契约 + 卡 ≤ 12,500;标题「under 12,500」
// 原因: 产品 2026-10-03 决定全部领域卡都放 Rath 当前大运,卡片预算 12,000 → 12,500(BUG-1221,修改 BUG-1160/1161 的预算决定)
test("size: contract + card stay under 12,500 characters without the checklist, annual and timing included", async () => {
const serverChart = {
name: "public",
toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" },
@@ -357,7 +360,7 @@ test("size: contract + card stay under 12,000 characters without the checklist,
const view = await tools["run-jyotish-consultation"].execute!({ question: "q", domains: [domain] } as never, { writer: { custom: async () => {} } } as never) as Json;
const { methodology: _methodology, ...rest } = view;
void _methodology;
assert.ok(JSON.stringify(rest).length <= 12_000, `${chart.id}/${domain} contract + card ${JSON.stringify(rest).length}`);
assert.ok(JSON.stringify(rest).length <= 12_500, `${chart.id}/${domain} contract + card ${JSON.stringify(rest).length}`);
assert.deepEqual(dig(view, "evidence_card", "gaps") ?? [], [], `${chart.id}/${domain} gaps`);
}
}
@@ -1,6 +1,8 @@
// TASK-astrologer-rulings-batch3-20261003 T4 (BUG-1218): the Sanjay Rath
// Narayana rides beside the legacy one on the timing-led cards; the legacy
// Narayana keeps its 「旧算法」 label and stays what scoring / timing use.
// TASK-astrologer-rulings-batch3-20261003 T4 (BUG-1218) and
// TASK-astrologer-rulings-batch4-20261003 T1 (BUG-1221): the Sanjay Rath
// Narayana rides beside the legacy one on every domain card, running MD only
// and in its short form (label, start sign, MD sign, start / end age); the
// legacy Narayana keeps its 「旧算法」 label and stays what scoring / timing use.
//
// Fixture: real engine consultation responses for three public AA charts
// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4);
@@ -10,7 +12,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import { buildEvidenceCard } from "../src/lib/consultation-evidence-card.ts";
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { consultationDomainIds, type ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
@@ -35,32 +37,66 @@ function engineRath(workflow: Json): Json {
return ((((workflow.chart as Json).modules as Json).narayana_dasha as Json).rath ?? {}) as Json;
}
function assertRathMd(workflow: Json, domain: ConsultationDomain, where: string) {
const rath = engineRath(workflow);
const md = (rath.current_dasha as Json).md as Json;
const timing = card(workflow, domain).base.timing as Json;
const shown = timing.narayana_rath as Json;
assert.ok(shown, `${where} narayana_rath`);
// Short form only: label, start sign, running MD sign and ages (red line 2).
assert.deepEqual(Object.keys(shown), ["algorithm", "seed_sign", "md"], where);
assert.equal(shown.algorithm, rath.algorithm_label);
assert.equal(shown.algorithm, "Sanjay Rath 版(并列参考,未进打分与时间轴结论)");
assert.equal(shown.seed_sign, rath.seed_sign);
assert.deepEqual(shown.md, { sign: md.sign, start_age: md.start_age, end_age: md.end_age }, where);
// No sub-periods on the card.
assert.equal(JSON.stringify(shown).includes("antardasha"), false, where);
// The legacy Narayana is unchanged and still labelled.
assert.equal((timing.narayana as Json).algorithm, "旧算法(从上升起、一律顺行)", where);
assert.equal(typeof ((timing.narayana as Json).md as Json).sign, "string", where);
assert.equal(rath.used_for_scoring, false);
}
// 原值: 只有 timing / annual 卡带 Rath 大运,且带 lord / years / seed_level
// 新值: 全部领域卡都带,只放标签、起运宫、当前大运星座与起止年龄
// 原因: 产品 2026-10-03 决定全部领域卡都加 Rath 当前大运、字段尽量短(BUG-1221,任务书红线 2)
test("timing and annual cards carry the Rath running MD beside the legacy Narayana", () => {
for (const chart of golden.charts) {
for (const [workflow, domain] of [[chart.timing_workflow, "timing"], [chart.annual_workflow, "annual"]] as const) {
const rath = engineRath(workflow);
const md = (rath.current_dasha as Json).md as Json;
const timing = card(workflow, domain).base.timing as Json;
const shown = timing.narayana_rath as Json;
assert.ok(shown, `${chart.id}/${domain} narayana_rath`);
assert.equal(shown.algorithm, rath.algorithm_label);
assert.equal(shown.algorithm, "Sanjay Rath 版(并列参考,未进打分与时间轴结论)");
assert.equal(shown.seed_sign, rath.seed_sign);
assert.equal(shown.seed_level, rath.strength_level_decided);
assert.deepEqual(shown.md, { sign: md.sign, lord: md.lord, years: md.years, start_age: md.start_age, end_age: md.end_age });
// The legacy Narayana is unchanged and still labelled.
assert.equal((timing.narayana as Json).algorithm, "旧算法(从上升起、一律顺行)");
assert.equal(rath.used_for_scoring, false);
assertRathMd(chart.timing_workflow, "timing", `${chart.id}/timing`);
assertRathMd(chart.annual_workflow, "annual", `${chart.id}/annual`);
}
});
// 原值: 「other domains leave the Rath MD to the report (12,000-character card budget)」:婚恋 / 事业 / 财富 / 健康卡不带 narayana_rath
// 新值: 「every domain card carries the Rath running MD」:全部 12 个领域卡都带
// 原因: 产品 2026-10-03 决定全部领域卡都加 Rath 当前大运,卡片预算 12,000 → 12,500(BUG-1221,修改 BUG-1160/1161)
test("every domain card carries the Rath running MD (12,500-character card budget)", () => {
for (const chart of golden.charts) {
for (const domain of consultationDomainIds) {
assertRathMd(chart.workflow, domain, `${chart.id}/${domain}`);
}
}
});
test("other domains leave the Rath MD to the report (12,000-character card budget)", () => {
for (const chart of golden.charts) {
for (const domain of ["marriage", "career", "wealth", "health"] as const) {
const timing = card(chart.workflow, domain).base.timing as Json;
assert.equal(timing.narayana_rath, undefined, `${chart.id}/${domain}`);
assert.equal(typeof ((timing.narayana as Json).md as Json).sign, "string");
// The nine public AA charts of the biography backtest golden
// (scripts/research/capture_consult_biography_backtest_golden.py): every
// captured route, every domain card carries the Rath running MD.
const backtest = JSON.parse(readFileSync(
new URL("./fixtures/consult-biography-backtest-golden.json", import.meta.url),
"utf8",
)) as { figures: Array<{ id: string; shared_chart: Json; routes: Record<string, Json & { chart?: Json; chart_modules?: Json }> }> };
test("nine public charts: every domain card carries the Rath running MD", () => {
assert.equal(backtest.figures.length, 9);
for (const figure of backtest.figures) {
for (const [route, captured] of Object.entries(figure.routes)) {
// Same overlay as frontend/scripts/research/consult-biography-backtest.mts routeWorkflow.
const { chart: chartOverrides, chart_modules: moduleOverrides, ...rest } = structuredClone(captured);
const shared = structuredClone(figure.shared_chart);
const workflow = { ...rest, chart: { ...shared, ...(chartOverrides ?? {}), modules: { ...(shared.modules as Json), ...(moduleOverrides ?? {}) } } };
for (const domain of consultationDomainIds) {
assertRathMd(workflow, domain, `${figure.id}/${route}/${domain}`);
}
}
}
});