docs(tasks): Niryaana Shoola start sign follows upstream ayur rasi strength (9f08b57b)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
Jesse_Chen
2026-10-18 22:24:25 +08:00
co-authored by Claude Opus 5.5
parent 463e13fa23
commit 9f5c0084db
4 changed files with 372 additions and 4 deletions
@@ -0,0 +1,3 @@
# 上游 Niryaana Shoola @ 9f08b57b(2026-10-18)
起运星座按 `pvr_niryaana_shoola_ayur_rasi_strength_v1` 选:比较第二宫与第八宫,依次看宫内行星数、所有行星的星座相位数(Jaimini 星座相位,宫主不加权)、宫内有无入旺行星;仍平手则 `blocked`,不硬选。行星集合与上游 full-reading 传入的一致(含罗睺、计都)。只出主运。
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Source-bounded Niryaana Shoola Dasha Mahadasha producer.
Niryaana Shoola and Shoola are adjacent, but distinct, Jaimini systems. This
module implements the former only. The latter retains its Lagna/seventh,
fixed-nine-year implementation in ``scripts/shoola_dasha.py``.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from scripts.dasha_profile_arbitration import attach_profile_arbitration
from scripts.rashi_dasha_explicit_contract import add_calendar_years
PROFILE_ID = "pvr_niryaana_shoola_v1"
LEGACY_MISCLASSIFIED_PROFILE_ID = "goel_nirayana_shoola_v1"
PYJHORA_PROFILE_ID = "pyjhora_2nd_8th_observation_v1"
TOTAL_CYCLE = 96
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
"Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon", 4: "Sun", 5: "Mercury",
6: "Venus", 7: "Mars", 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
}
_MOVABLE_SIGNS = {0, 3, 6, 9}
_FIXED_SIGNS = {1, 4, 7, 10}
_EXALTED_SIGNS = {
"Sun": {0}, "Moon": {1}, "Mars": {9}, "Mercury": {5},
"Jupiter": {3}, "Venus": {11}, "Saturn": {6},
}
def _sign_index(longitude: float) -> int:
return int(float(longitude) % 360.0 // 30.0)
def _forward_order(start: int) -> list[int]:
return [(int(start) + offset) % 12 for offset in range(12)]
def _reverse_order(start: int) -> list[int]:
return [(int(start) - offset) % 12 for offset in range(12)]
def _rasi_aspects(source_sign_index: int, target_sign_index: int) -> bool:
"""Return the standard Jaimini sign aspect relation."""
if source_sign_index in _MOVABLE_SIGNS:
return target_sign_index in _FIXED_SIGNS and target_sign_index != (
(source_sign_index + 1) % 12
)
if source_sign_index in _FIXED_SIGNS:
return target_sign_index in _MOVABLE_SIGNS and target_sign_index != (
(source_sign_index - 1) % 12
)
return target_sign_index in {2, 5, 8, 11} and target_sign_index != source_sign_index
def select_niryaana_shoola_start_sign(
*, ascendant_longitude: float, planet_longitudes: dict[str, float],
) -> dict[str, Any]:
"""Select the stronger second/eighth rasi under PVR's ayur-dasha scope.
PVR's general rasi rules are expressly adapted for ayur dashas: all rasi
aspects matter equally and the sign lord's aspect has no special weight.
The text does not provide a further longevity-specific tie-break, so a
tie remains blocked instead of borrowing Narayana's lord rules.
"""
lagna = _sign_index(ascendant_longitude)
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
positions = {
name: float(longitude)
for name, longitude in planet_longitudes.items()
if isinstance(longitude, (int, float))
}
candidates = {}
for sign_index in (second, eighth):
occupants = sorted(
name for name, longitude in positions.items()
if _sign_index(longitude) == sign_index
)
aspecting = sorted(
name for name, longitude in positions.items()
if _rasi_aspects(_sign_index(longitude), sign_index)
)
exalted = sorted(
name for name in occupants
if sign_index in _EXALTED_SIGNS.get(name, set())
)
candidates[sign_index] = {
"sign_index": sign_index,
"sign": SIGNS[sign_index],
"occupants": occupants,
"occupant_count": len(occupants),
"rasi_aspecting_planets": aspecting,
"rasi_aspect_count": len(aspecting),
"exalted_occupants": exalted,
}
second_score = (
candidates[second]["occupant_count"],
candidates[second]["rasi_aspect_count"],
bool(candidates[second]["exalted_occupants"]),
)
eighth_score = (
candidates[eighth]["occupant_count"],
candidates[eighth]["rasi_aspect_count"],
bool(candidates[eighth]["exalted_occupants"]),
)
payload = {
"profile": "pvr_niryaana_shoola_ayur_rasi_strength_v1",
"ascendant_sign_index": lagna,
"second_sign_index": second,
"eighth_sign_index": eighth,
"candidates": candidates,
"selection_levels": [
"occupant_count",
"all_planet_rasi_aspect_count",
"exalted_occupant_presence",
],
}
if second_score == eighth_score:
return {
**payload,
"status": "blocked",
"reason": "niryaana_shoola_ayur_rasi_strength_tie_requires_source_rule",
}
selected = second if second_score > eighth_score else eighth
return {
**payload,
"status": "selected",
"selected_sign_idx": selected,
"selected_sign": SIGNS[selected],
}
def resolve_lagna_seventh_tie_break(
*,
lagna_sign_index: int,
planets: dict[str, dict[str, Any]] | None = None,
planet_strengths: dict[str, float] | None = None,
) -> dict[str, Any]:
"""Explicit Shoola-only tie-break retained for its separate producer."""
planets = planets if isinstance(planets, dict) else {}
strengths = planet_strengths if isinstance(planet_strengths, dict) else {}
rows = []
for label, sign in (("lagna", int(lagna_sign_index) % 12), ("seventh", (int(lagna_sign_index) + 6) % 12)):
lord = SIGN_LORDS[sign]
lord_data = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
degree = lord_data.get("degree_in_sign", lord_data.get("degree"))
if not isinstance(degree, (int, float)) and isinstance(lord_data.get("lon"), (int, float)):
degree = float(lord_data["lon"]) % 30.0
occupants = [
name for name, data in planets.items()
if isinstance(data, dict) and int(data.get("sign_idx", -1)) % 12 == sign
]
rows.append({
"label": label, "sign_index": sign, "sign": SIGNS[sign],
"occupants": occupants, "occupant_count": len(occupants), "lord": lord,
"lord_strength": float(strengths[lord]) if isinstance(strengths.get(lord), (int, float)) else None,
"lord_degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None,
})
keys = [
(row["occupant_count"], row["lord_strength"] if row["lord_strength"] is not None else float("-inf"), row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else float("-inf"))
for row in rows
]
if keys[0] == keys[1]:
return {"status": "blocked", "reason": "Tie-break evidence remains equal after occupants, lord strength, and lord degree.", "rows": rows}
selected = rows[0] if keys[0] > keys[1] else rows[1]
return {"status": "selected", "selected": selected, "rows": rows, "rule": "occupants_then_sign_lord_strength_then_sign_lord_degree"}
def _sign_years(sign_index: int) -> int:
"""PVR Niryaana Shoola: movable/fixed/dual = 7/8/9 years."""
if int(sign_index) % 12 in {1, 4, 7, 10}:
return 8
if int(sign_index) % 12 in {2, 5, 8, 11}:
return 9
return 7
def niryaana_shoola_applicability(
*, ascendant_longitude: float, second_strength: float, eighth_strength: float,
) -> dict[str, Any]:
"""Select the stronger second/eighth sign; the source supplies no tie-break."""
lagna = _sign_index(ascendant_longitude)
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
second_strength, eighth_strength = float(second_strength), float(eighth_strength)
if second_strength == eighth_strength:
return {
"applicable": False, "execution_status": "blocked", "ascendant_sign_index": lagna,
"second_sign_index": second, "eighth_sign_index": eighth,
"second_strength": second_strength, "eighth_strength": eighth_strength,
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
"reason": "The cited Niryaana Shoola rule does not provide a tie-break for equal second/eighth strength.",
}
start = second if second_strength > eighth_strength else eighth
return {
"applicable": True, "ascendant_sign_index": lagna, "second_sign_index": second,
"eighth_sign_index": eighth, "second_strength": second_strength,
"eighth_strength": eighth_strength, "starting_sign_index": start,
"starting_sign": SIGNS[start], "direction": "forward" if start % 2 == 0 else "reverse",
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
"reason": "Niryaana Shoola starts from the stronger second/eighth sign; odd seeds run forward and even seeds reverse.",
}
def calculate_niryaana_shoola_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
"""Calculate source-bounded Niryaana Shoola Mahadashas only.
PVR's chapter proposes borrowing Narayana rules for children rather than
specifying its own AD/PD algorithm, so child periods remain deliberately
blocked. This system is for longevity analysis and is not relationship
timing evidence.
"""
profile_id = birth_info.get("profile_id") or PROFILE_ID
if profile_id == LEGACY_MISCLASSIFIED_PROFILE_ID:
return {
"applicable": False, "execution_status": "blocked", "confidence_status": "blocked",
"profile_id": profile_id, "major": [], "antardasha": [], "pratyantardasha": [],
"reason": "goel_nirayana_shoola_v1 conflates Niryaana Shoola with Shoola Dasha; choose pvr_niryaana_shoola_v1 or the separate Shoola producer.",
}
if profile_id not in {PROFILE_ID, PYJHORA_PROFILE_ID}:
return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Unsupported Niryaana Shoola profile_id: " + str(profile_id)}
required = ("birth_datetime", "ascendant_longitude", "second_strength", "eighth_strength")
missing = [field for field in required if birth_info.get(field) is None]
if missing:
return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Missing required input: " + ", ".join(missing)}
birth = birth_info["birth_datetime"]
if isinstance(birth, str):
birth = datetime.fromisoformat(birth)
applicability = niryaana_shoola_applicability(
ascendant_longitude=float(birth_info["ascendant_longitude"]),
second_strength=float(birth_info["second_strength"]),
eighth_strength=float(birth_info["eighth_strength"]),
)
if not applicability["applicable"]:
return {**applicability, "profile_id": profile_id, "total_cycle": TOTAL_CYCLE, "major": [], "antardasha": [], "pratyantardasha": [], "confidence_status": "blocked"}
order = _forward_order(applicability["starting_sign_index"]) if applicability["direction"] == "forward" else _reverse_order(applicability["starting_sign_index"])
try:
rows, cursor = [], birth
for sign_index in order:
years = _sign_years(sign_index)
end = add_calendar_years(cursor, years)
rows.append({"level": "mahadasha", "sign_index": sign_index, "sign": SIGNS[sign_index], "years": years, "start_date": cursor.isoformat(timespec="seconds"), "end_date": end.isoformat(timespec="seconds")})
cursor = end
except ValueError:
return {**applicability, "profile_id": profile_id, "execution_status": "blocked", "confidence_status": "blocked", "major": [], "reason": "Calendar-year anniversary cannot be represented for this birth date."}
payload = {
**applicability, "profile_id": profile_id, "execution_status": "executed",
"confidence_status": "source_bounded", "verification_status": "pvr_rule_replay",
"period_depth_status": "mahadasha_executed_ad_pd_blocked_source_rule",
"total_cycle": TOTAL_CYCLE, "duration_basis": "calendar_years", "major": rows,
"antardasha": [], "pratyantardasha": [],
"relationship_applicability": "not_applicable_longevity_specific",
"reason": "PVR Niryaana Shoola MD replay. Its child-period rule is not independently specified, and longevity-specific periods are not relationship timing evidence.",
"source_locator": "PVR, An Integrated Approach to Vedic Astrology, ch. 22.2, source pages 285-292.",
}
attach_profile_arbitration(payload, "niryaana_shoola", profile_id)
return payload
@@ -0,0 +1,102 @@
"""Regression tests for the distinct Niryaana Shoola rule family."""
from __future__ import annotations
from datetime import datetime
from scripts.niryaana_shoola_dasha import (
PROFILE_ID,
calculate_niryaana_shoola_dasha,
niryaana_shoola_applicability,
select_niryaana_shoola_start_sign,
)
def test_niryaana_shoola_selects_stronger_second_or_eighth() -> None:
second = niryaana_shoola_applicability(
ascendant_longitude=0.0, second_strength=2.0, eighth_strength=1.0,
)
eighth = niryaana_shoola_applicability(
ascendant_longitude=0.0, second_strength=1.0, eighth_strength=2.0,
)
assert second["starting_sign"] == "Taurus"
assert second["direction"] == "reverse"
assert eighth["starting_sign"] == "Scorpio"
assert eighth["direction"] == "reverse"
def test_niryaana_shoola_uses_ayur_specific_rasi_strength() -> None:
selection = select_niryaana_shoola_start_sign(
ascendant_longitude=0.0,
planet_longitudes={"Mars": 31.0, "Sun": 91.0},
)
assert selection["status"] == "selected"
assert selection["selected_sign"] == "Taurus"
assert selection["selection_levels"] == [
"occupant_count",
"all_planet_rasi_aspect_count",
"exalted_occupant_presence",
]
assert selection["candidates"][1]["occupants"] == ["Mars"]
assert selection["candidates"][7]["rasi_aspecting_planets"] == ["Sun"]
def test_niryaana_shoola_blocks_unresolved_ayur_rasi_strength_tie() -> None:
selection = select_niryaana_shoola_start_sign(
ascendant_longitude=0.0,
planet_longitudes={},
)
assert selection["status"] == "blocked"
assert selection["reason"] == "niryaana_shoola_ayur_rasi_strength_tie_requires_source_rule"
def test_niryaana_shoola_blocks_equal_second_eighth_without_invented_tie_break() -> None:
result = niryaana_shoola_applicability(
ascendant_longitude=0.0, second_strength=1.0, eighth_strength=1.0,
)
assert result["execution_status"] == "blocked"
assert "does not provide a tie-break" in result["reason"]
def test_niryaana_shoola_uses_modality_years_and_parity_direction() -> None:
result = calculate_niryaana_shoola_dasha({
"profile_id": PROFILE_ID,
"birth_datetime": datetime(2000, 1, 1, 12, 0),
"ascendant_longitude": 15.0,
"second_strength": 2.0,
"eighth_strength": 1.0,
})
assert result["execution_status"] == "executed"
assert result["total_cycle"] == 96
assert [row["sign"] for row in result["major"][:3]] == ["Taurus", "Aries", "Pisces"]
assert [row["years"] for row in result["major"][:3]] == [8, 7, 9]
assert result["period_depth_status"] == "mahadasha_executed_ad_pd_blocked_source_rule"
assert result["antardasha"] == []
assert result["relationship_applicability"] == "not_applicable_longevity_specific"
def test_legacy_goel_profile_is_blocked_as_misclassified() -> None:
result = calculate_niryaana_shoola_dasha({
"profile_id": "goel_nirayana_shoola_v1",
"birth_datetime": datetime(2000, 1, 1, 12, 0),
"ascendant_longitude": 0.0,
"lagna_strength": 2.0,
"seventh_strength": 1.0,
})
assert result["execution_status"] == "blocked"
assert "conflates Niryaana Shoola with Shoola Dasha" in result["reason"]
def test_pyjhora_observation_profile_replays_same_native_rule_shape() -> None:
result = calculate_niryaana_shoola_dasha({
"profile_id": "pyjhora_2nd_8th_observation_v1",
"birth_datetime": "2000-01-01T00:00:00",
"ascendant_longitude": 15.0,
"second_strength": 2.0,
"eighth_strength": 1.0,
})
assert result["execution_status"] == "executed"
assert result["starting_sign"] == "Taurus"
assert result["profile_arbitration"]["selection_status"] == "explicit_profile_selected"
@@ -16,7 +16,7 @@
| 3 | 上游同时把 Niryaana Shoola 从恋爱婚姻合盘的应期里排除(寿元类大运,不用于关系时间) | 上游 `validate_full_synastry_packets.py`:该行固定 `not_applicable` |
| 4 | 只在 staging:`origin/main` 还没有 `rashi_dashas_native.py`,生产不受影响 | — |
另:上游 `cba6a031`(10-18 晚)把 full-reading 接上了,二宫 / 八宫力量取 `bhava_bala.tri_component` 的 `total`。Claude 用两位公开名人复测:两张盘都能算出来,奇顺偶逆与 7 / 8 / 9 年都对;**但用我们的 Jaimini 星座力量(`stronger_sign`)比,两张盘选出的起运星座都和上游相反**(一张上游选双子、我们选射手;另一张上游选狮子、我们选水瓶)。上游所用的那套宫位力量在它自己的代码里标着「替代口径、待验证」。用哪种力量来比二宫 / 八宫,已请占星师确认(见 H4)。
另:上游 `cba6a031` 一度用 Bhava Bala 总分比二宫 / 八宫,Claude 复测发现两张名人盘起运都与 Jaimini 星座力量相反并提出质疑;占星师随后在 `9f08b57b` 改为寿命类星座力量规则(H4),不再读 Bhava Bala,平手明确 `blocked`。Claude 10-18 复测 `9f08b57b`:专项测试全过;两张名人盘一张起射手、一张起水瓶,奇顺偶逆与 7 / 8 / 9 年都对。
## 根因
@@ -29,20 +29,20 @@ BUG-1283(10-09)把旧 `niryaana_shoola_dasha.py` 换成 `goel_nirayana_shool
| H1 | Niryaana Shoola 采用占星师 10-18 的 PVR 口径(`pvr_niryaana_shoola_v1`)。**推翻 BUG-1283 选定的 `goel_nirayana_shoola_v1`**;BUG-1283 的另一半(旧 `niryaana_shoola_dasha.py` 不得被新路径调用)保持不变 |
| H2 | Shoola 保持现状(上升 / 七宫取强、每宫 9 年)——那本来就是 Shoola 的规则 |
| H3 | 合盘不用 Niryaana Shoola 做关系应期(与上游一致);本单只影响个人完整数据版报告和 full-reading 的 `niryaana_shoola_dasha` 键 |
| H4 | **力量比较先用 Jaimini 星座力量**(本文件已有的 `rashi_strengths` / `stronger_sign`,与 Chara、Narayana 等星座大运同一套),不用上游的 Bhava Bala。理由:Niryaana Shoola 是 Jaimini 星座大运,上游所用 Bhava Bala 替代口径在其代码中标为待验证。**占星师若确认应该用 Bhava Bala,再改**;开工前 Claude 会在评论里同步她的答复 |
| H4 | **起运星座按上游 `9f08b57b` 的寿命类星座力量选**(`pvr_niryaana_shoola_ayur_rasi_strength_v1`,占星师 10-18 裁定,推翻本单上一版「用我们的 `stronger_sign`」与上游 `cba6a031` 的 Bhava Bala):比较第二宫与第八宫,依次看 ① 宫内行星数 ② 所有行星对它的 Jaimini 星座相位数(宫主不加权)③ 宫内有无入旺行星;三层都平手则「本轮不上表」并写明「二宫与八宫力量相同,原书没有给出进一步的裁决规则」,不得借用 Narayana 的宫主规则硬选。行星集合与上游一致(含罗睺、计都)。在 `rashi_dashas_native.py` 里按上游函数逐条实现,副本见 `docs/research/upstream_niryaana_shoola_9f08b57b/` |
| H5 | **只出主运,不出子运**(与上游一致:上游说明 PVR 没有独立给出子运规则,子运标为 blocked)。报告里该表只列主运并写一句「子运规则未定,暂不列出」 |
## 硬红线
1. 在 `scripts/rashi_dashas_native.py` 内改,不 import 旧的 `scripts/niryaana_shoola_dasha.py`(BUG-1283 语法树断言保持绿);不整份搬上游文件(上游版本依赖我们没有的 `dasha_profile_arbitration` 等)。
2. 力量比较用本文件已有的 `rashi_strengths` / `stronger_sign`,平手时按现有 tie-break 规则走,仍分不出就「本轮不上表」,不得默认挑一个。
2. 力量比较只用 H4 的三层规则,**不用** `rashi_strengths` / `stronger_sign`(那套带宫主规则,PVR 说寿命类大运不适用);平手就「本轮不上表」,不得默认挑一个。
3. 全量 pytest 与基线逐条比对;改既有断言写三栏。
## 任务分解
| # | 内容 | 验收标准 |
| --- | --- | --- |
| T1 | `niryaana_shoola` 改为 PVR:第二宫与第八宫取强起运;起运星座奇数顺行、偶数逆行;动 / 固 / 双星座 7 / 8 / 9 年(12 宫一轮 96 年);**只出主运**(H5);profile 名 `pvr_niryaana_shoola_v1`,出处写 PVR《Vedic Astrology: An Integrated Approach》 | 新测试:① 同一张盘 Shoola 与 Niryaana Shoola 的首运或年数不再全同;② 白羊上升样例起运在金牛 / 天蝎中较强者;③ 奇偶方向各一例;④ 动 / 固 / 双年数各一例;⑤ 在上游测试样例上(样例直接给定二宫 / 八宫力量)与上游 `pvr_niryaana_shoola_v1` 的主运序列一致(把期望值抄过来作 golden,写明出处);⑥ 两位公开名人各一条:记录我们选出的起运星座与上游 `cba6a031` 的差异,写进 PROGRESS,不作为失败 |
| T1 | `niryaana_shoola` 改为 PVR:第二宫与第八宫取强起运;起运星座奇数顺行、偶数逆行;动 / 固 / 双星座 7 / 8 / 9 年(12 宫一轮 96 年);**只出主运**(H5);profile 名 `pvr_niryaana_shoola_v1`,出处写 PVR《Vedic Astrology: An Integrated Approach》 | 新测试:① 同一张盘 Shoola 与 Niryaana Shoola 的首运或年数不再全同;② 白羊上升样例起运在金牛 / 天蝎中较强者;③ 奇偶方向各一例;④ 动 / 固 / 双年数各一例;⑤ 在上游测试样例上(样例直接给定二宫 / 八宫力量)与上游 `pvr_niryaana_shoola_v1` 的主运序列一致(把期望值抄过来作 golden,写明出处);⑥ 两位公开名人各一条,起运星座与主运序列必须与上游 `9f08b57b` 一致(Claude 10-18 实测:一张起射手、一张起水瓶);⑦ 构造一张三层都平手的盘,结果为「本轮不上表」 |
| T2 | full-reading 的 `niryaana_shoola_dasha` 键改用 T1 结果(替换 #81 的占位) | 上游合盘校验器读这一行仍为 `not_applicable`(合盘不用);个人报告节表显示新表 |
| T3 | 记录:BUG_HISTORY(新编号,关联 BUG-1283,写明「推翻其所选 profile」)、CHANGELOG(报告里 Niryaana Shoola 大运表变了)、PROGRESS | 文档齐 |