feat(report): static English dictionary for yoga rules and dasha applicability (E3)

yoga_rules.json gains effects_en for every rule and an _en sibling for each
Han-bearing string (combo_template, strength, source, accuracy_note); zh values
untouched. reader_dasha_applicability renders language='en' from the same
packet; the default zh output is byte-identical (pinned by sha256).

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-09-29 19:26:00 +08:00
co-authored by Claude Opus 5.5
parent b2ad772e4d
commit 0c06ab1d78
3 changed files with 2921 additions and 191 deletions
+2732 -171
View File
File diff suppressed because it is too large Load Diff
+79 -20
View File
@@ -1,4 +1,8 @@
"""Reader-only applicability notes; never changes dasha calculations or periods."""
"""Reader-only applicability notes; never changes dasha calculations or periods.
``language="en"`` renders the same table from the same packet with the static
English wording below; the default ``"zh"`` output is unchanged.
"""
BOUNDARY = "第二时间轴,仅供交叉核对,不单独生成应期"
MISSING = "缺少本次计算的结构化适用性证据,无法确认各辅助时间轴是否适用;不补算、不补写日期。"
@@ -16,12 +20,62 @@ REASON_LABELS = {
}
def _reader_reason(value):
if value in REASON_LABELS:
return REASON_LABELS[value]
REASON_LABELS_EN = {
"patyayini_dasha_missing": "This calculation did not provide the Patyayini annual periods, so they cannot be used for timing checks yet.",
"The Shodashottari hora-plus-paksha applicability gate is not met.": "The Hora and lunar-fortnight (Paksha) condition for this dasha is not met.",
"This profile requires Navamsa Lagna in Taurus or Libra.": "This dasha requires the Navamsa Lagna in Taurus or Libra; not met here.",
"Dwisaptatisama requires Lagna lord in the 1st or 7th house.": "This dasha requires the Lagna lord in the 1st or 7th house; not met here.",
"Panchottari requires a Cancer Ascendant that is also Cancer in Dwadasamsa.": "This dasha requires a Cancer Lagna in both the birth chart and the Dwadasamsa; not met here.",
"Satabdika requires a vargottama Ascendant (same Rasi and Navamsa sign).": "This dasha requires a Vargottama Lagna (same sign in Rasi and Navamsa); not met here.",
"Chaturshitisama requires the 10th lord to occupy the 10th house.": "This dasha requires the 10th lord in the 10th house; not met here.",
"Shastihayani requires Sun in the Lagna sign.": "This dasha requires the Sun in the Lagna sign; not met here.",
}
_TEXT = {
"zh": {
"boundary": BOUNDARY,
"missing": MISSING,
"ashtottari": "月宿与月相组合未满足该大运的适用条件,不绕过判定。",
"unknown_reason": "计算返回了限制原因,但暂无可核对的详细说明;不据此补写适用条件或日期。",
"heading": "## 辅助时间轴适用性(参数敏感)",
"table_head": ["| 大运族 | 是否适用 / 原因 | 使用边界 |", "| --- | --- | --- |"],
"not_applicable": "本次不适用,不绕过原适用条件,不补写日期。",
"unavailable": "本次不可用:本地计算尚未提供可用周期,不能据此判断时间。",
"has_periods": "本次已返回周期,可作交叉核对;这不代表已完成多引擎验证。",
"unverified": "本次适用性尚未核实,缺少可核对周期,不补写日期。",
"applicable_true": " 原适用性判定:适用。",
"reasons_prefix": " 原因:",
"reasons_joiner": ";",
},
"en": {
"boundary": "Secondary timeline for cross-checking only; does not set event timing on its own",
"missing": "This calculation carries no structured applicability evidence, so it cannot confirm whether each auxiliary timeline applies; no periods or dates are filled in.",
"ashtottari": "The Moon's nakshatra and lunar phase do not meet this dasha's applicability condition; the rule is not bypassed.",
"unknown_reason": "The calculation returned a restriction without a checkable explanation; no condition or date is inferred from it.",
"heading": "## Auxiliary Dasha Applicability (reference only)",
"table_head": ["| Dasha system | Applicable? / Reason | Use |", "| --- | --- | --- |"],
"not_applicable": "Not applicable for this chart; the original condition is not bypassed and no dates are filled in.",
"unavailable": "Unavailable: the local calculation has not produced usable periods, so it cannot be used for timing.",
"has_periods": "Periods were returned and can be used for cross-checking; this does not mean multi-engine verification is complete.",
"unverified": "Applicability not yet verified and no checkable periods are available; no dates are filled in.",
"applicable_true": " Original applicability verdict: applicable.",
"reasons_prefix": " Reason: ",
"reasons_joiner": "; ",
},
}
def _language(value):
return "en" if value == "en" else "zh"
def _reader_reason(value, language="zh"):
labels = REASON_LABELS_EN if language == "en" else REASON_LABELS
if value in labels:
return labels[value]
if value.startswith("Moon Nakshatra '") and "does not qualify for Ashtottari Dasha under " in value:
return "月宿与月相组合未满足该大运的适用条件,不绕过判定。"
return "计算返回了限制原因,但暂无可核对的详细说明;不据此补写适用条件或日期。"
return _TEXT[language]["ashtottari"]
return _TEXT[language]["unknown_reason"]
def _has_periods(key, raw, source):
@@ -29,23 +83,28 @@ def _has_periods(key, raw, source):
return any(isinstance(data.get(field), (list, dict)) and bool(data[field]) for data in (raw, source) for field in fields)
def _cell(value):
return str(value).replace("|", "/").replace("\r", " ").replace("\n", " ").replace("<", "<").replace(">", ">")
def _cell(value, language="zh"):
text = str(value).replace("\r", " ").replace("\n", " ")
if language == "en":
return text.replace("|", "/").replace("<", "‹").replace(">", "›")
return text.replace("|", "/").replace("<", "<").replace(">", ">")
def render_dasha_applicability(packet):
def render_dasha_applicability(packet, language="zh"):
language = _language(language)
text = _TEXT[language]
timing = packet.get("worksheets", {}).get("timing_and_predictive_systems", {})
master = timing.get("dasha_master_pack", {})
families = master.get("families", {})
lines = ["", "## 辅助时间轴适用性(参数敏感)", ""]
lines = ["", text["heading"], ""]
if not isinstance(families, dict) or not families:
return "\n".join(lines + [MISSING, ""])
return "\n".join(lines + [text["missing"], ""])
families = dict(families)
annual = timing.get("annual_tajika_pack", {})
for key, value in annual.items():
if key.endswith("_dasha"):
families.setdefault(key.removesuffix("_dasha"), value)
lines += ["| 大运族 | 是否适用 / 原因 | 使用边界 |", "| --- | --- | --- |"]
lines += text["table_head"]
for key in sorted(families):
if key == "vimshottari":
continue
@@ -56,18 +115,18 @@ def render_dasha_applicability(packet):
status = raw.get("status", raw.get("execution_status"))
applicable = source.get("applicable", raw.get("applicable"))
if applicable is False or status == "not_applicable":
reason = "本次不适用,不绕过原适用条件,不补写日期。"
reason = text["not_applicable"]
elif status in {"blocked", "missing_in_local", "unavailable"}:
reason = "本次不可用:本地计算尚未提供可用周期,不能据此判断时间。"
reason = text["unavailable"]
elif _has_periods(key, raw, source):
reason = "本次已返回周期,可作交叉核对;这不代表已完成多引擎验证。"
reason = text["has_periods"]
else:
reason = "本次适用性尚未核实,缺少可核对周期,不补写日期。"
reason = text["unverified"]
if applicable is True:
reason += " 原适用性判定:适用。"
reasons = list(dict.fromkeys(_reader_reason(str(value)) for value in (raw.get("reason"), source.get("reason")) if value))
reason += text["applicable_true"]
reasons = list(dict.fromkeys(_reader_reason(str(value), language) for value in (raw.get("reason"), source.get("reason")) if value))
if reasons:
reason += " 原因:" + ";".join(reasons)
reason += text["reasons_prefix"] + text["reasons_joiner"].join(reasons)
name = "Kala Chakra" if key == "kala_chakra" else key.replace("_", "-").title()
lines.append(f"| {_cell(name)} | {_cell(reason)} | {BOUNDARY} |")
lines.append(f"| {_cell(name, language)} | {_cell(reason, language)} | {text['boundary']} |")
return "\n".join(lines) + "\n"
+110
View File
@@ -0,0 +1,110 @@
"""Static English dictionary for the English report edition.
The zh wording stays authoritative and unchanged; every Han-bearing rule string
in references/yoga_rules.json carries an ``_en`` sibling, and the auxiliary
dasha applicability table renders in English from the same packet.
"""
from __future__ import annotations
import hashlib
import itertools
import json
import re
from pathlib import Path
from scripts.reader_dasha_applicability import REASON_LABELS, render_dasha_applicability
ROOT = Path(__file__).resolve().parents[1]
RULES = json.loads((ROOT / "references" / "yoga_rules.json").read_text(encoding="utf-8"))["rules"]
HAN = re.compile(r"[㐀-鿿豈-﫿]")
PLACEHOLDER = re.compile(r"\{[^{}]*\}")
# Keys whose Han value is paired by an existing English key rather than a `_en` sibling.
PAIRED = {"name_cn": "name"}
def _walk(node, path=()):
if isinstance(node, dict):
yield path, node
for key, value in node.items():
yield from _walk(value, path + (key,))
elif isinstance(node, list):
for index, value in enumerate(node):
yield from _walk(value, path + (index,))
def test_every_rule_has_parallel_english_effects():
for rule in RULES:
effects = rule.get("effects")
if effects is None:
continue
english = rule.get("effects_en")
assert isinstance(english, list) and len(english) == len(effects), rule["id"]
for zh, en in zip(effects, english):
assert isinstance(en, str) and en.strip(), (rule["id"], zh)
assert not HAN.search(en), (rule["id"], en)
if not HAN.search(zh):
assert en == zh, (rule["id"], zh, en)
def test_every_han_string_has_an_english_sibling():
for rule in RULES:
for path, node in _walk(rule):
for key, value in node.items():
if not isinstance(value, str) or not HAN.search(value) or key.endswith("_en"):
continue
sibling = PAIRED.get(key, f"{key}_en")
english = node.get(sibling)
assert isinstance(english, str) and english.strip(), (rule["id"], path, key)
assert not HAN.search(english), (rule["id"], path, sibling, english)
def test_english_templates_keep_the_same_placeholders():
checked = 0
for rule in RULES:
for path, node in _walk(rule):
zh = node.get("combo_template")
if isinstance(zh, str):
assert sorted(PLACEHOLDER.findall(node.get("combo_template_en", zh))) == sorted(PLACEHOLDER.findall(zh)), (rule["id"], path)
checked += 1
assert checked >= 150
def _applicability_packets():
reasons = [None, *REASON_LABELS, "Moon Nakshatra 'X' does not qualify for Ashtottari Dasha under Y", "odd <reason> | pipe"]
statuses = [None, "not_applicable", "blocked", "missing_in_local", "unavailable", "ok"]
packets = [{}, {"worksheets": {}}, {"worksheets": {"timing_and_predictive_systems": {"dasha_master_pack": {"families": {}}}}}]
for index, (status, reason, applicable) in enumerate(itertools.product(statuses, reasons, [None, True, False])):
families = {
"vimshottari": {},
"narayana": {"status": status, "reason": reason, "mahadasha_sequence": [1] if index % 2 else []},
"ashtottari": {"status": status, "reason": reason, "applicable": applicable},
"kala_chakra": {"periods": [1]},
}
packets.append({"worksheets": {"timing_and_predictive_systems": {
"dasha_master_pack": {"families": families},
"ashtottari_dasha": {"applicable": applicable, "reason": reason},
"annual_tajika_pack": {"mudda_dasha": {"status": status}},
}}})
return packets
# sha256 of the zh output for every synthetic packet, taken from the module
# before the English path was added: zh must stay byte-identical.
ZH_APPLICABILITY_SHA256 = "b3194019f11f476f75327a7e76c94099eff7d50c5301d215c43cb45c000d278d"
def test_dasha_applicability_zh_is_unchanged():
packets = _applicability_packets()
rendered = "\x00".join(render_dasha_applicability(packet) for packet in packets)
assert hashlib.sha256(rendered.encode("utf-8")).hexdigest() == ZH_APPLICABILITY_SHA256
assert all(render_dasha_applicability(packet, language="zh") == render_dasha_applicability(packet) for packet in packets)
def test_dasha_applicability_en_has_no_han_and_same_rows():
for packet in _applicability_packets():
zh = render_dasha_applicability(packet)
en = render_dasha_applicability(packet, language="en")
assert not HAN.search(en), en
assert zh.count("\n") == en.count("\n")
assert zh.count("|") == en.count("|")