feat(narayana): show the Sanjay Rath version beside the legacy one (T4, BUG-1218)

Display only; scoring and timing conclusions keep the legacy algorithm.
narayana_legacy_label attaches a Rath view (seed, deciding level, direction,
24 MDs, running MD/AD) under modules.narayana_dasha.rath for the consultation
modules, full reading Step 4.13 and the CLI. Reference report: one summary
line under the legacy line plus Rath MD / running-MD AD tables (English terms
added). Evidence card: narayana_rath (label, seed sign, level, running MD) on
timing / annual cards only — other cards sit at 11,979 of 12,000 characters.
Goldens re-captured from the real engine.

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 21:09:40 +08:00
co-authored by Claude Opus 5.5
parent 3264831a23
commit bf8d704708
13 changed files with 387 additions and 16 deletions
+28 -3
View File
@@ -331,6 +331,8 @@ export type EvidenceCard = {
timing: {
vimshottari: JsonRecord;
narayana: JsonRecord;
/** BUG-1218: Sanjay Rath Narayana beside the legacy one; display only (scoring and timing stay legacy). */
narayana_rath?: JsonRecord;
};
};
domain_sections: Partial<Record<ConsultationDomain, EvidenceCardDomainSection>>;
@@ -582,7 +584,16 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
return { ascendant: ascendant ?? null, houses, planets, functional_benefic_malefic: functional, d9 };
}
function baseTiming(timing: 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.
*/
const RATH_NARAYANA_CARD_DOMAINS: ReadonlySet<ConsultationDomain> = new Set(["timing", "annual"]);
function baseTiming(timing: Record<string, unknown>, gaps: string[], withRath = false) {
const sub = record(timing.dasha_sub_periods);
const current = record(sub.current);
const pratyantar = record(sub.pratyantar_dasha_timeline);
@@ -623,6 +634,19 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
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).
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;
}
return { vimshottari, narayana, narayana_rath: narayanaRath };
}
return { vimshottari, narayana };
}
@@ -997,9 +1021,10 @@ 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),
timing: baseTiming(claimEvidence(first.packet, "timing"), gaps, withRath),
};
const domainSections: EvidenceCard["domain_sections"] = {};
const domains: ConsultationDomain[] = [];
@@ -1024,7 +1049,7 @@ export function buildEvidenceCard(
const ownGaps: string[] = [];
const candidate = {
natal: baseNatal(claimEvidence(source.packet, "natal_foundation"), ownGaps),
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps),
timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps, withRath),
};
if (JSON.stringify(candidate) !== JSON.stringify(base)) {
own = candidate;
@@ -348,6 +348,9 @@ const timingKeys = new Set([
"md", "ad", "pd", "years", "startage", "endage", "remainingyears", "pratyantardashatimeline",
// BUG-1215: the running Narayana is the legacy algorithm; its label travels with it.
"algorithmlabel",
// BUG-1218: the Sanjay Rath Narayana beside it (display only): label, start
// sign, deciding level and the running MD.
"rath", "seedsign", "strengthleveldecided",
]);
const validationKeys = new Set([
@@ -0,0 +1,66 @@
// 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.
//
// Fixture: real engine consultation responses for three public AA charts
// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4);
// expected values are read straight from the raw engine payload.
import assert from "node:assert/strict";
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 { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
consultationWorkflowResponseSchema,
toAgentConsultationContext,
toModelOutput,
} from "../src/mastra/consultation-workflow.ts";
type Json = Record<string, unknown>;
const golden = JSON.parse(readFileSync(
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
"utf8",
)) as { charts: Array<{ id: string; workflow: Json; annual_workflow: Json; timing_workflow: Json }> };
function card(workflow: Json, domain: ConsultationDomain) {
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow)));
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain);
return buildEvidenceCard([{ domain, packet, context }]);
}
function engineRath(workflow: Json): Json {
return ((((workflow.chart as Json).modules as Json).narayana_dasha as Json).rath ?? {}) as Json;
}
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);
}
}
});
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");
}
}
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -56,8 +56,9 @@ def cmd_narayana_dasha(args, chart_data):
current_age=current_age,
birth_year=birth_year,
)
from narayana_legacy_label import label_legacy_narayana # BUG-1215
label_legacy_narayana(result)
from narayana_legacy_label import label_legacy_narayana # BUG-1215; Rath view BUG-1218
label_legacy_narayana(result, lagna_sign_idx=asc_idx, planet_lons=planet_lons,
current_age=current_age, birth_year=birth_year)
# 格式化输出
lines = [
@@ -98,4 +99,20 @@ def cmd_narayana_dasha(args, chart_data):
for line in result.get('interpretation', []):
lines.append(f" {line}")
rath = result.get('rath') or {}
if rath:
lines.append("")
lines.append(f"── {rath.get('algorithm_label')} ──")
if rath.get('seed_sign'):
lines.append(f" 起运宫: {rath['seed_sign']}(第 {rath.get('strength_level_decided')} 级:{rath.get('strength_level_text')});{rath.get('direction_text')}")
if rath.get('method_note'):
lines.append(f" {rath['method_note']}")
for p in rath.get('mahadasha', [])[:12]:
lines.append(f" {p['sign']:>12s} ({p['lord']:>7s}): {p['years']:2d}年 [{p['start_age']:5.1f}→{p['end_age']:5.1f}]")
rmd = (rath.get('current_dasha') or {}).get('md')
if rmd:
lines.append(f" 当前大运: {rmd['sign']}({rmd['lord']}){rmd['years']}年")
else:
lines.append(f" 起运宫未定({rath.get('reason') or rath.get('status')})")
return "\n".join(lines)
+1 -1
View File
@@ -1623,7 +1623,7 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
)
if isinstance(narayana_result, dict):
# BUG-1215: legacy algorithm, labelled until the Rath profile replaces it.
modules['narayana_dasha'] = _load_local_module('narayana_legacy_label').label_legacy_narayana(narayana_result)
modules['narayana_dasha'] = _load_local_module('narayana_legacy_label').label_legacy_narayana(narayana_result, lagna_sign_idx=asc_sign_idx, planet_lons=planet_lons, current_age=_consultation_current_age(birth_payload, body), birth_year=int(birth_payload.get('year', 0) or 0))
except Exception as exc:
diagnostics.append({'layer': 'narayana_dasha', 'status': 'unavailable', 'reason': exc.__class__.__name__})
+4 -2
View File
@@ -65,7 +65,7 @@ except ModuleNotFoundError: # pragma: no cover - minimal environments
lines.extend(" | ".join(str(item) for item in row) for row in rows)
return "\n".join(lines)
from life_stage_hook import generate_life_stage_hooks
from narayana_legacy_label import LEGACY_NOTE_ZH as LEGACY_NARAYANA_NOTE_ZH # BUG-1215
from narayana_legacy_label import LEGACY_NOTE_ZH as LEGACY_NARAYANA_NOTE_ZH, rath_report_lines_zh # BUG-1215 / BUG-1218
from capability_evidence_pool import build_capability_evidence_pool_summary
from guided_topic_discovery import build_guided_topics
@@ -9449,6 +9449,7 @@ def render_pl9_markdown(packet: dict) -> str:
'本地年龄轴与 PL9 日期边界/起运序列的字段级 parity 尚未闭环,因此不替代主时间判断。',
'',
f'- 算法:{LEGACY_NARAYANA_NOTE_ZH}', # BUG-1215
*rath_report_lines_zh(((worksheets.get('timing_and_predictive_systems') or {}).get('narayana_dasha') or {}).get('rath')), # BUG-1218
'',
'#### Patyayini 年内节奏支持',
'',
@@ -16143,7 +16144,8 @@ def cmd_full_reading(args):
birth_year=args.year,
)
from narayana_legacy_label import label_legacy_narayana # BUG-1215
report['modules']['narayana_dasha'] = label_legacy_narayana(narayana_result)
report['modules']['narayana_dasha'] = label_legacy_narayana( # + Rath view, display only (BUG-1218)
narayana_result, lagna_sign_idx=asc_idx, planet_lons=planet_lons, current_age=current_age, birth_year=args.year)
except Exception as e:
report['errors'].append(f"narayana-dasha: {e}")
+156 -3
View File
@@ -8,11 +8,16 @@ order by the start sign); until the product switches (product decision after
the trial), every user-visible Narayana carries this label and the data carry
`profile: legacy_reference_only`. Kept outside `narayana_dasha.py`, which is in
the frozen rectification scoring identity (ERR-110 / ERR-115).
BUG-1218 (T4, product owner 2026-10-03): when the caller hands the chart, the
Sanjay Rath profile (`narayana_rath`, round-5 rules) rides along under `rath`
for display only. Scoring and timeline conclusions keep reading the legacy
fields; nothing here changes them.
"""
from __future__ import annotations
from typing import Any, Dict
from typing import Any, Dict, List, Optional
LEGACY_PROFILE = "legacy_reference_only"
LEGACY_NOTE_ZH = "旧算法(从上升起、一律顺行),待替换为 Sanjay Rath 版"
@@ -21,11 +26,159 @@ LEGACY_NOTE_EN = "Legacy algorithm (starts from the Lagna, always zodiacal); to
LEGACY_LABEL_ZH = "旧算法(从上升起、一律顺行)"
def label_legacy_narayana(result: Any) -> Any:
"""Add the legacy profile and note to a `narayana_dasha_full_report` result."""
RATH_LABEL_ZH = "Sanjay Rath 版(并列参考,未进打分与时间轴结论)"
RATH_LABEL_EN = "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions)"
STRENGTH_LEVEL_ZH = {
1: "占据星数", 2: "木星 / 水星 / 宫主三角色", 3: "占据星尊贵", 4: "星座性质",
5: "宫主星座内度数", 6: "宫主落奇偶相反星座", 7: "大运年数",
}
STRENGTH_LEVEL_EN = {
1: "occupants", 2: "Jupiter / Mercury / lord roles", 3: "occupant dignity", 4: "modality",
5: "lord's degree in sign", 6: "lord in a sign of opposite parity", 7: "dasha years",
}
DIRECTION_ZH = {
"table10_general": "按 Table 10", "table11_saturn_zodiacal": "土星在起运宫:顺行(Table 11)",
"table12_ketu_reversed": "计都在起运宫:反向(Table 12)",
}
DIRECTION_EN = {
"table10_general": "Table 10 order", "table11_saturn_zodiacal": "Saturn in the start sign: zodiacal (Table 11)",
"table12_ketu_reversed": "Ketu in the start sign: reversed (Table 12)",
}
def _period(row: Dict[str, Any], birth_year: Optional[int]) -> Dict[str, Any]:
out = {key: row.get(key) for key in ("sign", "lord", "years")}
out["start_age"] = round(float(row["start_age"]), 2)
out["end_age"] = round(float(row["end_age"]), 2)
if isinstance(out["years"], float):
out["years"] = round(out["years"], 2)
if birth_year:
out["start_year"] = round(birth_year + float(row["start_age"]), 2)
out["end_year"] = round(birth_year + float(row["end_age"]), 2)
return out
def _running(rows: List[Dict[str, Any]], age: Optional[float]) -> Optional[Dict[str, Any]]:
if age is None:
return None
return next((row for row in rows if float(row["start_age"]) <= age < float(row["end_age"])), None)
def rath_narayana_view(lagna_sign_idx: int, planet_lons: Dict[str, float], *,
current_age: Optional[float] = None, birth_year: Optional[int] = None) -> Dict[str, Any]:
"""Display-only Sanjay Rath Narayana: seed, basis, MDs, running MD / AD."""
try:
from narayana_rath import antardasha, calc_narayana_rath
except ImportError: # pragma: no cover - package import
from scripts.narayana_rath import antardasha, calc_narayana_rath
result = calc_narayana_rath(int(lagna_sign_idx) % 12, planet_lons, cycle_count=2)
view: Dict[str, Any] = {
"profile": result["profile"],
"algorithm_label": RATH_LABEL_ZH,
"algorithm_label_en": RATH_LABEL_EN,
"status": result["status"],
"used_for_scoring": False,
"source": result["source"],
"dual_lord_rule": result["dual_lord_rule"],
"antardasha_start_rule": result["antardasha_start_rule"],
}
if not result.get("mahadasha"):
view.update({"reason": result.get("reason"),
"alternative_seed_signs": sorted((result.get("alternatives") or {}).keys())})
return view
level = result.get("strength_level_decided")
view.update({
"seed_sign": result["seed_sign"],
"seed_basis": result["seed_basis"],
"strength_level_decided": level,
"strength_level_text": STRENGTH_LEVEL_ZH.get(level),
"strength_level_text_en": STRENGTH_LEVEL_EN.get(level),
"direction": result["direction"],
"direction_text": DIRECTION_ZH.get(result["direction"]),
"direction_text_en": DIRECTION_EN.get(result["direction"]),
"exception_applied": result.get("exception_applied"),
"method_note": result.get("method_note"),
"method_note_en": result.get("method_note_en"),
"mahadasha": [_period(row, birth_year) for row in result["mahadasha"]],
})
md = _running(result["mahadasha"], current_age)
if md is not None:
ad = antardasha(md, planet_lons)
current: Dict[str, Any] = {"md": _period(md, birth_year)}
rows = ad.get("rows") or []
running_ad = _running(rows, current_age)
if running_ad is not None:
current["ad"] = _period(running_ad, birth_year)
view["current_dasha"] = current
view["current_md_antardasha"] = [_period(row, birth_year) for row in rows]
view["current_md_antardasha_rule"] = ad.get("antardasha_start_rule")
return view
def label_legacy_narayana(result: Any, *, lagna_sign_idx: Optional[int] = None,
planet_lons: Optional[Dict[str, float]] = None,
current_age: Optional[float] = None,
birth_year: Optional[int] = None) -> Any:
"""Add the legacy profile and note to a `narayana_dasha_full_report` result.
With the chart (`lagna_sign_idx`, `planet_lons`) the Rath view is attached
under `rath` (display only, BUG-1218); a failure there never touches the
legacy result.
"""
if isinstance(result, dict):
result["profile"] = LEGACY_PROFILE
result["algorithm_note"] = LEGACY_NOTE_ZH
result["algorithm_note_en"] = LEGACY_NOTE_EN
result["algorithm_label"] = LEGACY_LABEL_ZH
if lagna_sign_idx is not None and planet_lons:
try:
result["rath"] = rath_narayana_view(lagna_sign_idx, planet_lons,
current_age=current_age, birth_year=birth_year)
except Exception as exc: # display layer only
result["rath"] = {"status": "blocked", "reason": exc.__class__.__name__,
"algorithm_label": RATH_LABEL_ZH, "algorithm_label_en": RATH_LABEL_EN}
return result
def rath_report_lines_zh(rath: Any) -> List[str]:
"""Reference-report block for the Sanjay Rath Narayana beside the legacy
line (BUG-1218, display only): summary, 24 MD rows, running MD's ADs."""
summary = rath_summary_line_zh(rath)
if summary is None:
return []
lines = [summary]
if not rath.get("mahadasha"):
return lines
if rath.get("direction_text"):
lines.append(f"- 顺序:{rath['direction_text']}" + (f";{rath['method_note']}" if rath.get("method_note") else ""))
lines.extend(["", "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |", "|---------|------|------|----------|----------|------|"])
for index, row in enumerate(rath["mahadasha"], start=1):
lines.append(f"| {index} | {row.get('sign')} | {row.get('lord')} | {row.get('start_age')} | {row.get('end_age')} | {row.get('years')} |")
md = (rath.get("current_dasha") or {}).get("md")
rule = rath.get("current_md_antardasha_rule") or {}
if md and rath.get("current_md_antardasha"):
compared = rule.get("compared") or ["-", "-"]
lines.extend([
"",
f"- 当前大运 {md.get('sign')} 的子运:比较 {compared[0]} 与 {compared[-1]},较强 {rule.get('stronger_sign')}"
f"(第 {rule.get('strength_level_decided')} 级),从宫主 {rule.get('lord')} 所在 {rule.get('start_sign')} 起,12 等分",
"",
"| Rath AD | 星座 | 起始年龄 | 结束年龄 |",
"|---------|------|----------|----------|",
])
for index, row in enumerate(rath["current_md_antardasha"], start=1):
lines.append(f"| {index} | {row.get('sign')} | {row.get('start_age')} | {row.get('end_age')} |")
return lines
def rath_summary_line_zh(rath: Any) -> Optional[str]:
"""One report line naming the Rath start sign and running MD (BUG-1218)."""
if not isinstance(rath, dict) or not rath:
return None
if not rath.get("seed_sign"):
return f"- 并列:{RATH_LABEL_ZH}:起运宫未定,{rath.get('reason') or rath.get('status')}"
md = (rath.get("current_dasha") or {}).get("md") or {}
running = f";当前大运 {md.get('sign')},宫主 {md.get('lord')}" if md else ""
return (f"- 并列:{RATH_LABEL_ZH}:起运宫 {rath.get('seed_sign')}"
f"(第 {rath.get('strength_level_decided')} 级:{rath.get('strength_level_text')}){running}")
+30
View File
@@ -150,6 +150,31 @@ _PHRASES: dict[str, str] = {
# BUG-1215: narayana_legacy_label.LEGACY_NOTE_ZH
"旧算法(从上升起、一律顺行),待替换为 Sanjay Rath 版": "Legacy algorithm (starts from the Lagna, always zodiacal); to be replaced by Sanjay Rath's version",
"- 算法:": "- Algorithm: ",
# BUG-1218: narayana_legacy_label rath_report_lines_zh
"Sanjay Rath 版(并列参考,未进打分与时间轴结论):": "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions): ",
"Sanjay Rath 版(并列参考,未进打分与时间轴结论)": "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions)",
",宫主 ": ", lord ",
"起运宫未定,": "start sign undecided, ",
"- 并列:": "- Alongside: ",
"- 顺序:": "- Order: ",
"起运宫 ": "start sign ",
";当前大运 ": "; running MD ",
"- 当前大运 ": "- Running MD ",
"按 Table 10": "Table 10 order",
"土星在起运宫:顺行(Table 11)": "Saturn in the start sign: zodiacal (Table 11)",
"计都在起运宫:反向(Table 12)": "Ketu in the start sign: reversed (Table 12)",
"PyJHora 兼容:计都优先": "PyJHora-compatible: Ketu takes precedence",
"占据星数": "occupants",
"木星 / 水星 / 宫主三角色": "Jupiter / Mercury / lord roles",
"占据星尊贵": "occupant dignity",
"星座性质": "modality",
"宫主星座内度数": "lord's degree in sign",
"宫主落奇偶相反星座": "lord in a sign of opposite parity",
"大运年数": "dasha years",
"| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |": "| Rath MD | Sign | Lord | Start age | End age | Years |",
"| Rath AD | 星座 | 起始年龄 | 结束年龄 |": "| Rath AD | Sign | Start age | End age |",
" 的子运:比较 ": " sub-periods: compare ",
",12 等分": ", twelve equal parts",
# BUG-1214: tajika_year_lord.selection_basis_text (zh) → its en twin.
"年盘不全,暂用 Muntha 主星": "Annual chart incomplete; Muntha sign lord used for now",
"(Panchavargiya 为本地近似计算)": " (Panchavargiya is a local approximation)",
@@ -240,6 +265,11 @@ REGEX_EN: list[tuple[re.Pattern[str], str]] = [
f"the strongest by Panchavargiya, {m.group(2)}, is the year lord")),
(re.compile(r"五类候选都不照年盘上升,取全体 Panchavargiya 最强的([A-Za-z]+)"),
r"None of the five candidates aspects the annual Lagna; the strongest of all by Panchavargiya, \1, is the year lord"),
# BUG-1218 Rath Narayana report lines
(re.compile(r"(第 (\d) 级:([^)]+))"), r" (level \1: \2)"),
(re.compile(r"(第 (\d) 级)"), r" (level \1)"),
(re.compile(r" 与 ([^,]+),较强 ([^(]+)"), r" and \1; stronger \2"),
(re.compile(r",从宫主 (\S+) 所在 (\S+) 起"), r", from the sign of its lord \1, \2"),
# scripts/pl9_reader_export.py: f"第{house}宫主:{lord}"
(re.compile(r"第(\d+)宫主:"), r"Lord of house \1: "),
# scripts/aspects.py / jyotish_engine.py: f"{n}宫相位"
@@ -60,8 +60,9 @@ MODULE_KEEP = (
"functional_benefic_malefic", "kakshya", "yogas", "chara_dasha", "transits",
)
# BUG-1215: + profile / algorithm_note / algorithm_label (the legacy-algorithm label the card carries).
# BUG-1218: + rath (the Sanjay Rath view the card shows beside the legacy one).
NARAYANA_KEEP = ("lagna_sign", "current_dasha", "current_year", "current_age", "mahadasha_sequence",
"profile", "algorithm_note", "algorithm_label")
"profile", "algorithm_note", "algorithm_label", "rath")
def _pick(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
+53
View File
@@ -61,3 +61,56 @@ def test_kn_rao_never_names_narayana_on_user_visible_surfaces() -> None:
ROOT / "scripts/narayana_dasha.py", ROOT / "scripts/cmd_narayana_dasha.py"]
hits = [path.name for path in surfaces if pattern.search(path.read_text(encoding="utf-8"))]
assert hits == []
# ── BUG-1218 (T4): Sanjay Rath version shown beside the legacy one ──
def test_full_reading_carries_the_rath_view_and_leaves_the_legacy_result_alone() -> None:
from scripts.jyotish_engine import cmd_full_reading
from scripts.narayana_dasha import narayana_dasha_full_report
from scripts.narayana_legacy_label import RATH_LABEL_ZH
reading = cmd_full_reading(type("Args", (), dict(CASES["day"]))())
module = reading["modules"]["narayana_dasha"]
rath = module["rath"]
assert rath["algorithm_label"] == RATH_LABEL_ZH and rath["used_for_scoring"] is False
assert rath["profile"] == "sanjay_rath" and rath["seed_sign"] and rath["strength_level_decided"] in range(1, 8)
assert len(rath["mahadasha"]) == 24 and len(rath["current_md_antardasha"]) == 12
# Attaching the Rath view leaves every legacy field exactly as it was.
from scripts.narayana_legacy_label import label_legacy_narayana
lons = {"Sun": 10.0, "Moon": 100.0, "Mars": 200.0, "Mercury": 20.0, "Jupiter": 250.0,
"Venus": 340.0, "Saturn": 300.0, "Rahu": 50.0, "Ketu": 230.0} # fictional
base = narayana_dasha_full_report(lagna_sign_idx=3, planet_lons=lons, current_age=40.0, birth_year=1980)
with_rath = label_legacy_narayana(copy.deepcopy(base), lagna_sign_idx=3, planet_lons=lons, current_age=40.0, birth_year=1980)
without = label_legacy_narayana(copy.deepcopy(base))
assert "rath" not in without
assert {key: value for key, value in with_rath.items() if key != "rath"} == without
def test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines() -> None:
from scripts.narayana_legacy_label import rath_report_lines_zh
from scripts.pl9_reader_english_terms import PHRASES_EN, REGEX_EN
packet = _packet(CASES["day"])
zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reference")
legacy = zh.index(f"- 算法:{LEGACY_NOTE_ZH}")
rath = zh.index("- 并列:Sanjay Rath 版(并列参考,未进打分与时间轴结论):起运宫 ")
assert legacy < rath
assert "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |" in zh and "| Rath AD | 星座 | 起始年龄 | 结束年龄 |" in zh
view = packet["worksheets"]["timing_and_predictive_systems"]["narayana_dasha"]["rath"]
for line in rath_report_lines_zh(view):
text = line
for pattern, target in REGEX_EN:
text = pattern.sub(target, text)
for source, target in PHRASES_EN:
text = text.replace(source, target)
assert not re.search(r"[一-鿿]", text), text
def test_consultation_modules_carry_the_rath_view() -> None:
golden = json.loads((ROOT / "frontend/tests/fixtures/consult-evidence-card-golden.json").read_text(encoding="utf-8"))
for chart in golden["charts"]:
for route in ("workflow", "annual_workflow", "timing_workflow"):
module = chart[route]["chart"]["modules"]["narayana_dasha"]
assert module["profile"] == LEGACY_PROFILE
assert module["rath"]["used_for_scoring"] is False and module["rath"]["current_dasha"]["md"]["sign"]
+23 -2
View File
@@ -222,8 +222,29 @@ def test_table25_sub_periods_24_of_36() -> None:
assert (gemini["stronger_sign"], gemini["strength_level_decided"], gemini["start_sign"]) == ("Sagittarius", 5, "Cancer")
def test_rath_profile_is_not_wired_into_any_product_path() -> None:
def test_rath_profile_is_display_only() -> None:
"""原值:没有任何脚本导入 narayana_rath(`users == []`)
新值:只有展示层 narayana_legacy_label 导入它;冻结的打分文件与 rectification/ 都不导入展示层或 Rath 模块
原因:BUG-1218 T4(产品负责人 2026-10-03 晚恢复 T4):Rath 版与旧算法并列显示,打分与时间轴结论仍用旧算法"""
pattern = re.compile(r"\bnarayana_rath\b")
users = sorted(path.name for path in (ROOT / "scripts").rglob("*.py")
if path.name != "narayana_rath.py" and pattern.search(path.read_text(encoding="utf-8")))
assert users == []
assert users == ["narayana_legacy_label.py"]
frozen = json.loads((ROOT / "references/rectification_sealed_holdout.v1.json").read_text(encoding="utf-8"))
scoring = set(_production_files(frozen)) | {str(p.relative_to(ROOT)) for p in (ROOT / "scripts/rectification").rglob("*.py")}
display = re.compile(r"\b(narayana_rath|narayana_legacy_label)\b")
assert sorted(path for path in scoring if display.search((ROOT / path).read_text(encoding="utf-8"))) == []
def _production_files(record) -> list:
found = []
if isinstance(record, dict):
for key, value in record.items():
if key == "production_scoring_files" and isinstance(value, list):
found.extend(value)
else:
found.extend(_production_files(value))
elif isinstance(record, list):
for value in record:
found.extend(_production_files(value))
return found