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"