feat(narayana-rath): follow book p.43 for dual lords and node years (BUG-1227~1229)
Independent Staging Quality Gate / validate (push) Failing after 16m17s
Independent Staging Quality Gate / publish (push) Skipped

Round-7 option A is text-first, so Scorpio and Aquarius lords, the Ketu-only degree reversal, and node exaltation years now live in the Rath display layer. Scoring files stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-10-04 18:37:34 +08:00
co-authored by Cursor
parent 64c652eea2
commit baf4ae8b34
15 changed files with 604 additions and 84 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-04 — 占星师口径第六批:Rath 版双主星改按原书 p.43 正文(未上线)
- Sanjay Rath 版 Narayana(并列参考,不进打分):天蝎、水瓶的双主星改按原书第 43 页正文选。两主分落别的星座时,用本模块 7 级比较它们所在的星座;火星在白羊、土星在摩羯不再被当成「已在本宫」。同宫时比宫内度数,只把计都倒过来算,度数完全一样就标未决,不暗自取列表里的第一颗(BUG-1227)。
- 比强弱的第 5 级也改为只倒算计都。罗睺和七颗实体星用原始宫内度数(BUG-1228)。
- 罗睺、计都当宫主时,入旺加 1 年、落陷减 1 年(罗睺旺双子、陷射手,计都相反),年数仍限制在 0 到 12。报告大运表分开写基本年数、修正、最终年数。标准盘印出来的年数与这套正文不一致的地方逐条留在进度记录里,不再为了凑表改通则(BUG-1229)。
- 咨询卡在当前大运是天蝎或水瓶时,旁边加一句双主星依据。版本标注「按原书 p.43 正文(第七轮裁定)」。
- 生时校正:打分用到的文件没有改动。Skill 版本不变;不改数据库结构;不改对话提示词。
## 2026-10-04 — 占星师口径第五批:年主选不出时不再用 Muntha 主星顶替;Rath 版 Narayana 计罗计尊贵、标出子运方向分歧与原书软件特例(未上线)
- 年运:年盘不全、年主选不出时,以前会拿 Muntha 主星顶上(「年盘不全,暂用 Muntha 主星」),现在不再给伪年主,数据卡和报告年运段写「年主暂无法确定(原因)」,Tri-Pataka 随之不算;Mudda 月份分段不受影响。正常年盘不变:9 位公开名人 2026 年年主、Tri-Pataka、Mudda 改前改后完全相同(BUG-1223)。
+38 -6
View File
@@ -16567,16 +16567,48 @@
## BUG-1227 | Rath 版双主星(天蝎、水瓶)没按原书 p.43 (a)–(e) 选
- 状态:blocked(任务书红线 2:照裁定实现会让 Table 17 年数低于现状,按约定停下;分支未改 `_lord`)
- 状态:resolved(第七轮裁定问 1 选 A,推翻第五批红线 2 的 Table 17 年数底线;分支 `codex/astrologer-rulings-batch6-20261004`)
- 首次发现 / 最近更新:2026-10-03 / 2026-10-04
- 来源:第三批 BUG-1218 验收(软件表双主星 8/10);占星师第六轮裁定问 6a(p.43 (d)、p.116);任务书 T1。
- 影响面:`scripts/narayana_rath._lord`(仍调冻结 `narayana_dasha._resolve_narayana_lord(…, "jaimini_dual_lord_source_v1")`)→ 天蝎 / 水瓶大运年数、第 2 级宫主角色、第 5–7 级、子运起点。
- 影响面:`scripts/narayana_rath.resolve_dual_lord`(第五批仍调冻结的 `jaimini_dual_lord_source_v1`)→ 天蝎 / 水瓶大运年数、第 2 级宫主角色、第 5–7 级、子运起点。展示层 `narayana_legacy_label`。不进打分。
- 现象:原书 5 张软件表的双主星对上 8/10:Table 23 水瓶原书土星、我们罗睺;Table 25 天蝎原书计都、我们火星。
- 原书原文(PDF Page 40 / 41,书页 p.43;BPHS 46.158–163):(a) 两主都在本宫 → 12 年;(b) 两主同在另一宫 → 从本宫数到该宫;(c) 一主在本宫、另一主在别处 → 取在别处的那颗;(d) 两主分落别的星座 → 比两主所落星座的力量(脚注 33 指第 1 章力量来源),强者给年数;(e) 两宫力量相等 → 取给出年数较多的那颗(原书例:计都射手、火星处女、都无同宫 → 取火星 10 年)。p.116:两主同宫时经度高者强。
- 试算(研究,未落地;实现见研究分支 `codex/narayana-rath-rectification-trial-20261004` 的 `--dual-lord p43`):(a)–(e) 按上文,(d) 用本模块 7 级 `compare_signs`(叠加 BUG-1224 罗计尊贵)。软件表双主星 **9/10**(Table 23 水瓶改对,土星:摩羯 2 角色 > 双子 1 角色,第 2 级;Table 25 天蝎改对,计都:双鱼计都 Moolatrikona,第 3 级),新错 Table 23 天蝎:处女(火星)对射手(计都),射手得木星两角色、第 2 级判计都,原书判火星。**Table 17 年数 12/12 → 10/12**:同一张标准盘的原书 Table 17 / Chart 3 天蝎取火星 10 年、水瓶取罗睺 8 年,(d) 给计都 1 年、土星 1 年。强弱 28/30、Table 25 子运 24/36 不变。
- 根因(原书内部不一致):标准盘(Table 17 / 23 同一出生资料)三处说法互相冲突——p.43 (e) 的例句与 Chart 3 把「计都射手、火星处女」当作力量相等(取火星 10 年),但按第 2 级(第二来源)射手得木星两角色、按 BUG-1224 计都在射手入旺,任何逐级比较都判计都;Chart 3 水瓶取罗睺 8 年(注「土星在本宫摩羯 Rule 5(c)」,即把土星落自己另一宫当作 (c) 的「在本宫」),而同书 Table 23 软件判定土星强。没有一条通则能同时满足 Table 17 年数与 Table 23 双主星。占星师答复里「Table 23 水瓶:金牛 3 星 > 处女 1 星」的推算对应的是 Table 25(Wadiyar)的水瓶(土星金牛、罗睺处女),Table 23 本盘是土星摩羯、罗睺双子。
- 处理:按任务书红线 2「任一项低于现状就停下」不落地;`_lord` 不变(双主星仍 8/10),Table 17 年数保持 12/12。等产品 / 占星师决定:(1) Table 17 / Chart 3 与 Table 23 以哪处为准;(2) (d) 的「力量」是否只比第 1 级(星数),平了即走 (e)——这一读法 Table 17 年数全对,但 Table 24 天蝎、Table 25 天蝎会错(7/10)。
- 验证:试算脚本与结果见进度记录 T1 节;`tests/test_narayana_rath_book_tables.py` 仍钉住 8/10 与 12/12。
- 相关记录:BUG-1218、BUG-1224。
- 处理(第五批,已推翻):当时按红线 2 停下,`_lord` 仍走冻结的 `jaimini_dual_lord_source_v1`(双主星 8/10,Table 17 年数 12/12)。
- 修复:第七轮书面回复经产品负责人采用,问 1 选 A(正文优先,不倒改通则去凑 Table 17)。`narayana_rath.resolve_dual_lord` 在本模块实现 p.43 (a)–(e):(d) 用本模块 7 级 `compare_signs`;(c) 只认天蝎 / 水瓶本身;(a)(b) 同宫身份按 p.116,只倒算计都,经度全同返回未决;比较途中再遇到同一双主星则返回未决。不改冻结的 `_resolve_narayana_lord`。软件表双主星 9/10,唯一不符是 Table 23 天蝎(书火星、第 2 级计都)。Table 17 年数 10/12(天蝎计都 2 = 基本 1 加旺 1,水瓶土星 1),不再作通过线。
- 验证:`tests/test_narayana_rath_book_tables.py`、`tests/test_narayana_rath_rules.py`(火星白羊 / 土星摩羯不触发 (c);同宫经度;计都 0° 比较值为 30;全同未决;遇环未决)。
- 防复发:上述测试钉住 9/10 与 Table 23 天蝎这一条不符;年数断言钉新值,不钉书印。
- 相关记录:BUG-1218、BUG-1224、BUG-1228、BUG-1229。
- 复发自:无
- 修复版本:—(blocked)
- 修复版本:待发布
## BUG-1228 | Rath 版第 5 级把罗睺度数也倒算
- 状态:resolved(分支 `codex/astrologer-rulings-batch6-20261004`)
- 首次发现 / 最近更新:2026-10-04 / 2026-10-04
- 来源:第七轮裁定问 3(p.71 正文与脚注 42、p.116 Rule 2)。任务书 T2。
- 影响面:`scripts/narayana_rath._strength_degree`(第 5 级宫主度数,以及同宫双主星比经度)。Chara Karaka 八星排序不改。
- 现象:强弱比较里罗睺、计都都用 30 − 宫内度数。原书脚注 42 写明强弱计算只倒算计都。
- 根因:第 5 级沿用了 Chara Karaka 对罗睺的倒算。
- 修复:实体星与罗睺用原始宫内度数,逆行也不倒算;只有计都用 30 − 宫内度数。计都在 0° 时比较值为 30,不再取模成 0。完全相等返回未决。
- 验证:原书软件表 30 对的胜者与改前相同,仍是 28/30(不符仍是 Wadiyar 两对,第 5 级)。新增同宫经度测试。
- 防复发:`tests/test_narayana_rath_rules.py` 的经度用例。
- 相关记录:BUG-1227。
- 复发自:无
- 修复版本:待发布
## BUG-1229 | Rath 版罗计为宫主时,入旺不加算、落陷不减年
- 状态:resolved(分支 `codex/astrologer-rulings-batch6-20261004`)
- 首次发现 / 最近更新:2026-10-04 / 2026-10-04
- 来源:第七轮裁定对 p.40 (a) 的推论(产品负责人采用问 1 的「正文优先」)。原书 Chart 3 水瓶罗睺 8 + 1 = 9 年。任务书 T3。
- 影响面:`scripts/narayana_rath.rath_period_years`(大运年数与强弱第 7 级)。冻结的 `calculate_narayana_period_years` 不改,七颗实体星仍走它。
- 现象:年数旺陷表只有七颗实体星。罗睺在双子、计都在射手不加 1 年;罗睺在射手、计都在双子不减 1 年。
- 根因:Phalita 旺陷没有接到 Rath 版年数上。
- 修复:基本年数仍用奇偶足计数。罗计按 Phalita 入旺 +1、落陷 −1(罗睺旺双子 / 陷射手,计都旺射手 / 陷双子),再截在 0–12。输出分栏 `lord`、`base_years`、`dignity_adjustment`、`years`、`adjustment_source`。
- 验证:Table 17 天蝎计都基本 1、修正 +1、最终 2;Table 18 水瓶罗睺基本 2、修正 −1、最终 1。Chart 3 若取罗睺,水瓶为基本 8、修正 +1、最终 9,与书注一致;本单按 A 取的是土星,最终 1,差异记在进度记录。
- 防复发:`tests/test_narayana_rath_rules.py::test_node_dignity_adjusts_the_period_and_clamps`。
- 相关记录:BUG-1224、BUG-1227。
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,97 @@
# PROGRESS:占星师第七轮落地(p.43 双主星、只倒算计都、罗计旺陷加减年)— 2026-10-04
- 任务书:`docs/tasks/TASK-astrologer-rulings-batch6-20261004.md`(`64c652ee`)。
- 分支:`codex/astrologer-rulings-batch6-20261004`,工作树 `.worktrees/astrologer-rulings-batch6-20261004`。
- 开工基线:`origin/staging` = `64c652ee`。预检 `scripts/pre_work_check.py` 为 `pass`,远端 `verified`。磁盘 `/` 可用约 343 GiB。
- BUG:开工时最大号 BUG-1227。本单 BUG-1227 改 resolved,新增 BUG-1228(T2)、BUG-1229(T3)。
- 研究分支 `86e55514` 不在本仓对象库,p.43 按任务书与第七轮回复在 `narayana_rath.py` 重写,没有搬研究开关。
- 冻结文件未改:`scripts/narayana_dasha.py`、`references/rectification_sealed_holdout.v1.json`。Rath 仍只被 `narayana_legacy_label.py` 导入。
## 我替任务书确认的实现
1. (a)(b) 的 `rule` 记年数条款,同宫身份另记 `identity_rule: p116_longitude`。经度全同则 `status: undecided`,不按列表先后取星。
2. (d) 的比较若因循环变成未决,不改走 (e)。
3. (a) 两主都在天蝎或水瓶时,年数固定 12(四颗星在该宫的旺陷修正都是 0),身份仍可比经度。
4. 第 2 周期仍是 12 减第 1 周期,基本年数和修正留空,只保留最终年数。
## T2 第 5 级只倒算计都(BUG-1228)
`_strength_degree`:实体星与罗睺用宫内度数,计都用 30 − 宫内度数,0° 计都的比较值是 30。原书软件表 30 对的胜者与改前相同,不符仍是 Wadiyar 双子/射手、处女/双鱼,都在第 5 级。没有因本条改判的盘。
## T3 罗计旺陷 ±1(BUG-1229)
`rath_period_years`。实体星沿用冻结函数。罗计按 Phalita:罗睺旺双子 / 陷射手,计都旺射手 / 陷双子。截在 0–12。大运和第 7 级都走这层。
显式起运宫后的年数(顺序都是 12/12):
| 表 | 星座 | 书印 | 新值 | 基本 | 修正 | 来源 |
| --- | --- | --- | --- | --- | --- | --- |
| Table 15 | 水瓶、天蝎 | 2、7 | 2、7 | | | 与书印相同 |
| Table 17 | 天蝎 | 10(火星;Chart 3 也是 10) | 2 | 1 | +1 | 双主星 A 取计都,计都在射手入旺 |
| Table 17 | 水瓶 | 8(Table 17);9(Chart 3:罗睺 8+1) | 1 | 1 | 0 | 双主星 A 取土星,不取罗睺。Chart 3 的 9 年按罗睺仍可算出(基本 8、修正 +1),本单不采用 |
| Table 18 | 水瓶 | 2 | 1 | 2 | −1 | 主星仍是罗睺,罗睺落陷减 1。其余 11 段与书印相同 |
| Table 18 | 天蝎 | 9 | 9 | | | 与书印相同 |
其余样表行与书印相同的不逐行列。年数不再作通过线。
## T1 双主星(BUG-1227,解除 blocked)
软件表 10 条都由 (d) 判出:
| 表 | 宫 | 书 | 我们 | 级 | 两主所在 |
| --- | --- | --- | --- | --- | --- |
| Table 22 | 水瓶 | 土星 | 土星 | 1 | 摩羯 / 巨蟹 |
| Table 22 | 天蝎 | 火星 | 火星 | 1 | 水瓶 / 摩羯 |
| Table 23 | 水瓶 | 土星 | 土星 | 2 | 摩羯 / 双子 |
| Table 23 | 天蝎 | 火星 | 计都 | 2 | 处女 / 射手 |
| Table 24 | 水瓶 | 土星 | 土星 | 2 | 天秤 / 射手 |
| Table 24 | 天蝎 | 火星 | 火星 | 2 | 水瓶 / 双子 |
| Table 25 | 水瓶 | 土星 | 土星 | 1 | 金牛 / 处女 |
| Table 25 | 天蝎 | 计都 | 计都 | 3 | 狮子 / 双鱼 |
| Table 27 | 水瓶 | 罗睺 | 罗睺 | 1 | 巨蟹 / 射手 |
| Table 27 | 天蝎 | 火星 | 火星 | 3 | 狮子 / 双子 |
9/10。唯一不符:Table 23 天蝎,书火星,第 2 级计都(射手得木星两个角色)。这是选 A 的已知代价。
底线:Table 15、17 通则起运宫仍自动复现(巨蟹第 1 级、双鱼第 2 级)。Table 18 显式巨蟹后顺序 12/12,通则仍是摩羯第 5 级。强弱 28/30。Table 25 子运仍是 24/36(巨蟹 12、双子 0、金牛 12)。
## T4 展示
- 标签改为「Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定))」,英文对照在 `pl9_reader_english_terms.py`。
- 参考版报告大运表改为基本年数 / 修正 / 最终。天蝎、水瓶各写一句双主星依据。
- 咨询卡只在当前大运是天蝎或水瓶时带这一句。字数测试仍不超过 12,500(加方法清单的年运、时运不超过 18,500)。
- 12 张公开盘的 Rath 段用真实引擎按盘内经度重算后写入 golden。乔布斯当前大运由天秤 70–72 岁改为水瓶 71–72 岁(前面天蝎少了 1 年,年龄边界前移)。嘉兰水瓶主星由土星改为罗睺,年数仍是 5 / 7。齐达内天蝎由火星 7 年改为计都 8 年(基本 7,入旺 +1)。梦露水瓶由 8 年改为 9 年(罗睺入旺 +1)。
## 测试
| 项 | 结果 |
| --- | --- |
| `test_narayana_rath_rules.py`、`test_narayana_rath_book_tables.py`、`test_narayana_legacy_label.py`、`test_report_english_dictionary.py`、`test_report_english_edition.py`、`test_narayana_dasha.py`、`test_node_own_sign_and_fixed_lords.py` | 通过(上游对照 1 条因本机没有上游仓而跳过) |
| 咨询卡:Rath 三例、字数三份(证据卡、v2、压缩清单) | 通过 |
| `tsc --noEmit` | 0 错。本机 Node 是 v24.15.0;`tsc` 与 `npm test` 用 nvm 的 Node 22.14.0 |
| `npm run lint` | 0 error,126 warnings |
| `npm test`(Node 22.14.0) | 4932 项,pass 4872,fail 19,skipped 41。失败 19 条都是本机没有 Docker,起不来 Postgres。与开工基线同一条命令的失败名单相同,顺序也相同 |
| 全量 `pytest tests` | 见下节 |
| v5 77 例 | 与第五批公布的记分板相同,见下节 |
| 快速门 `run_quality_gate.py --profile quick --skip-frontend-runtime` | `Quality gate passed`。1046 passed、2 skipped(第五批记录是 1047 passed、1 skipped;本机多跳过 1 条,门禁结果是通过) |
## 全量 pytest
开工基线 `64c652ee`(一次性 `--detach` 工作树)与本分支各跑一遍 `.venv` 的 `python -m pytest tests -q --tb=line`。两边都先放好本机已有的 `de421.bsp` 和 `hip_main.dat`,避免 Skyfield 经本机代理现下这两份星历。
失败名单:基线 68 条,本分支 67 条。本分支没有多出来的失败。基线多的一条是 `tests/test_vedastro_divisional_longitude_bridge.py::test_vedastro_bridge_divisional_longitudes_match_local_degree_mapping`,当时报 `bridge_runtime_error`(两套全量同时在跑)。这条在基线工作树单独再跑通过。其余 67 条名字相同,包括库存清单、占星 oracle 快照、VedAstro 运行时针脚;Rath 定向测试和英文版测试不在失败名单里。
跑完后 `git checkout` 掉测试改写的 4 个 `references/oracle/prashna_*_2026_07_20.json`。
## v5 77 例
命令:`PYTHONHASHSEED=0 JYOTISH_API_CHART_CACHE_TTL_SECONDS=0 python scripts/research/holdout_v5_baseline.py --dataset v5`。77 例,三套 harness 都没有 errors。打分文件与 `64c652ee` 无差异。记分板与第五批进度记录里的改前 / 改后数字相同:
| 指标 | ±10 | ±30 | ±60 |
| --- | --- | --- | --- |
| 先验头名 | 0.1818 | 0.0909 | 0.0779 |
| 六题回放头名 | 0.6364 | 0.4935 | 0.3117 |
| 线上区间回放头名 | 0.6623 | 0.5584 | 0.4026 |
| 覆盖(回放) | 0.987 | 0.987 | 0.987 |
| 中位宽度(回放) | 13 | 33 | 65 |
| futile 真值在区间 | 76 | 75 | 76 |
+1 -1
View File
@@ -419,4 +419,4 @@
| `TASK-annual-return-frame-20261003.md` | `PROGRESS-annual-return-frame-20261003.md` | 太阳返照时刻错位:出生太阳与求解器不同参考系(差 15″≈6 分钟)+ 年盘丢秒;243 张年盘里年盘上升变 13、年主变 11;搬上游 7cc6425d/46ed9399 两处,PyJHora 对照 ≤0.5 分钟;排在第三批之后(依据 `docs/research/shared_engine_reuse_audit_2026_10_03.md`) | 已部署 staging `01929bae`(BUG-1220) | 分支 `codex/annual-return-frame-20261003` |
| `TASK-astrologer-rulings-batch4-20261003.md` | `PROGRESS-astrologer-rulings-batch4-20261003.md` | Rath 当前大运进全部领域卡(卡预算 12,000→12,500、总 18,000→18,500,修改 BUG-1160/1161 决定);报告年运强度表改 Tajika 五分法(与年主同口径);校正打分不换,等第六轮 | 已实现待验收(T1–T3 完成) | 分支 `codex/astrologer-rulings-batch4-20261003`(未推送;BUG-1221~1222;校正分数不变、未升版本) |
| `TASK-astrologer-rulings-batch5-20261004.md` | `PROGRESS-astrologer-rulings-batch5-20261004.md` | 占星师第六轮:Rath 双主星按 p.43(a)–(e)、罗计尊贵按 Rath Table 9(仅 Narayana 内)、子运方向 p.51 例外与书内分歧标注、Wadiyar 两对标软件特例;年主选不出时不再用 Muntha 主星顶替(对齐上游 03bea6ed);Rath 替换校正 v5 重试算(研究)(BUG 从 1223 起) | 已实现待验收(T2–T5 完成;T1 按红线停下 blocked;T6 研究分支已跑) | 分支 `codex/astrologer-rulings-batch5-20261004`(未推送;BUG-1223~1227;校正分数不变、未升版本);研究分支 `codex/narayana-rath-rectification-trial-20261004`(不合入) |
| `TASK-astrologer-rulings-batch6-20261004.md` | `PROGRESS-astrologer-rulings-batch6-20261004.md` | 第七轮裁定(共享仓书面回复,产品采用;问 1 选 A):Rath 版双主星按 p.43 (a)–(e)(BUG-1227 解除 blocked,推翻第五批红线 2 的 Table 17 年数底线);第 5 级宫主度数只倒算计都(p.71 脚注 42);罗计旺陷 ±1 年;同宫两主比经度;BUG 从 1228 起 | 待领取 | 分支 `codex/astrologer-rulings-batch6-20261004` |
| `TASK-astrologer-rulings-batch6-20261004.md` | `PROGRESS-astrologer-rulings-batch6-20261004.md` | 第七轮裁定(共享仓书面回复,产品采用;问 1 选 A):Rath 版双主星按 p.43 (a)–(e)(BUG-1227 解除 blocked,推翻第五批红线 2 的 Table 17 年数底线);第 5 级宫主度数只倒算计都(p.71 脚注 42);罗计旺陷 ±1 年;同宫两主比经度;BUG 从 1228 起 | 待验收 | 分支 `codex/astrologer-rulings-batch6-20261004`(BUG-1227~1229;校正分数文件未改、未升版本) |
@@ -592,7 +592,7 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
* 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_MD_CARD_KEYS = ["sign", "start_age", "end_age"] as const;
const RATH_MD_CARD_KEYS = ["sign", "start_age", "end_age", "dual_lord_basis", "dual_lord_basis_en"] as const;
function baseTiming(timing: Record<string, unknown>, gaps: string[]) {
const sub = record(timing.dasha_sub_periods);
+2 -1
View File
@@ -350,7 +350,8 @@ const timingKeys = new Set([
"algorithmlabel",
// BUG-1218: the Sanjay Rath Narayana beside it (display only): label, start
// sign, deciding level and the running MD.
"rath", "seedsign", "strengthleveldecided",
// BUG-1227: Scorpio / Aquarius MDs also carry the one dual-lord sentence.
"rath", "seedsign", "strengthleveldecided", "duallordbasis", "duallordbasisen",
]);
const validationKeys = new Set([
@@ -43,12 +43,18 @@ function assertRathMd(workflow: Json, domain: ConsultationDomain, where: string)
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).
// Short form: label, start sign, running MD sign and ages. Scorpio / Aquarius
// also carry the one dual-lord sentence (round-7, BUG-1227).
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.algorithm, "Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定))");
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);
const expectedMd: Json = { sign: md.sign, start_age: md.start_age, end_age: md.end_age };
if (typeof md.dual_lord_basis === "string") {
expectedMd.dual_lord_basis = md.dual_lord_basis;
expectedMd.dual_lord_basis_en = md.dual_lord_basis_en;
}
assert.deepEqual(shown.md, expectedMd, where);
// No sub-periods on the card.
assert.equal(JSON.stringify(shown).includes("antardasha"), false, where);
// The legacy Narayana is unchanged and still labelled.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+28 -9
View File
@@ -26,8 +26,8 @@ LEGACY_NOTE_EN = "Legacy algorithm (starts from the Lagna, always zodiacal); to
LEGACY_LABEL_ZH = "旧算法(从上升起、一律顺行)"
RATH_LABEL_ZH = "Sanjay Rath 版(并列参考,未进打分与时间轴结论)"
RATH_LABEL_EN = "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions)"
RATH_LABEL_ZH = "Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定))"
RATH_LABEL_EN = "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions; book p.43 text, round-7 ruling)"
STRENGTH_LEVEL_ZH = {
1: "占据星数", 2: "木星 / 水星 / 宫主三角色", 3: "占据星尊贵", 4: "星座性质",
5: "宫主星座内度数", 6: "宫主落奇偶相反星座", 7: "大运年数",
@@ -47,14 +47,19 @@ DIRECTION_EN = {
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 = {key: row.get(key) for key in ("sign", "lord", "years", "base_years", "dignity_adjustment", "adjustment_source")}
out["start_age"] = round(float(row["start_age"]), 2)
out["end_age"] = round(float(row["end_age"]), 2)
if row.get("end_age") is not None:
out["end_age"] = round(float(row["end_age"]), 2)
if isinstance(out["years"], float):
out["years"] = round(out["years"], 2)
if birth_year:
decision = row.get("dual_lord_decision") or {}
if decision.get("basis_text"):
out["dual_lord_basis"] = decision["basis_text"]
out["dual_lord_basis_en"] = decision.get("basis_text_en")
if birth_year and out.get("end_age") is not None:
out["start_year"] = round(birth_year + float(row["start_age"]), 2)
out["end_year"] = round(birth_year + float(row["end_age"]), 2)
out["end_year"] = round(birth_year + float(out["end_age"]), 2)
return out
@@ -151,9 +156,23 @@ def rath_report_lines_zh(rath: Any) -> List[str]:
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 | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |", "|---------|------|------|----------|----------|------|"])
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')} |")
lines.append("| {index} | {sign} | {lord} | {start} | {end} | {base} | {adjustment} | {years} |".format(
index=index, sign=row.get("sign"), lord=row.get("lord") or "未决",
start=row.get("start_age"), end=row.get("end_age"),
base="—" if row.get("base_years") is None else row.get("base_years"),
adjustment="—" if row.get("dignity_adjustment") is None else row.get("dignity_adjustment"),
years="—" if row.get("years") is None else row.get("years")))
seen = set()
for row in rath["mahadasha"]:
sign = row.get("sign")
basis = row.get("dual_lord_basis")
if sign in ("Scorpio", "Aquarius") and basis and sign not in seen:
seen.add(sign)
lines.append(f"- 双主星 {sign}:{basis}")
md = (rath.get("current_dasha") or {}).get("md")
rule = rath.get("current_md_antardasha_rule") or {}
if md and rath.get("current_md_antardasha"):
@@ -194,7 +213,7 @@ def rath_summary_line_zh(rath: Any) -> Optional[str]:
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 ""
running = f";当前大运 {md.get('sign')},宫主 {md.get('lord') or '未决'}" if md else ""
return (f"- 并列:{RATH_LABEL_ZH}:起运宫 {rath.get('seed_sign')}"
f"(第 {rath.get('strength_level_decided')} 级:{rath.get('strength_level_text')}){running}")
+284 -39
View File
@@ -19,8 +19,10 @@ p.63]:
Ketu by Rath Table 9 (p.41-42, Phalita set; round-6 ruling Q2,
BUG-1224), debilitation below none per p.67;
4. dual > fixed > movable;
5. the lord's degree inside its sign, Chara Karaka sense (higher is
stronger; Rahu / Ketu as 30 - degree);
5. the lord's degree inside its sign (higher is stronger; only Ketu is
30 - degree, and Ketu at 0° compares as 30; Rahu and the seven grahas
use the raw degree, retrograde included — round-7 ruling Q3, p.71
footnote 42, BUG-1228);
6. the lord placed in a sign of the opposite parity to the sign it rules;
7. the sign giving the longer dasha.
"Contains the AK" / "lord is the AK" are not general rules [p.35]. The
@@ -35,9 +37,15 @@ backwards) [p.51, p.53 Table 12; p.54 Table 14 is the AD table]. Saturn and
Ketu both in the start sign: Ketu first (PyJHora's practice, product owner
ruling 2026-10-03, `method_note`).
Years: `jaimini_odd_footed_dignity_v2` (odd/even-footed count, fixed-sign
exception, exalted +1 / debilitated -1); Scorpio / Aquarius lords by
`jaimini_dual_lord_source_v1`. Cycle 2 = 12 minus cycle 1.
Years: `jaimini_odd_footed_dignity_v2` count, then exalted +1 / debilitated
-1. The seven grahas use the frozen dignity table. Rahu / Ketu use the
Phalita pair (Rahu exalted Gemini / debilitated Sagittarius, Ketu the
reverse) and are clamped to 0–12 (p.40 (a), round-7, BUG-1229). Scorpio /
Aquarius lords follow book p.43 (a)–(e) with the round-7 ruling (option A):
(d) compares the signs the two lords occupy with this module's seven
levels; (c) counts only Scorpio / Aquarius themselves, not Mars in Aries or
Saturn in Capricorn; same-sign identity uses the level-5 degree rule and
returns undecided on an exact tie (BUG-1227). Cycle 2 = 12 minus cycle 1.
AD [p.48 Arambha Rasi, p.50 note 2]: compare the MD sign and its 7th with the
same seven levels, take the stronger, start from the sign holding that sign's
@@ -77,7 +85,11 @@ except ImportError: # pragma: no cover - package import
PROFILE = "sanjay_rath"
DURATION_PROFILE = "jaimini_odd_footed_dignity_v2"
DUAL_LORD_PROFILE = "jaimini_dual_lord_source_v1"
DUAL_LORD_PROFILE = "rath_p43_text_first_round7_v1"
P43_SOURCE = "p.43; p.116; p.71 fn 42; Jaimini course p.276"
P43_VERSION_ZH = "按原书 p.43 正文(第七轮裁定)"
P43_VERSION_EN = "Book p.43 text (round-7 ruling)"
_DUAL_PAIRS = {7: ("Mars", "Ketu"), 10: ("Saturn", "Rahu")}
ROLE_PROFILE = "rath_p36_role_conjunction_v1"
INDIRA_EXCEPTION = "rath_p66_indira_ak"
# Book examples whose printed start sign the general rule does not give.
@@ -124,7 +136,10 @@ SOURCE = {
"md_order": "p.51 Table 10; Saturn p.51-53 Table 11; Ketu p.51, p.53 Table 12",
"ad": "p.48 Arambha Rasi, p.50 note 2; Tables 13 / 11 / 14 (p.54)",
"indira_example": "p.65-66 (AK), Table 18 p.67",
"ruling": "astrologer round 5, 2026-10-03, Q1-Q4; product owner: Ketu first; round 6, 2026-10-04, Q2 (node dignity)",
"ruling": "astrologer round 5, 2026-10-03, Q1-Q4; product owner: Ketu first; round 6, 2026-10-04, Q2 (node dignity); round 7, 2026-10-04, Q1 option A (p.43), Q3 (only Ketu reversed)",
"dual_lord": P43_SOURCE,
"dual_lord_version": P43_VERSION_ZH,
"dual_lord_version_en": P43_VERSION_EN,
"node_dignity": "Table 9 p.41-42 (Phalita exaltation Rahu Gemini / Ketu Sagittarius); debilitated weaker p.67",
"port": "upstream yinduzhanxing c7117f92 scripts/narayana_dasha.py",
}
@@ -158,8 +173,193 @@ def _grahas(planet_lons: Dict[str, float]) -> Dict[str, float]:
return {planet: float(planet_lons[planet]) for planet in GRAHAS if planet_lons.get(planet) is not None}
def _lord(sign_idx: int, planet_lons: Dict[str, float]) -> str:
return nd._resolve_narayana_lord(sign_idx, planet_lons, dual_lord_profile=DUAL_LORD_PROFILE)
def _strength_degree(planet: str, lon: float) -> float:
"""Degree used when a higher value is stronger (BUG-1228, round-7 Q3).
Grahas and Rahu keep the raw degree inside the sign. Only Ketu is
reversed (p.71 footnote 42). Ketu at 0° compares as 30, not 0.
"""
degree = float(lon) % 30.0
if planet == "Ketu":
return 30.0 - degree
return degree
def _ck_degree(planet: str, lon: float) -> float:
return _strength_degree(planet, lon)
def rath_period_years(sign_idx: int, lord: str, lord_sign_idx: int) -> Dict[str, Any]:
"""Base years, dignity adjustment and final years (BUG-1229).
The count is the frozen odd/even-footed duration. Graha exaltation /
debilitation stays in `calculate_narayana_period_years`. Rahu / Ketu add
or subtract one year on the Phalita exaltation pair only, then clamp to
0–12 (p.40 (a)).
"""
base = nd.calculate_narayana_duration(sign_idx, lord_sign_idx, profile=DURATION_PROFILE)
if lord in RATH_NODE_DIGNITY:
table = RATH_NODE_DIGNITY[lord]
if lord_sign_idx in table["exalted"]:
adjustment = 1
elif lord_sign_idx in table["debilitated"]:
adjustment = -1
else:
adjustment = 0
source = "p40a_phalita_nodes"
years = max(0, min(12, base + adjustment))
else:
years, adjustment = nd.calculate_narayana_period_years(
sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
source = "p40a_grahas"
return {
"lord": lord,
"base_years": base,
"dignity_adjustment": adjustment,
"years": years,
"adjustment_source": source,
}
_BASIS = {
"a": (
"两主都在本宫,年数 12;身份按同宫经度,只倒算计都",
"Both lords are in the sign, so the period is 12 years; identity is by same-sign degree, and only Ketu is reversed",
),
"b": (
"两主同在另一宫,年数数到该宫;身份按同宫经度,只倒算计都",
"Both lords share another sign, so the years are counted to it; identity is by same-sign degree, and only Ketu is reversed",
),
"c": (
"一主在天蝎或水瓶本宫,取在外面的那颗;火星在白羊、土星在摩羯不算本宫",
"One lord is in Scorpio or Aquarius itself, so the lord outside it is taken; Mars in Aries and Saturn in Capricorn do not count as that own sign",
),
"d": (
"两主分落别的星座,按七级比较所落星座",
"The two lords occupy different signs, compared on the seven strength levels",
),
"e": (
"两主所落星座七级全平,取年数较多的一颗",
"The occupied signs tie on all seven levels, so the lord giving more years is taken",
),
}
def _dual_decision(*, status: str, rule: Optional[str], lord: Optional[str], compared: List[Dict[str, Any]],
strength_level_decided: Optional[int] = None, reason: Optional[str] = None,
period: Optional[Dict[str, Any]] = None, identity_rule: Optional[str] = None) -> Dict[str, Any]:
if reason == "resolution_cycle":
zh, en = ("双主星判定遇到循环,返回未决", "Dual-lord resolution hit a cycle and stays undecided")
elif rule is None:
zh, en = ("", "")
else:
zh, en = _BASIS.get(rule, ("双主星未决", "Dual lord undecided"))
if rule == "d" and strength_level_decided and lord:
zh = f"{zh},第 {strength_level_decided} 级取 {lord}"
en = f"{en}; level {strength_level_decided} takes {lord}"
elif lord and rule in {"a", "b", "c", "e"}:
zh = f"{zh},取 {lord}"
en = f"{en}; takes {lord}"
return {
"status": status,
"rule": rule,
"identity_rule": identity_rule,
"lord": lord,
"compared": compared,
"strength_level_decided": strength_level_decided,
"basis_text": zh,
"basis_text_en": en,
"source": P43_SOURCE,
"reason": reason,
"period": period,
}
def _same_sign_identity(first: str, second: str, planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""p.116 plus p.71 footnote 42. Exact equality stays undecided."""
first_degree = _strength_degree(first, planet_lons[first])
second_degree = _strength_degree(second, planet_lons[second])
if first_degree == second_degree:
return {"status": "undecided", "lord": None, "reason": "equal_degree",
"degrees": {first: first_degree, second: second_degree}}
winner = first if first_degree > second_degree else second
return {"status": "selected", "lord": winner, "reason": None,
"degrees": {first: first_degree, second: second_degree}}
def resolve_dual_lord(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Dict[str, Any]:
"""p.43 (a)–(e) for Scorpio / Aquarius. Other signs return their one lord.
Comparing the signs the two lords occupy can ask for another dual lord.
A sign already on `_resolution_path` stays undecided instead of looping.
"""
lons = _grahas(planet_lons)
pair = _DUAL_PAIRS.get(sign_idx)
if pair is None:
lord = SIGN_LORDS[SIGNS[sign_idx]]
return _dual_decision(status="selected", rule=None, lord=lord, compared=[], period=None)
if sign_idx in _resolution_path:
return _dual_decision(status="undecided", rule=None, lord=None, compared=[], reason="resolution_cycle")
path = _resolution_path + (sign_idx,)
first, second = pair
first_sign = nd._get_planet_sign(first, lons)
second_sign = nd._get_planet_sign(second, lons)
if first_sign is None or second_sign is None:
return _dual_decision(status="selected", rule=None, lord=SIGN_LORDS[SIGNS[sign_idx]], compared=[],
reason="longitude_missing")
compared = [
{"lord": first, "sign": SIGNS[first_sign], "sign_idx": first_sign},
{"lord": second, "sign": SIGNS[second_sign], "sign_idx": second_sign},
]
home_first = first_sign == sign_idx
home_second = second_sign == sign_idx
if home_first and home_second:
identity = _same_sign_identity(first, second, lons)
period = {"lord": identity["lord"], "base_years": 12, "dignity_adjustment": 0, "years": 12,
"adjustment_source": "p43a_both_in_sign"}
return _dual_decision(status=identity["status"], rule="a", lord=identity["lord"], compared=compared,
reason=identity["reason"], period=period, identity_rule="p116_longitude")
if first_sign == second_sign:
identity = _same_sign_identity(first, second, lons)
if identity["status"] != "selected":
left = rath_period_years(sign_idx, first, first_sign)
right = rath_period_years(sign_idx, second, second_sign)
period = left if left["years"] == right["years"] else None
if period is not None:
period = {**period, "lord": None}
return _dual_decision(status="undecided", rule="b", lord=None, compared=compared,
reason="equal_degree", period=period, identity_rule="p116_longitude")
period = rath_period_years(sign_idx, identity["lord"], first_sign)
return _dual_decision(status="selected", rule="b", lord=identity["lord"], compared=compared,
period=period, identity_rule="p116_longitude")
if home_first != home_second:
lord = second if home_first else first
lord_sign = second_sign if home_first else first_sign
return _dual_decision(status="selected", rule="c", lord=lord, compared=compared,
period=rath_period_years(sign_idx, lord, lord_sign))
comparison = compare_signs(first_sign, second_sign, lons, _resolution_path=path)
if comparison["status"] == "selected":
winner = comparison["selected_sign_idx"]
lord = first if first_sign == winner else second
return _dual_decision(status="selected", rule="d", lord=lord, compared=compared,
strength_level_decided=comparison["strength_level_decided"],
period=rath_period_years(sign_idx, lord, winner))
if comparison["status"] == "undecided":
return _dual_decision(status="undecided", rule="d", lord=None, compared=compared,
reason=comparison.get("reason") or "dual_lord_unresolved")
left = rath_period_years(sign_idx, first, first_sign)
right = rath_period_years(sign_idx, second, second_sign)
if left["years"] == right["years"]:
return _dual_decision(status="undecided", rule="e", lord=None, compared=compared, reason="equal_years",
period={**left, "lord": None})
period = left if left["years"] > right["years"] else right
return _dual_decision(status="selected", rule="e", lord=period["lord"], compared=compared, period=period)
def _lord(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Optional[str]:
return resolve_dual_lord(sign_idx, planet_lons, _resolution_path=_resolution_path).get("lord")
def _role_candidate(sign_idx: int, planet_lons: Dict[str, float], lord: str) -> Dict[str, Any]:
@@ -231,41 +431,48 @@ def _occupant_dignity(planet: str, sign_idx: int) -> int:
return 0
def _ck_degree(planet: str, lon: float) -> float:
degree = float(lon) % 30.0
return 30.0 - degree if planet in ("Rahu", "Ketu") else degree
def sign_strength(sign_idx: int, planet_lons: Dict[str, float]) -> Dict[str, Any]:
def sign_strength(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Dict[str, Any]:
"""All seven level values for one sign (see module docstring)."""
lons = _grahas(planet_lons)
lord = _lord(sign_idx, lons)
row = _role_candidate(sign_idx, lons, lord)
decision = resolve_dual_lord(sign_idx, lons, _resolution_path=_resolution_path)
lord = decision.get("lord")
row = _role_candidate(sign_idx, lons, lord or SIGN_LORDS[SIGNS[sign_idx]])
row["dual_lord_decision"] = decision
row["lord_undecided"] = not lord
if not lord:
row["lord"] = None
row["role_factors"] = []
row["aspect_factors"] = []
row["aspect_factor_count"] = None
ranks = {planet: _occupant_dignity(planet, sign_idx) for planet in row["occupants"]}
best = max(ranks.values(), default=0)
dignified = sum(1 for value in ranks.values() if value > 0)
debilitated = sum(1 for value in ranks.values() if value == DEBILITATED_RANK)
lord_sign_idx = nd._get_planet_sign(lord, lons)
lord_sign_idx = nd._get_planet_sign(lord, lons) if lord else None
if lord_sign_idx is None:
lord_sign_idx = sign_idx
years, _ = nd.calculate_narayana_period_years(sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
period = rath_period_years(sign_idx, lord, lord_sign_idx) if lord else None
years = period["years"] if period else None
degree = _strength_degree(lord, lons[lord]) if lord and lord in lons else None
row.update({
"occupant_dignity": {"ranks": ranks, "best_rank": best, "dignified_count": dignified,
"debilitated_count": debilitated},
"modality": "dual" if sign_idx in (2, 5, 8, 11) else "fixed" if sign_idx in nd._FIXED_SIGNS else "movable",
"lord_sign": SIGNS[lord_sign_idx],
"lord_degree_in_sign": round(_ck_degree(lord, lons[lord]), 6) if lord in lons else None,
"lord_opposite_parity": lord_sign_idx % 2 != sign_idx % 2,
"lord_sign": SIGNS[lord_sign_idx] if lord else None,
"lord_degree_in_sign": degree,
"lord_opposite_parity": (lord_sign_idx % 2 != sign_idx % 2) if lord else None,
"dasha_years": years,
"period": period,
})
row["level_values"] = [
row["occupant_count"],
row["aspect_factor_count"],
(best, dignified),
{"movable": 0, "fixed": 1, "dual": 2}[row["modality"]],
row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else -1.0,
int(row["lord_opposite_parity"]),
years,
degree if degree is not None else -1.0,
int(row["lord_opposite_parity"] or False),
years if years is not None else -1,
]
return row
@@ -280,16 +487,23 @@ def _atmakaraka(planet_lons: Dict[str, float]) -> Optional[str]:
def compare_signs(first_idx: int, second_idx: int, planet_lons: Dict[str, float],
*, allow_book_example: bool = False) -> Dict[str, Any]:
*, allow_book_example: bool = False,
_resolution_path: Tuple[int, ...] = ()) -> Dict[str, Any]:
"""Stronger of two signs by the seven general levels.
`allow_book_example`: only when all seven tie, the Indira example's AK
tie-break may decide; the result then carries `book_example_exception`.
"""
candidates = {first_idx: sign_strength(first_idx, planet_lons), second_idx: sign_strength(second_idx, planet_lons)}
candidates = {
first_idx: sign_strength(first_idx, planet_lons, _resolution_path=_resolution_path),
second_idx: sign_strength(second_idx, planet_lons, _resolution_path=_resolution_path),
}
base = {"candidates": {SIGNS[idx]: row for idx, row in candidates.items()},
"book_example_exception": None}
for level, name in enumerate(LEVELS):
if level >= 1 and (candidates[first_idx].get("lord_undecided") or candidates[second_idx].get("lord_undecided")):
return {**base, "status": "undecided", "reason": "dual_lord_unresolved",
"strength_level_decided": None, "strength_level_name": None}
a = candidates[first_idx]["level_values"][level]
b = candidates[second_idx]["level_values"][level]
if a != b:
@@ -395,23 +609,46 @@ def mahadasha_sequence(seed_sign_idx: int, planet_lons: Dict[str, float],
rows: List[Dict[str, Any]] = []
cursor = float(start_age)
for position, sign_idx in enumerate(order["order"], start=1):
lord = _lord(sign_idx, planet_lons)
lord_sign_idx = nd._get_planet_sign(lord, planet_lons)
if lord_sign_idx is None:
decision = resolve_dual_lord(sign_idx, planet_lons)
period = decision.get("period")
lord = decision.get("lord")
if period is None and lord:
lord_sign_idx = nd._get_planet_sign(lord, planet_lons)
if lord_sign_idx is None:
lord_sign_idx = sign_idx
period = rath_period_years(sign_idx, lord, lord_sign_idx)
elif period is None:
lord_sign_idx = sign_idx
years, adjustment = nd.calculate_narayana_period_years(sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
rows.append({
else:
lord_sign_idx = nd._get_planet_sign(lord, planet_lons) if lord else None
if lord_sign_idx is None:
lord_sign_idx = sign_idx
years = None if period is None else period["years"]
row = {
"sign": SIGNS[sign_idx], "sign_idx": sign_idx, "lord": lord,
"lord_in_sign": SIGNS[lord_sign_idx], "lord_sign_idx": lord_sign_idx,
"years": years, "dignity_adjustment": adjustment, "cycle_index": 1,
"lord_in_sign": SIGNS[lord_sign_idx] if lord_sign_idx is not None else None,
"lord_sign_idx": lord_sign_idx,
"years": years,
"base_years": None if period is None else period["base_years"],
"dignity_adjustment": None if period is None else period["dignity_adjustment"],
"adjustment_source": None if period is None else period["adjustment_source"],
"cycle_index": 1,
"period_key": f"narayana_rath:1:{position:02d}:{SIGNS[sign_idx]}",
"start_age": round(cursor, 4), "end_age": round(cursor + years, 4),
})
"start_age": round(cursor, 4),
"end_age": None if years is None else round(cursor + years, 4),
}
if sign_idx in _DUAL_PAIRS:
row["dual_lord_decision"] = decision
rows.append(row)
if years is None:
return {**order, "seed_sign": SIGNS[seed_sign_idx], "seed_sign_idx": seed_sign_idx,
"mahadasha": rows, "status": "undecided"}
cursor += years
if cycle_count == 2:
for position, first in enumerate(list(rows), start=1):
years = 12 - first["years"]
rows.append({**first, "years": years, "dignity_adjustment": None, "cycle_index": 2,
rows.append({**first, "years": years, "base_years": None, "dignity_adjustment": None,
"adjustment_source": None, "cycle_index": 2,
"period_key": f"narayana_rath:2:{position:02d}:{first['sign']}",
"start_age": round(cursor, 4), "end_age": round(cursor + years, 4)})
cursor += years
@@ -431,7 +668,14 @@ def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> Dict[st
"compared": [SIGNS[md_sign_idx], SIGNS[seventh]],
"strength_level_decided": None}}
stronger = comparison["selected_sign_idx"]
lord = _lord(stronger, planet_lons)
decision = resolve_dual_lord(stronger, planet_lons)
lord = decision.get("lord")
if not lord:
return {"status": "blocked", "reason": decision.get("reason") or "dual_lord_undecided", "rows": [],
"antardasha_start_rule": {"rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign",
"compared": [SIGNS[md_sign_idx], SIGNS[seventh]],
"stronger_sign": SIGNS[stronger],
"dual_lord_decision": decision}}
start = nd._get_planet_sign(lord, planet_lons)
if start is None:
return {"status": "blocked", "reason": "stronger_sign_lord_longitude_missing", "rows": []}
@@ -450,7 +694,8 @@ def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> Dict[st
"stronger_sign": SIGNS[stronger],
"strength_level_decided": comparison["strength_level_decided"],
"lord": lord,
"dual_lord_rule": DUAL_LORD_PROFILE if stronger in (7, 10) else None,
"dual_lord_rule": decision.get("rule") if stronger in _DUAL_PAIRS else None,
"dual_lord_decision": decision if stronger in _DUAL_PAIRS else None,
"start_sign": SIGNS[start],
"order_profile": profile,
"method_note": note,
+18 -3
View File
@@ -151,8 +151,8 @@ _PHRASES: dict[str, str] = {
"旧算法(从上升起、一律顺行),待替换为 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)",
"Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定)):": "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions; book p.43 text, round-7 ruling): ",
"Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定))": "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions; book p.43 text, round-7 ruling)",
",宫主 ": ", lord ",
"起运宫未定,": "start sign undecided, ",
"- 并列:": "- Alongside: ",
@@ -171,7 +171,8 @@ _PHRASES: dict[str, str] = {
"宫主星座内度数": "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 MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 基本年数 | 修正 | 最终 |": "| Rath MD | Sign | Lord | Start age | End age | Base years | Adjustment | Final |",
"未决": "undecided",
"| Rath AD | 星座 | 起始年龄 | 结束年龄 |": "| Rath AD | Sign | Start age | End age |",
" 的子运:比较 ": " sub-periods: compare ",
# BUG-1225: narayana_legacy_label._ad_direction_lines_zh (AD direction + book divergence)
@@ -286,6 +287,20 @@ 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-1227 Rath dual-lord basis on the reference report (round-7 option A).
(re.compile(r"- 双主星 ([A-Za-z]+):"), r"- Co-lords \1: "),
(re.compile(r"两主分落别的星座,按七级比较所落星座,第 (\d+) 级取 ([A-Za-z]+)"),
r"The two lords occupy different signs, compared on the seven strength levels; level \1 takes \2"),
(re.compile(r"两主都在本宫,年数 12;身份按同宫经度,只倒算计都,取 ([A-Za-z]+)"),
r"Both lords are in the sign, so the period is 12 years; identity is by same-sign degree, and only Ketu is reversed; takes \1"),
(re.compile(r"两主同在另一宫,年数数到该宫;身份按同宫经度,只倒算计都,取 ([A-Za-z]+)"),
r"Both lords share another sign, so the years are counted to it; identity is by same-sign degree, and only Ketu is reversed; takes \1"),
(re.compile(r"一主在天蝎或水瓶本宫,取在外面的那颗;火星在白羊、土星在摩羯不算本宫,取 ([A-Za-z]+)"),
r"One lord is in Scorpio or Aquarius itself, so the lord outside it is taken; Mars in Aries and Saturn in Capricorn do not count as that own sign; takes \1"),
(re.compile(r"两主所落星座七级全平,取年数较多的一颗,取 ([A-Za-z]+)"),
r"The occupied signs tie on all seven levels, so the lord giving more years is taken; takes \1"),
(re.compile(r"双主星判定遇到循环,返回未决"),
"Dual-lord resolution hit a cycle and stays undecided"),
# BUG-1218 Rath Narayana report lines
(re.compile(r"(第 (\d) 级:([^)]+))"), r" (level \1: \2)"),
(re.compile(r"(第 (\d) 级)"), r" (level \1)"),
+2 -2
View File
@@ -94,9 +94,9 @@ def test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines() -
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 版(并列参考,未进打分与时间轴结论):起运宫 ")
rath = zh.index("- 并列:Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定)):起运宫 ")
assert legacy < rath
assert "| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 年数 |" in zh and "| Rath AD | 星座 | 起始年龄 | 结束年龄 |" in zh
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
+37 -16
View File
@@ -70,7 +70,11 @@ def test_book_start_sign_reproduces_order_and_years(table: str) -> None:
asc, lons = _chart(table)
result = calc_narayana_rath(asc, lons, seed_sign_idx=SIGNS.index(book["start_sign"]), cycle_count=1)
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in book["rows"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] for row in book["rows"]]
# 原值:年数逐行等于书印值
# 新值:Table 15 仍等于书印值;Table 17 / 18 见 NEW_EXPLICIT_YEARS(年数不再作通过线)
# 原因:BUG-1227 第七轮选 A,加上 BUG-1229 罗计旺陷 ±1
expected_years = NEW_EXPLICIT_YEARS.get(table, [row[1] for row in book["rows"]])
assert [row["years"] for row in result["mahadasha"]] == expected_years
def test_saturn_in_the_start_sign_gives_the_zodiacal_order() -> None:
@@ -96,7 +100,10 @@ def test_start_sign_by_the_seven_level_rule() -> None:
assert [row["role"] for row in pisces["role_factors"]] == ["jupiter", "sign_lord"]
assert result["seed_selection"]["candidates"]["Virgo"]["aspect_factor_count"] == 1
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in _book("Table 17")["rows"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] for row in _book("Table 17")["rows"]]
# 原值:年数 12/12 等于 Table 17 书印值(天蝎 10、水瓶 8)
# 新值:10/12;天蝎计都 2(基本 1,入旺 +1),水瓶土星 1。Chart 3 水瓶印 9
# 原因:BUG-1227 第七轮选 A,年数不再作通过线;BUG-1229
assert [row["years"] for row in result["mahadasha"]] == NEW_EXPLICIT_YEARS["Table 17"]
asc, lons = _chart("Table 18")
for allow in (False, True):
result = calc_narayana_rath(asc, lons, cycle_count=1, allow_book_example=allow)
@@ -138,7 +145,10 @@ def test_table18_only_with_the_explicit_cancer_seed_and_flagged() -> None:
assert result["book_example_exception"] == "rath_p66_indira_ak"
assert result["book_example"]["book_internal_contradiction"] is True
assert [row["sign"] for row in result["mahadasha"]] == [row[0] for row in book["rows"]]
assert [row["years"] for row in result["mahadasha"]] == [row[1] for row in book["rows"]]
# 原值:显式巨蟹起运后年数等于书印值(水瓶 2)
# 新值:水瓶罗睺 1(基本 2,落陷 −1),其余 11 段不变
# 原因:BUG-1229,罗计按 Phalita 旺陷加减;顺序仍是 12/12
assert [row["years"] for row in result["mahadasha"]] == NEW_EXPLICIT_YEARS["Table 18"]
# The general rule's own verdict travels with it, unchanged.
assert (result["general_rule_selection"]["selected_sign"], result["general_rule_selection"]["strength_level_decided"]) == ("Capricorn", 5)
with pytest.raises(ValueError):
@@ -168,20 +178,25 @@ VERDICT_TABLES = {
# software used Rule 8 at the seventh level — a software special case; keep
# the general result, flagged `book_software_rule8_exception` (BUG-1226).
EXPECTED_SIGN_MISSES = {("Table 25", "Gemini", "Sagittarius"), ("Table 25", "Virgo", "Pisces")}
# Dual lords (Aquarius, Scorpio): book Table 23 Aquarius = Saturn (ours Rahu),
# Table 25 Scorpio = Ketu (ours Mars). Round-6 ruling Q6a (p.43 (d): compare
# the signs the two lords occupy) was tried in batch 5 (BUG-1227) and NOT
# landed: it gives 9/10 here (new miss Table 23 Scorpio) but drops the Table 17
# years from 12/12 to 10/12 (Scorpio 10 -> 1 by Ketu, Aquarius 8 -> 1 by
# Saturn) — the book's standard nativity (Table 17 / Chart 3, and the p.43 (e)
# example) conflicts with its own Table 23 verdict. Kept at 8/10 (red line).
EXPECTED_CO_LORD_MISSES = {("Table 23", "Aquarius"), ("Table 25", "Scorpio")}
# Dual lords after round-7 option A (BUG-1227 resolved). 9/10: the one miss is
# Table 23 Scorpio (book Mars, p.43 (d) level 2 takes Ketu because Sagittarius
# has two Jupiter roles). Table 17 years are no longer a pass line:
# Scorpio book 10 -> Ketu 2 (base 1, exalted +1); Aquarius book 8 -> Saturn 1.
# Chart 3 prints Aquarius 9 (Rahu 8+1). Table 18 Aquarius book 2 -> Rahu 1
# (base 2, debilitated -1).
EXPECTED_CO_LORD_MISSES = {("Table 23", "Scorpio")}
# Explicit-seed years that no longer match the printed book rows. Order is unchanged.
NEW_EXPLICIT_YEARS = {
"Table 17": [12, 5, 2, 3, 5, 1, 1, 12, 2, 2, 9, 1],
"Table 18": [6, 9, 10, 2, 9, 5, 6, 1, 10, 4, 7, 5],
}
def test_book_software_verdicts_28_of_30_and_co_lords_8_of_10() -> None:
from scripts.narayana_rath import _lord, compare_signs
def test_book_software_verdicts_28_of_30_and_co_lords_9_of_10() -> None:
from scripts.narayana_rath import compare_signs, resolve_dual_lord
sign_misses, co_lord_misses, pairs = set(), set(), 0
decisions = []
for table, (birth, co_lords, stronger) in VERDICT_TABLES.items():
chart, _asc, *_ = compute_chart_data(*birth, ayanamsa_name="lahiri")
lons = {name: row["degree"] for name, row in chart["planets"].items()}
@@ -195,11 +210,17 @@ def test_book_software_verdicts_28_of_30_and_co_lords_8_of_10() -> None:
assert result["strength_level_decided"] == 5
sign_misses.add((table, SIGNS[winner], SIGNS[second if winner == first else first]))
for sign_idx, want in zip((10, 7), co_lords):
if _lord(sign_idx, lons) != want:
decision = resolve_dual_lord(sign_idx, lons)
decisions.append((table, SIGNS[sign_idx], want, decision))
if decision.get("lord") != want:
co_lord_misses.add((table, SIGNS[sign_idx]))
assert pairs == 30
assert sign_misses == EXPECTED_SIGN_MISSES # 28/30
assert co_lord_misses == EXPECTED_CO_LORD_MISSES # 8/10
assert sign_misses == EXPECTED_SIGN_MISSES # 28/30, same two Wadiyar pairs
assert co_lord_misses == EXPECTED_CO_LORD_MISSES # 9/10
assert len(decisions) == 10
assert all(row[3]["rule"] == "d" and row[3]["status"] == "selected" for row in decisions)
scorpio = next(row[3] for row in decisions if row[0] == "Table 23" and row[1] == "Scorpio")
assert (scorpio["lord"], scorpio["strength_level_decided"]) == ("Ketu", 2)
# Table 25 (Wadiyar) sub-periods for the first three MDs, from upstream
+77 -1
View File
@@ -183,8 +183,12 @@ def test_ported_upstream_varsha_saturn_seed_order_and_lengths() -> None:
first = seq["mahadasha"][:5]
assert seq["direction"] == "table11_saturn_zodiacal"
assert [row["sign_idx"] for row in first] == [7, 8, 9, 10, 11]
assert [row["years"] for row in first] == [7, 4, 2, 3, 11]
# 原值:第一段 7 年(计都在双子,年数不减落陷)
# 新值:第一段 6 年(基本 7,计都在双子落陷 −1);主星仍是计都,第 1 级
# 原因:BUG-1229,第七轮 p.40 (a),罗计按 Phalita 旺陷加减
assert [row["years"] for row in first] == [6, 4, 2, 3, 11]
assert first[0]["lord"] == "Ketu"
assert (first[0]["base_years"], first[0]["dignity_adjustment"], first[0]["adjustment_source"]) == (7, -1, "p40a_phalita_nodes")
def test_output_fields() -> None:
@@ -295,3 +299,75 @@ def test_book_divergence_lists_the_two_other_statements() -> None:
p47 = direction["book_divergence"][0]
assert p47["direction"] == "reverse" # Taurus is an even sign
assert p47["differs"] == (p47["direction"] != direction["direction"])
# --- BUG-1227 / BUG-1228 / BUG-1229: round-7 ruling (p.43 option A, only Ketu
# reversed, node exaltation ±1). Synthetic longitudes, no birth data.
def _nine(**placed: float) -> dict:
chart = {"Sun": 5.0, "Moon": 6.0, "Mars": 7.0, "Mercury": 8.0, "Jupiter": 9.0,
"Venus": 11.0, "Saturn": 12.0, "Rahu": 13.0, "Ketu": 193.0}
chart.update(placed)
return chart
def test_mars_in_aries_and_saturn_in_capricorn_do_not_trigger_rule_c() -> None:
"""附问:火星在白羊、土星在摩羯不是 p.43 (c) 的「在本宫」。若误触发,会改取外面那颗。"""
from scripts.narayana_rath import resolve_dual_lord
scorpio = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(
Mars=10.0, Sun=11.0, Moon=12.0, Ketu=65.0, Rahu=245.0))
assert (scorpio["rule"], scorpio["lord"]) == ("d", "Mars")
aquarius = resolve_dual_lord(SIGNS.index("Aquarius"), _nine(
Saturn=280.0, Sun=281.0, Moon=282.0, Rahu=70.0, Ketu=250.0))
assert (aquarius["rule"], aquarius["lord"]) == ("d", "Saturn")
def test_same_sign_degree_reverses_only_ketu_and_ties_stay_undecided() -> None:
from scripts.narayana_rath import _strength_degree, resolve_dual_lord
assert _strength_degree("Ketu", 0.0) == 30.0
assert _strength_degree("Ketu", 27.0) == 3.0
assert _strength_degree("Rahu", 27.0) == 27.0
assert _strength_degree("Saturn", 14.0) == 14.0
rahu = resolve_dual_lord(SIGNS.index("Aquarius"), _nine(Saturn=44.0, Rahu=57.0, Ketu=237.0))
assert (rahu["status"], rahu["rule"], rahu["identity_rule"], rahu["lord"]) == ("selected", "b", "p116_longitude", "Rahu")
mars = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=134.0, Ketu=147.0, Rahu=327.0))
assert (mars["status"], mars["rule"], mars["lord"]) == ("selected", "b", "Mars")
ketu_zero = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=29.0, Ketu=0.0, Rahu=180.0))
assert (ketu_zero["rule"], ketu_zero["lord"]) == ("b", "Ketu")
tied = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(Mars=10.0, Ketu=20.0, Rahu=200.0))
assert (tied["status"], tied["lord"], tied["reason"]) == ("undecided", None, "equal_degree")
def test_rahu_degree_at_level_five_is_not_reversed() -> None:
from scripts.narayana_rath import sign_strength
row = sign_strength(SIGNS.index("Aquarius"), _nine(Saturn=44.0, Rahu=57.0, Ketu=237.0))
assert row["lord"] == "Rahu"
assert row["lord_degree_in_sign"] == 27.0
def test_node_dignity_adjusts_the_period_and_clamps() -> None:
from scripts.narayana_rath import rath_period_years
ketu = rath_period_years(SIGNS.index("Scorpio"), "Ketu", SIGNS.index("Sagittarius"))
assert (ketu["base_years"], ketu["dignity_adjustment"], ketu["years"], ketu["adjustment_source"]) == (1, 1, 2, "p40a_phalita_nodes")
rahu = rath_period_years(SIGNS.index("Aquarius"), "Rahu", SIGNS.index("Gemini"))
assert (rahu["base_years"], rahu["dignity_adjustment"], rahu["years"]) == (8, 1, 9)
mars = rath_period_years(SIGNS.index("Aries"), "Mars", SIGNS.index("Capricorn"))
assert mars["adjustment_source"] == "p40a_grahas" and mars["dignity_adjustment"] == 1
def test_dual_lord_cycle_stays_undecided() -> None:
"""Scorpio's lords sit in Aquarius and Taurus (tied). Aquarius's lords then
sit in Leo and Scorpio (tied), which asks for Scorpio again."""
from scripts.narayana_rath import resolve_dual_lord
decision = resolve_dual_lord(SIGNS.index("Scorpio"), _nine(
Mars=305.0, Ketu=35.0, Rahu=215.0, Saturn=125.0,
Sun=5.0, Moon=6.0, Mercury=7.0, Jupiter=8.0, Venus=9.0))
assert decision["status"] == "undecided"
assert decision["lord"] is None
assert decision["rule"] == "d"