feat(tajika): year lord by the Panchadhikari rule, ported from upstream c7117f92 (BUG-1214)

Round-4 astrologer ruling Q3. tajika.calc_panchadhikari_year_lord and its
missing pieces (Tri-Rasi tables, planet index maps, ayanamsa-aware PyJHora
lookups) plus references/tajika_panchavargiya_rules.json (blob e8c15ac3) are
ported verbatim from yinduzhanxing c7117f92, with one keyword added
(muntha_sign_idx): upstream builds the Muntha-lord candidate from the annual
Lagna + years; ours stays natal Lagna + completed years (BUG-1212).

Both annual producers (solar_return_full_report, cmd_tajika) call the new
tajika_year_lord.select_annual_year_lord, so the pack conflict gate compares
the same rule. Output carries candidates / candidate_list with Panchavargiya
score and engine, aspect flags, selection_basis and a one-sentence basis
(zh/en). The product path always uses the native proxy (PyJHora is AGPL, absent
from the API image and CI); JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1 restores the
upstream PyJHora lookup for research. Card varshesha_basis and the report
'Selection basis' rows show the sentence; the BUG-1212 stand-in label is gone.
Mudda Dasha and Tri-Pataka follow the selected year lord.

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 16:30:28 +08:00
co-authored by Claude Opus 5.5
parent bc03bba26b
commit 0afcc2d9a3
11 changed files with 633 additions and 45 deletions
+16 -4
View File
@@ -234,9 +234,12 @@ def test_real_annual_producers_agree_without_identical_metadata(reader_packet) -
field = annual["year_lord"]
assert field["status"] == "partial_verified"
left, right = [item["value"] for item in field["producers_checked"]]
assert "year_lord_sign" in left
assert "year_lord_sign" not in right
# 原值:左(太阳返照)有 year_lord_sign、右(Tajika)没有——元数据不同仍判一致
# 新值:两个生产者共用 tajika_year_lord,键相同;断言选出的年主、候选与依据一致
# 原因:BUG-1214 两个生产者改为同一 Panchadhikari 选法(任务书 T2)
assert left["year_lord"] == right["year_lord"]
assert left["candidates"] == right["candidates"]
assert left["selection_basis"] == right["selection_basis"]
assert field["data"] == left
sections = annual["report_sections"]
assert "冲突" not in repr(sections["executive_summary"]["summary_lines"])
@@ -252,7 +255,12 @@ def test_reader_annual_cells_use_real_distinct_years_and_keep_return_dates(reade
assert "-" not in lords + signs
assert len(set(lords)) >= 2
assert len(set(signs)) >= 2
assert "| Selection basis | Muntha 所在星座的守护星(当前本地口径) |" in reader
# 原值:「| Selection basis | Muntha 所在星座的守护星(当前本地口径) |」(开工基线即已失败:
# BUG-1212 把该文案改成了「暂用 Muntha 主星…」,本条未跟改)
# 新值:每个年度都有 Panchadhikari 选法依据一句话
# 原因:BUG-1214(任务书 T2)
bases = re.findall(r"^\| Selection basis \| ([^|]+) \|$", reader, re.M)
assert len(bases) == len(lords) and all("年盘上升" in basis for basis in bases)
assert "| Annual ascendant sign | - |" not in reader
timing = reader_packet["worksheets"]["timing_and_predictive_systems"]
for year in ("2027", "2028"):
@@ -274,7 +282,11 @@ def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) -
)
assert annual["year_lord"]["status"] == "conflict"
reader = _pl9_export_markdown_for_edition(packet, "reader_main")
assert "| Varshesha | 太阳返照:火星;Tajika:金星(两者不同) |" in reader
# 原值:「太阳返照:火星;Tajika:金星(两者不同)」(当年 / 次年的 Muntha 主星)
# 新值:「太阳返照:火星;Tajika:月亮(两者不同)」(当年 / 次年按 Panchadhikari 选出的年主)
# 原因:BUG-1214 年主改为 Panchadhikari 选法(任务书 T2);本用例只测冲突两值都保留
assert (current["year_lord"], later["year_lord"]) == ("Mars", "Moon")
assert "| Varshesha | 太阳返照:火星;Tajika:月亮(两者不同) |" in reader
assert "两种计算口径给出的年主不同,暂不合并" in reader
assert "| Muntha sign | 白羊座 |" in reader
assert _leak_hits(reader) == []
@@ -0,0 +1,163 @@
"""Tajika year lord by the Panchadhikari rule (BUG-1214, round-4 ruling Q3).
`tajika.calc_panchadhikari_year_lord` is ported from upstream yinduzhanxing
c7117f92 together with `references/tajika_panchavargiya_rules.json`. Upstream's
`tests/test_tajika.py` has no Panchadhikari case and its CLI smoke cases need a
private local subject chart, so the rule is pinned here with synthetic
longitudes (selection branches) and a fictional chart (both producers agree).
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
import scripts.tajika as tajika
from scripts.annual_tajika_pack import build_annual_tajika_pack
from scripts.tajika_year_lord import selection_basis_text
ROOT = Path(__file__).resolve().parents[1]
UPSTREAM_RULES_BLOB = "e8c15ac31246a1b923f2d108729c319f8731d179" # c7117f92:references/tajika_panchavargiya_rules.json
@pytest.fixture(autouse=True)
def _native_panchavargiya(monkeypatch):
# Product path: native-proxy Panchavargiya unless the research opt-in is set.
monkeypatch.delenv("JYOTISH_YEAR_LORD_PYJHORA_REFERENCE", raising=False)
def _scores(monkeypatch, scores: dict) -> None:
def fake(planet, lon_value, **_kwargs):
return {"score": scores.get(planet, 0.0), "engine": "native_proxy", "components": {}}
monkeypatch.setattr(tajika, "_resolve_year_lord_panchavargiya_score", fake)
def _lon(sign_idx: int, deg: float = 10.0) -> float:
return sign_idx * 30.0 + deg
def test_rules_json_is_the_upstream_blob() -> None:
data = (ROOT / "references/tajika_panchavargiya_rules.json").read_bytes()
assert hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() == UPSTREAM_RULES_BLOB
assert set(tajika._PANCHAVARGIYA_RULES) == {"source", "tajika_relation", "hudda", "drekkana_lords"}
# Annual Lagna Aries (0). Benefic Tajika aspects reach the Lagna from signs
# 3/5/9/11 away (5th/9th/3rd/11th), malefic from 1/4/7/10.
BASE = {"Sun": _lon(1), "Moon": _lon(1), "Mars": _lon(1), "Mercury": _lon(1),
"Jupiter": _lon(1), "Venus": _lon(1), "Saturn": _lon(1)}
def _select(planet_lons, **kwargs):
defaults = dict(annual_asc_sign_idx=0, natal_lagna_sign_idx=3, years_from_dob=0, muntha_sign_idx=6)
defaults.update(kwargs)
return tajika.calc_panchadhikari_year_lord(planet_lons, **defaults)
def test_benefic_shortlist_takes_the_strongest(monkeypatch) -> None:
# Candidates: natal Lagna lord Moon (Cancer), Muntha lord Venus (Libra),
# annual Lagna lord Mars, new-year lord and Tri-Rasi lord (day/night).
lons = {**BASE, "Moon": _lon(4), "Venus": _lon(8), "Mars": _lon(1)} # Moon in Leo (5th), Venus in Sag (9th)
_scores(monkeypatch, {"Moon": 12.0, "Venus": 15.0, "Mars": 20.0})
result = _select(lons)
assert result["selection_basis"] == "panchadhikari_benefic_shortlist_pvb"
assert result["year_lord"] == "Venus" # Mars is stronger but casts no benefic aspect
rows = {row["planet"]: row for row in result["candidates"]}
assert rows["Moon"]["benefic_aspects_asc"] and rows["Venus"]["benefic_aspects_asc"]
assert not rows["Mars"]["benefic_aspects_asc"]
def test_single_benefic_wins_even_when_weak(monkeypatch) -> None:
lons = {**BASE, "Moon": _lon(2)} # Moon in Gemini: 3rd from Aries
_scores(monkeypatch, {"Moon": 1.0, "Mars": 20.0, "Venus": 20.0})
result = _select(lons)
assert (result["year_lord"], result["selection_basis"]) == ("Moon", "panchadhikari_single_benefic")
def test_malefic_shortlist_when_no_benefic(monkeypatch) -> None:
lons = {**BASE, "Moon": _lon(3), "Venus": _lon(6), "Mars": _lon(1)} # Moon 4th, Venus 7th, Mars 2nd
_scores(monkeypatch, {"Moon": 9.0, "Venus": 11.0, "Mars": 30.0})
result = _select(lons)
assert (result["year_lord"], result["selection_basis"]) == ("Venus", "panchadhikari_malefic_shortlist_pvb")
def test_strongest_of_all_when_no_candidate_aspects(monkeypatch) -> None:
lons = {**BASE, "Moon": _lon(1), "Venus": _lon(5), "Mars": _lon(7)} # 2nd / 6th / 8th: no Tajika aspect
lons = {planet: (lon if planet in ("Moon", "Venus", "Mars") else _lon(11)) for planet, lon in lons.items()} # others 12th
_scores(monkeypatch, {"Moon": 9.0, "Venus": 11.0, "Mars": 14.0, "Sun": 3.0, "Jupiter": 2.0, "Saturn": 1.0, "Mercury": 1.0})
result = _select(lons)
assert result["selection_basis"] == "panchadhikari_pvb_fallback_all_candidates"
assert result["year_lord"] == max(result["candidates"], key=lambda row: row["panchavargiya_score"])["planet"]
def test_muntha_candidate_uses_the_supplied_natal_muntha(monkeypatch) -> None:
_scores(monkeypatch, {})
lons = dict(BASE)
ours = _select(lons, years_from_dob=5, muntha_sign_idx=9) # natal Muntha Capricorn
upstream = _select(lons, years_from_dob=5, muntha_sign_idx=None) # upstream: annual Lagna + years = Virgo
assert next(row for row in ours["candidates"] if row["source"] == "muntha_lord")["planet"] == "Saturn"
assert next(row for row in upstream["candidates"] if row["source"] == "muntha_lord")["planet"] == "Mercury"
assert (ours["muntha_sign"], upstream["muntha_sign"]) == ("Capricorn", "Virgo")
@pytest.mark.parametrize("basis", [
"panchadhikari_single_benefic", "panchadhikari_benefic_shortlist_pvb", "panchadhikari_single_malefic",
"panchadhikari_malefic_shortlist_pvb", "panchadhikari_pvb_fallback_all_candidates",
])
def test_every_basis_has_a_sentence(basis: str) -> None:
zh, en = selection_basis_text({"selection_basis": basis, "year_lord": "Mars", "panchavargiya_engine": "native_proxy",
"candidates": [{"planet": "Mars", "benefic_aspects_asc": True, "malefic_aspects_asc": True}]})
assert "Mars" in zh and zh.endswith("(Panchavargiya 为本地近似计算)")
assert "Mars" in en and en.endswith("(Panchavargiya is a local approximation)")
FICTIONAL = {"birth": {"date": "1990-01-01", "time": "12:00:00", "latitude": 39.9, "longitude": 116.4, "utc_offset": "+08:00"},
"settings": {"ayanamsa": "raman"}}
@pytest.mark.parametrize("target_year", [2024, 2025, 2026])
def test_both_annual_producers_select_the_same_year_lord(target_year: int) -> None:
pack = build_annual_tajika_pack({**FICTIONAL, "target_year": target_year})
field = pack["year_lord"]
assert field["status"] == "partial_verified", field
values = [row["value"] for row in field["producers_checked"]]
assert {row["producer"] for row in field["producers_checked"]} == {"solar_return", "tajika"}
for value in values:
assert value["selection_basis"].startswith("panchadhikari_")
assert value["muntha_basis"] == "natal_ascendant_plus_completed_years"
assert value["selection_basis_text"] and value["selection_basis_text_en"]
assert {row["panchavargiya_engine"] for row in value["candidate_list"]} == {"native_proxy"}
assert sum(row["selected"] for row in value["candidate_list"]) == 1
assert values[0]["candidates"] == values[1]["candidates"]
assert values[0]["year_lord"] == values[1]["year_lord"]
# Mudda Dasha starts from the selected year lord on both lanes.
assert pack["mudda_dasha"]["data"]["varsha_lord"] == values[0]["year_lord"]
def test_pyjhora_reference_runs_only_on_the_research_opt_in(monkeypatch) -> None:
"""The product path never hands PyJHora (AGPL, absent from the API image)
the annual JD; the research opt-in restores the upstream lookup."""
from scripts.solar_return import calc_solar_return_chart
from scripts.tajika_year_lord import PYJHORA_REFERENCE_ENV, select_annual_year_lord
seen = []
def fake_external(planet, *, annual_jd, **_kwargs):
seen.append(annual_jd)
return None
import sys
from scripts import tajika_year_lord
selector_module = sys.modules[tajika_year_lord.calc_panchadhikari_year_lord.__module__]
monkeypatch.setattr(selector_module, "_external_pyjhora_panchavargiya_score", fake_external)
sr = calc_solar_return_chart(1990, 1, 1, 12, 0, 39.9, 116.4, 8.0, 2026, ayanamsa_name="raman")
kwargs = dict(natal_asc_sign_idx=sr["birth_asc_sign_idx"], completed_years=36, ayanamsa_name="raman",
birth_lat=39.9, birth_lon=116.4, birth_tz=8.0)
select_annual_year_lord(sr, **kwargs)
assert seen and set(seen) == {None}
seen.clear()
monkeypatch.setenv(PYJHORA_REFERENCE_ENV, "1")
select_annual_year_lord(sr, **kwargs)
assert seen and set(seen) == {sr["solar_return"]["jd_ut"]}
+39 -9
View File
@@ -1,8 +1,10 @@
"""Year lord is labelled as a Muntha-lord stand-in for Panchavargiya (BUG-1212).
"""Year lord shows its selection basis on every surface (BUG-1212 → BUG-1214).
Astrologer rulings 2026-10-03 甲11: Muntha = natal Lagna + completed years
(kept); year lord by Panchavargiya is not implemented (native producer blocked),
so every surface that shows the year lord says so. Fictional chart.
Astrologer rulings 2026-10-03 甲11 and round-4 Q3: Muntha = natal Lagna +
completed years (kept); the year lord is chosen by the Panchadhikari rule
(`tajika_year_lord`), and the card and both report editions carry its
one-sentence basis instead of the BUG-1212 "Muntha lord for now" label.
Fictional chart.
"""
from __future__ import annotations
@@ -12,7 +14,9 @@ import re
from datetime import datetime
from pathlib import Path
from scripts.consultation_native_layers import YEAR_LORD_BASIS, build_annual_tajika
import pytest
from scripts.consultation_native_layers import build_annual_tajika
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
from scripts.solar_return import SIGNS, solar_return_full_report
from tests.test_report_english_edition import CASES, _packet
@@ -20,14 +24,28 @@ from tests.test_report_english_edition import CASES, _packet
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture(autouse=True)
def _native_panchavargiya(monkeypatch):
# Product path: native-proxy Panchavargiya unless the research opt-in is set.
monkeypatch.delenv("JYOTISH_YEAR_LORD_PYJHORA_REFERENCE", raising=False)
def test_report_shows_the_basis_in_both_editions() -> None:
# 原值:两版都断言「暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入」
# 新值:断言 Panchadhikari 选法依据一句话(中文版含「年盘上升」与「本地近似计算」,
# 英文版是同一句的英文、不含中文)
# 原因:BUG-1214 接入 Panchadhikari 年主选法,去掉 BUG-1212 的暂用标注(任务书 T2)
packet = _packet(CASES["day"])
zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main")
english = copy.deepcopy(packet)
english["report_language"] = "en"
en = _pl9_export_markdown_for_edition(english, "reader_main")
assert zh.count("| Selection basis | 暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入 |") >= 1
assert "| Selection basis | Muntha sign lord for now; the astrologer's rule is Panchavargiya, not yet wired |" in en
zh_rows = [line for line in zh.splitlines() if line.startswith("| Selection basis |")]
en_rows = [line for line in en.splitlines() if line.startswith("| Selection basis |")]
assert zh_rows and all("年盘上升" in row and "Panchavargiya 为本地近似计算" in row for row in zh_rows)
assert en_rows and all("annual Lagna" in row and "local approximation" in row for row in en_rows)
assert not re.search(r"[\u4e00-\u9fff]", "\n".join(en_rows))
assert "暂用 Muntha 主星" not in zh
assert zh.count("| Varshesha |") == zh.count("| Selection basis |")
@@ -39,7 +57,11 @@ def test_consultation_annual_card_carries_the_basis() -> None:
case["lat"], case["lon"], case["tz"], ayanamsa_name=case["ayanamsa"])
annual = build_annual_tajika(payload, chart, reference=datetime(2026, 10, 3))
assert annual["status"] == "parameter_sensitive"
assert annual["varshesha_basis"] == YEAR_LORD_BASIS
# 原值:varshesha_basis == YEAR_LORD_BASIS(「暂用 Muntha 主星…待接入」)
# 新值:varshesha_basis 是年主选法依据一句话(tajika_year_lord.selection_basis_text)
# 原因:BUG-1214(任务书 T2)
assert "年盘上升" in annual["varshesha_basis"] and "暂用" not in annual["varshesha_basis"]
assert annual["varshesha_basis"].endswith("(Panchavargiya 为本地近似计算)")
card = (ROOT / "frontend/src/lib/consultation-evidence-card.ts").read_text(encoding="utf-8")
assert '"varshesha", "varshesha_basis"' in card
workflow = (ROOT / "frontend/src/mastra/consultation-workflow.ts").read_text(encoding="utf-8")
@@ -59,7 +81,15 @@ def test_main_path_muntha_is_natal_lagna_plus_completed_years() -> None:
assert (muntha["natal_asc_sign_idx"], muntha["age"]) == (natal_asc, completed)
assert muntha["muntha_sign_idx"] == (natal_asc + completed) % 12
assert muntha["muntha_sign"] == SIGNS[muntha["muntha_sign_idx"]]
assert report["year_lord"]["year_lord"] == muntha["muntha_lord"]
# 原值:年主 == Muntha 主星
# 新值:年主由 Panchadhikari 选出;Muntha 主星一定在候选里(来源 muntha_lord),
# 候选 Muntha 用的是「本命上升 + 已满年数」
# 原因:BUG-1214(第四轮问 3)
year_lord = report["year_lord"]
assert year_lord["muntha_basis"] == "natal_ascendant_plus_completed_years"
assert year_lord["muntha_sign_idx"] == muntha["muntha_sign_idx"]
planets = {row["planet"] for row in year_lord["candidates"]}
assert muntha["muntha_lord"] in planets and year_lord["year_lord"] in planets
def test_sun_sign_muntha_variant_is_off_the_main_path() -> None: