diff --git a/CHANGELOG.md b/CHANGELOG.md index b301ab4d..0d920af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 完整数据版最后增加一章独立年度方法参考 + +- 完整数据版报告末尾新增「独立年度方法参考」。这一章单独用 PyJHora 计算请求年及后两年的 Mudda、Patyayini 和 Tajika,并写明来源。 +- 前面的本机年运、对话卡和校正打分不改用这套数字。PyJHora 只在单独的计算进程里运行。 +- 标准报告没有这一章。 +- Skill 版本不变(BUG-1427、BUG-1428)。 + ## 2026-10-23 — 阅读页的 Yogini 子运缺字段时不再整段消失 - 补子运时只在原来的大运行上加子运,不改、不删原来的字段。缺完整年数时仍显示主运,不再整段报错。 diff --git a/deploy/README.md b/deploy/README.md index e5c4d558..f014055b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -39,6 +39,8 @@ The volume is not a backup domain. Chart cache is a TTL'd compute cache (default Heavy rectification scans and high-rigor workflows share a process-wide concurrency gate (`JYOTISH_HEAVY_COMPUTE_CONCURRENCY`, default `2` to match the 2 vCPU host). A saturated request waits up to 8 seconds if fewer than 4 requests are already waiting, then returns HTTP 429 with `Retry-After`. Health checks and other light routes do not take a slot. +The API image installs PyJHora for one chapter only: the full-data report's last section, 「独立年度方法参考」. That chapter runs PyJHora in a child process. The API process does not import it. Chat cards, rectification scoring, and the rest of the report keep the previous no-PyJHora results. AGPL for this chapter is the product decision of 2026-10-21 (issue #93). Do not set `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`. + ## DNS and Supabase Auth Final Spaceship resource records (apply only during the approved cutover window): diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1f327c3e..302778d2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17149,6 +17149,7 @@ - 修复版本:本分支 `codex/special-lagnas-native-20261008` - Claude 验收(2026-10-08,Linux):屏蔽 `jhora` 导入后跑 `cmd_full_reading`,乔布斯、奥巴马、虚构盘 × Lahiri / Raman 共 6 组,8 个点全部 `computed`、`source` 为 `native_special_lagna`,与 `fb8277bf` 调 PyJHora 的结果最大差 0°。Python 全量 62 条失败,与 `2df6ee72` 逐名相同;快速门通过;`tests/test_runtime_jhora_import_scan.py` 在快速门内,人为在 `native_special_lagnas.py` 加一行 `from jhora ...` 后该测试失败(变异检查)。年运资料包屏蔽 `jhora` 前后逐字节相同。 - 待问占星师:Vighati 上升按每分钟 15° 计(与 PyJHora 一致);按字面「每个 vighati 一个星座」应为每分钟 75°。 +- 决策推翻(2026-10-22,issue #93,不改本条状态):产品决定生产镜像可以安装 PyJHora,但只给完整数据版最后一章「独立年度方法参考」用。调用放在单独的子进程里,父进程不加载它。八个特殊上升点仍走本条的本机算法,不改回进程内 PyJHora。AGPL 由产品接受。见 BUG-1427、BUG-1428。 ## BUG-1274 | 中文报告把 Bhava Bala 译成年龄状态,并把 Planets 译成行星s @@ -19350,3 +19351,35 @@ - 复发自:无 - 修复版本:分支 `codex/full-data-parity-1-20261021`。 +## BUG-1427 | 装上 PyJHora 后,对话交叉验证会在接口进程里加载它 + +- 状态:resolved +- 首次发现:2026-10-23 +- 最近更新:2026-10-23 +- 影响面:对话编排里的外部引擎交叉验证。完整数据版最后一章不走这条路径。 +- 用户现象:生产镜像一旦装上 PyJHora,对话在同一进程里探测并加载它。岁差和星历路径会被改掉,同一时刻的月亮经度不再稳定。 +- 触发条件:镜像含 PyJHora,且对话走到交叉验证。 +- 根因:产品路径用「能不能导入 jhora」决定要不要调用。装上之后探测成功,接着在接口进程里导入。 +- 修复:产品报告固定写成今天没装 PyJHora 时的结果,不探测、不导入。命令行诊断工具仍按原样报告是否可用。 +- 验证:`tests/test_pyjhora_annual_method_reference.py` 把探测函数换成会失败的替身,产品报告仍不调用它。`tests/test_unified_consultation_orchestrator.py` 的适配器状态仍是缺少 jhora 模块。`tests/test_pyjhora_adapter_diagnostics.py` 命令行行为不变。 +- 防复发:上面几条。产品路径再调用探测,测试会失败。 +- 相关记录:issue #93。BUG-1273、BUG-1428。 +- 复发自:无 +- 修复版本:分支 `codex/full-data-parity-3-20261022`。 + +## BUG-1428 | 装上 PyJHora 后,校正三引擎包会在接口进程里加载它 + +- 状态:resolved +- 首次发现:2026-10-23 +- 最近更新:2026-10-23 +- 影响面:校正用的三引擎对照包里的 PyJHora 一行。生时校正打分本身不走这条路径。 +- 用户现象:生产镜像一旦装上 PyJHora,这行对照在接口进程里导入它,星盘其余数字可能被带着变。 +- 触发条件:镜像含 PyJHora,且组装三引擎对照包。 +- 根因:这一行直接导入 PyJHora。没装时得到「没有这个模块」;装上之后导入成功,计算发生在父进程。 +- 修复:产品路径固定抛出「没有这个模块」,不执行导入。对照行仍是今天没装时的那一行。 +- 验证:`tests/test_pyjhora_annual_method_reference.py` 检查这段源码没有导入语句,调用时得到模块缺失。封存合同测试 `tests/test_sealed_holdout_contract_freshness.py` 与 `tests/test_rectification_validation_integrity_gate.py` 仍通过。 +- 防复发:上述源码断言。再写回进程内导入,测试会失败。 +- 相关记录:issue #93。BUG-1273、BUG-1427。 +- 复发自:无 +- 修复版本:分支 `codex/full-data-parity-3-20261022`。 + diff --git a/docs/tasks/PROGRESS-full-data-parity-3-20261022.md b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md new file mode 100644 index 00000000..87014139 --- /dev/null +++ b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md @@ -0,0 +1,87 @@ +# PROGRESS — 完整数据版独立年度方法参考(#93) + +基线是已合入 #91 的 `origin/staging` `85e8adddbc9c14e31c687c91ad8666afe3290e64`。#92 没有合入 staging。没有收到「照上游原样」,D6 用默认:当地时间,Patyayini / Tajika 用「年龄 + 1 年」,Mudda 仍用年龄。 + +## 做了什么 + +完整数据版报告最后多了一章「独立年度方法参考」。这一章在单独的子进程里用 PyJHora 算请求年及后两年。父进程不加载 PyJHora。对话卡、校正和前面的本机年运仍按没装 PyJHora 时的结果。标准报告没有这一章。 + +某一年某一张表算不出来时,该表写「未获得结果」,不把内部原因印进报告。 + +## 换路径开关 + +| 编号 | 处理 | +| --- | --- | +| S1 | 产品路径不启动子进程,仍返回原来的年运结果。研究脚本明确要求时才计算。测试里的旧替身只有 4 个参数,不会把整章打成未判定。 | +| S2 | 对话交叉验证不探测、不导入。命令行诊断仍可报告是否装了。 | +| S3 | 校正三引擎对照固定返回「没有这个模块」,不在接口进程里导入。 | +| S4 | 部署文件不设置 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE`。Dockerfile 与工作流没改。 | +| S5 | 没有新增被禁止的那两个模块。 | +| S6 | 诊断脚本会显示 PyJHora 可用。它们不在产品路径上,没改。 | +| S7 | 见下面的测试。装上之后这些用例会真跑,不再跳过。 | + +## 装与不装 + +同一进程里,虚构盘 0 连续 5 次:去掉本章后的中文、英文各自哈希不变。父进程没有加载 jhora。英文汉字为 0。 + +把「找不到 jhora」的拦截放进子进程后,再用同一个进程哈希种子重跑虚构盘 0、1、9:去掉本章后的中文和英文与装上时逐字相同。资料包去掉新键、生成时刻和缓存时刻后,只剩报告引用号。引用号来自结果指纹。结果指纹现在不把这一章算进去,所以装与不装的引用号一致。 + +不固定哈希种子时,虚构盘 0 的太阳相位力量会差 0.01,虚构盘 1 的第一条王者格句子会换成另一条。这是既有代码用集合遍历,顺序随进程变化,不是这一章引入的。同一进程里的 5 次中文哈希相同。本轮没有改那两处旧代码。 + +开工前的「改代码之前」对照没有量成。当时的测量停在星历下载,不能当作装与不装的差异清单。上面是改完之后的对照。 + +## 两种年份口径(虚构盘) + +文档口径是本轮采用的默认。上游口径是世界时、并且年份不加 1。只列回归时刻和 Mudda 第一段起点,没有出生资料。小数小时已折成秒。 + +| 虚构盘 | 岁差回读 | 交点 | 文档口径回归 | 上游口径回归 | 文档口径 Mudda 起点 | 上游口径 Mudda 起点 | 这次子进程秒数 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 0 | RAMAN,一致 | 平交点 | 2026-07-19 08:55:02 | 2025-07-18 23:19:06 | 2026-07-08 02:01:59 | 2026-07-10 19:12:54 | 50.057 | +| 1 | LAHIRI,一致 | 平交点 | 2026-07-01 06:18:58 | 2025-06-30 21:05:13 | 2026-06-17 18:10:07 | 2026-06-21 00:36:03 | 52.986 | +| 9 | RAMAN,一致 | 平交点 | 2026-05-08 23:36:33 | 2025-05-09 00:22:13 | 2026-04-29 06:01:58 | 2026-04-23 10:30:48 | 50.567 | + +这三次为了对照多算了一遍上游口径,所以比正式出报告的那一次更慢。虚构盘 0 上,正式计算复用同一次年盘,三次复用都与再算一次相同(此前已测)。 + +## 时长(Windows,虚构盘 0,5 次) + +中文先渲染会把中文句子写进资料包,接着的英文会被判成有汉字而整份撤回。计时已改成英文使用渲染前的副本。下表是改完之后的数。第一次作废的英文时长不采用。 + +| 项目 | 中位 | 最大 | +| --- | --- | --- | +| 只出中文 | 54.873 秒 | | +| 只出英文(含同一次排盘) | 47.344 秒 | | +| 中英一起 | 57.034 秒 | 59.013 秒 | + +排盘约 45.183 秒,中文排版 9.690 秒,英文排版 2.161 秒。中文约 821290 字,英文约 1358880 字。本章中文约 38313 字,英文约 44551 字。 + +虚构盘 1 中英一起 78.568 秒,虚构盘 9 为 85.168 秒。三张都低于 180 秒。Linux 是否仍低于 180 秒由 Claude 复测。 + +## 镜像 + +本地镜像 `jyotisha-api:parity3-20261023`,`docker image inspect` 的 Size 为 932044815 字节。本机没有更早的 `jyotisha-api` 镜像,无法写增量。`docker history` 里安装依赖的那一层约 345MB。容器内可以导入 jhora、swisseph、geocoder、geopy。`pip show pyswisseph` 为 2.10.3.2。`pip check` 无冲突。Dockerfile 没改。 + +`docker stats` 按让步没测。子进程工作集在这台 Windows 上的采样一直停在约 4.4MB,空进程也是这个数,不能当成峰值。峰值留给 Linux 复测。 + +## 改过的断言 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 阅读页三处泄漏检查 | 全文不得出现 PyJHora | 先去掉「独立年度方法参考」再查 | 这一章必须写来源 | +| 英文泄漏检查 | 英文全文不得出现 PyJHora | 先去掉 Independent Annual Method Reference 再查 | 同上 | + +## 本轮跑过的检查 + +- 结果指纹不包含这一章:通过。 +- 四参数替身仍能接到年运,不把整章打成未判定:通过。 +- 阅读页、英文版、完整数据章、子进程门禁:在加上结果指纹排除之前已通过(约 291 秒那一轮,外加更早的三张盘真算约 288 秒)。 +- 封存合同与校正完整性:在四参数修复之前的那一轮通过。本轮没重跑。 +- 全量 pytest 和快速门没跑,留给 Claude。 +- 隐私标记、导入扫描、结果指纹,以及 S7 的 Ashtakavarga 与三份大运对照:退出码 0,约 33 秒。 + +## 偏离 + +- T0 到 T6 放在同一次提交。章节标题测试要等渲染和依赖同时在,拆开会红。 +- 没装的环境用启动时拦截 `jhora`,没有另建一套虚拟环境。 +- 子进程超时是 120 秒。45 秒会在这台机器上把已经算完的三年掐掉。 +- 没做容器内存曲线。 +- 开工前的基线对照没有形成可用数字。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 358f1049..a64ca0e0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -526,7 +526,7 @@ | `TASK-full-data-parity-2-20261021.md` | — | #92 完整数据版补齐(二):研究型推运附录(Tithi Ashtottari、Yoga / Nakshatra Pravesha、Karana 等,口径换成我方真恒星)、四种星座大运第三层、Kalachakra 第三层(简化模型)、Niryaana Shoola 子运只在有书面出处时做、D81 / D108 / D144 研究型分盘;全部标研究口径,不进对话卡与校正;不设大小上限 | 待执行 | `codex/full-data-parity-2-20261021` | | `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` | | `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` | -| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` | +| `TASK-full-data-parity-3-20261022.md` | `PROGRESS-full-data-parity-3-20261022.md` | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 默认,未收到「照上游原样」) | 待验收 | `codex/full-data-parity-3-20261022` | | `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` | | `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` | | `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` | diff --git a/docs/testing/full-data-parity-3-20261022.md b/docs/testing/full-data-parity-3-20261022.md new file mode 100644 index 00000000..38ba0c00 --- /dev/null +++ b/docs/testing/full-data-parity-3-20261022.md @@ -0,0 +1,11 @@ +# 完整数据版「独立年度方法参考」真机验收 + +用虚构盘或公开名人盘。不要把真实出生资料写进记录。 + +1. 打开一份完整数据版报告,翻到最后。最后一章标题是「独立年度方法参考」。 +2. 这一章有请求年、后一年、再后一年。每年都能看到来源是 PyJHora,以及 Mudda 两张表、Patyayini、Tajika。 +3. 某一格算不出来时,那一格写「未获得结果」,整份报告仍然能打开。 +4. 切换到英文。同一章标题是 Independent Annual Method Reference。英文里没有汉字。来源行是 Source: PyJHora。 +5. 导出 `.md`。最后一章仍在文件末尾。 +6. 打开一份这次更新之前生成的旧报告。它仍然能从列表打开,内容不因为这章而打不开。 +7. 打开一份标准报告(不是完整数据版)。没有「独立年度方法参考」这一章。 diff --git a/requirements-reference-engines.txt b/requirements-reference-engines.txt index 0995f855..a24accda 100644 --- a/requirements-reference-engines.txt +++ b/requirements-reference-engines.txt @@ -1,7 +1,9 @@ # Optional isolated reference-engine environment. # -# PyJHora is AGPL-3.0 and must remain an external benchmark/reference engine. -# Do not merge this file into the commercial production runtime requirements. +# Product decision 2026-10-21 (issue #93): PyJHora 4.8.7 is installed from +# requirements.txt for one full-data chapter only, and only inside a child +# process. AGPL risk for that chapter is accepted. The API process must not +# import it. This file stays the pinned reference list. # PyJHora 4.8.7 omits geocoder from its PyPI dependency metadata even though # jhora.utils imports it, so the missing runtime dependency is explicit here. PyJHora==4.8.7 diff --git a/requirements.txt b/requirements.txt index 374dafc1..28b38062 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,10 @@ # 核心:Swiss Ephemeris 天文计算库(必需) pyswisseph +# 仅供完整数据版「独立年度方法参考」子进程使用,AGPL,产品 10-21 决定。 +PyJHora==4.8.7 +geocoder==1.38.1 +geopy==2.4.1 timezonefinder>=6.5 # VedAstro 官方 Python SDK(官方快照与交叉验证) diff --git a/scripts/annual_pyjhora_replay.py b/scripts/annual_pyjhora_replay.py index 5f02a453..a82a3ed9 100755 --- a/scripts/annual_pyjhora_replay.py +++ b/scripts/annual_pyjhora_replay.py @@ -39,6 +39,13 @@ def _jhora_importable() -> bool: return _jhora_spec_cache +def _call_isolated_runner(fn, profile, birth_julian_day, place, age, *, research: bool): + """Call a 4-arg test stand-in, or the real runner that also takes research.""" + if "research" in signature(fn).parameters: + return fn(profile, birth_julian_day, place, age, research=research) + return fn(profile, birth_julian_day, place, age) + + def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]: """Same dict the child returns when ``import jhora`` raises ModuleNotFoundError.""" copied = dict(settings) @@ -92,6 +99,7 @@ def probe_pyjhora_year_lord( import_module: Callable[[str], Any] = _import_module, distribution_version: Callable[[str], str] = _distribution_version, isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None, + research: bool = False, ) -> dict[str, Any]: """Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API. @@ -123,8 +131,13 @@ def probe_pyjhora_year_lord( "year_lord_replay": replay, } - replay_runner = isolated_replay or _run_isolated_tajaka_year_lord - isolated = replay_runner(profile, birth_julian_day, place, age) + if isolated_replay is None: + isolated = _call_isolated_runner( + _run_isolated_tajaka_year_lord, + profile, birth_julian_day, place, age, research=research, + ) + else: + isolated = isolated_replay(profile, birth_julian_day, place, age) if isolated.get("reason") != "pyjhora_tajaka_api_missing": replay.update(isolated) replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates")) @@ -159,6 +172,8 @@ def _run_isolated_tajaka_year_lord( birth_julian_day: float, place: Any, age: int | float, + *, + research: bool = False, ) -> dict[str, Any]: settings = _replay_settings(profile) request = { @@ -168,7 +183,9 @@ def _run_isolated_tajaka_year_lord( "settings": settings, } request_hash = _request_hash(request) - if not _jhora_importable(): + # Product calls leave research false and return the no-install golden. + # They do not call find_spec. Only an explicit research replay may spawn. + if not research or not _jhora_importable(): replay = _no_jhora_tajaka_replay(settings) replay["request_hash"] = request_hash return replay @@ -298,6 +315,8 @@ def _run_isolated_annual_replay( birth_julian_day: float, place: Any, age: int | float, + *, + research: bool = False, ) -> dict[str, Any]: """Call optional PyJHora annual surfaces with a child-process Place.""" @@ -309,7 +328,8 @@ def _run_isolated_annual_replay( "settings": settings, } request_hash = _request_hash(request) - if not _jhora_importable(): + # Same pin as the Tajaka year-lord child: product traffic stays on the golden. + if not research or not _jhora_importable(): replay = _no_jhora_annual_replay(settings) replay["request_hash"] = request_hash return replay @@ -662,16 +682,25 @@ def replay_pyjhora_annual( import_module: Callable[[str], Any] = _import_module, year_lord_probe: Callable[..., dict[str, Any]] | None = None, isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None, + research: bool = False, ) -> dict[str, Any]: """Replay annual PyJHora methods for the same profile, if available.""" - year_lord_observer = year_lord_probe or probe_pyjhora_year_lord - year_lord_result = year_lord_observer( - profile, - birth_julian_day=birth_julian_day, - place=place, - age=age, - ) + if year_lord_probe is None: + year_lord_result = probe_pyjhora_year_lord( + profile, + birth_julian_day=birth_julian_day, + place=place, + age=age, + research=research, + ) + else: + year_lord_result = year_lord_probe( + profile, + birth_julian_day=birth_julian_day, + place=place, + age=age, + ) year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None blocked = _base(profile) blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else { @@ -681,8 +710,13 @@ def replay_pyjhora_annual( blocked["evidence_scope"] = "pyjhora_behavior_only" blocked["parity_status"] = "not_multiengine_parity" if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None): - observer = isolated_annual_replay or _run_isolated_annual_replay - observed = observer(profile, birth_julian_day, place, age) + if isolated_annual_replay is None: + observed = _call_isolated_runner( + _run_isolated_annual_replay, + profile, birth_julian_day, place, age, research=research, + ) + else: + observed = isolated_annual_replay(profile, birth_julian_day, place, age) if not isinstance(observed, dict): observed = _annual_replay_blocked( "pyjhora_isolated_annual_replay_invalid_payload", diff --git a/scripts/diagnose_pyjhora_adapter.py b/scripts/diagnose_pyjhora_adapter.py index fba2a704..a8038c52 100644 --- a/scripts/diagnose_pyjhora_adapter.py +++ b/scripts/diagnose_pyjhora_adapter.py @@ -17,6 +17,38 @@ ROOT = Path(__file__).resolve().parents[1] ADAPTER_RELATIVE = "benchmarks/jyotish/scripts/run_pyjhora_compare.py" +def build_product_report() -> dict: + """Product path: the same dict as a machine without PyJHora. + + Does not call find_spec and does not import jhora. The CLI still uses + ``build_report``, which may probe. + """ + adapter = ROOT / ADAPTER_RELATIVE + module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora" + adapter_exists = adapter.exists() + status = "missing_adapter" if not adapter_exists else "missing_dependency" + return { + "scope": "pyjhora_adapter_diagnostics", + "status": status, + "adapter_command": f"python3 {ADAPTER_RELATIVE}", + "adapter_exists": adapter_exists, + "dependency_module": module_name, + "dependency_available": False, + "missing_dependency": module_name, + "dependency_import_error": None, + "install_hint": { + "package": "PyJHora", + "commands": [ + "pip install -r requirements.txt -r requirements-reference-engines.txt" + ], + "note": "Install in an isolated optional benchmark environment, not as a hard runtime dependency.", + }, + "license_boundary": "AGPL external benchmark only; do not vendor or make it a runtime dependency.", + "ephemeris_data_note": "Recent PyJHora releases may require separate Swiss Ephemeris data download/configuration before full chart comparison can run.", + "boundary": "This verifies required PyJHora modules import successfully, but does not run chart comparison.", + } + + def build_report() -> dict: adapter = ROOT / ADAPTER_RELATIVE module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora" diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 4ba95d48..14345307 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -2332,6 +2332,29 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict: return packet +def _attach_pyjhora_annual_method_reference(packet: dict, args): + """One call into the subprocess-backed annual method reference.""" + try: + from scripts.report_render.annual_method_reference import attach_pyjhora_annual_method_reference + except ModuleNotFoundError: # pragma: no cover - direct script execution + from report_render.annual_method_reference import attach_pyjhora_annual_method_reference + return attach_pyjhora_annual_method_reference(packet, args) + + +def _bind_result_without_annual_method_reference(packet: dict, profile: dict) -> dict: + """Keep the new chapter out of result_hash. The chart result itself is unchanged.""" + worksheets = packet.get("worksheets") if isinstance(packet, dict) else None + timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None + detached = None + if isinstance(timing, dict) and "pyjhora_annual_method_reference" in timing: + detached = timing.pop("pyjhora_annual_method_reference") + try: + return bind_result_to_profile(packet, profile) + finally: + if isinstance(timing, dict) and detached is not None: + timing["pyjhora_annual_method_reference"] = detached + + def _uncertainty_minutes_from_args(args): explicit = getattr(args, 'uncertainty_minutes', None) if explicit not in (None, ''): @@ -18083,6 +18106,7 @@ def build_professional_report_reference_packet( packet = build_pl9_style_export_packet(full_reading, include_raw=True) packet = attach_calculation_profile(packet, args) packet = _attach_annual_tajika_pack(packet, args) + packet = _attach_pyjhora_annual_method_reference(packet, args) packet = _attach_report_governance_contracts(packet, args) packet = _attach_base_charts_pack(packet) packet = _attach_visual_chart_pack(packet, args) @@ -18100,7 +18124,7 @@ def build_professional_report_reference_packet( and isinstance(profile.get('input_hash'), str) and len(profile['input_hash']) == 64 ): - delivered = bind_result_to_profile(delivered, profile) + delivered = _bind_result_without_annual_method_reference(delivered, profile) if isinstance(delivered, dict) and delivered.get('selected_report_scope') == 'full': evaluate_full_report = _try_attr_import('full_report_quality_gate', 'evaluate_full_report') if evaluate_full_report is None: diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index 143c2c6f..48f1092f 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -2417,7 +2417,8 @@ def render_full_data_markdown(packet: dict) -> str: _pl9_english_report_title(packet), ) rendered = _append_parity_chapters(rendered, packet, "en") - return _apply_local_report_conventions(rendered, language="en") + rendered = _apply_local_report_conventions(rendered, language="en") + return _append_annual_method_reference(rendered, packet, "en") zh_structured_appendix = re.sub( r"^#\s+.*?(?:\n{2,}|\n)", "", @@ -2439,7 +2440,20 @@ def render_full_data_markdown(packet: dict) -> str: ) rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet)) rendered = _append_parity_chapters(rendered, packet, "zh") - return _apply_local_report_conventions(rendered, language="zh") + rendered = _apply_local_report_conventions(rendered, language="zh") + return _append_annual_method_reference(rendered, packet, "zh") + + +def _append_annual_method_reference(markdown: str, packet: dict, language: str) -> str: + """After the sanitizers. Those sanitizers delete any line that names PyJHora.""" + try: + from scripts.report_render.annual_method_reference import render_annual_method_reference + except ModuleNotFoundError: # pragma: no cover + from report_render.annual_method_reference import render_annual_method_reference + extra = render_annual_method_reference(packet, language).strip() + if not extra: + return markdown + return markdown.rstrip() + "\n\n" + extra + "\n" def read_full_data_domain(packet: Mapping, domain: str) -> dict: @@ -2452,7 +2466,7 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict: raise KeyError(domain) if not isinstance(packet, Mapping): raise TypeError("packet must be a mapping") - return copy.deepcopy({ + copied = copy.deepcopy({ "domain": domain, "label": FULL_DATA_DOMAINS[domain], "report_version": packet.get("report_version"), @@ -2461,3 +2475,8 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict: "calculation_profile": packet.get("calculation_profile"), "worksheets": packet.get("worksheets"), }) + worksheets = copied.get("worksheets") + timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None + if isinstance(timing, dict): + timing.pop("pyjhora_annual_method_reference", None) + return copied diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py index da266d31..4f508782 100644 --- a/scripts/rectification_three_engine_packet.py +++ b/scripts/rectification_three_engine_packet.py @@ -1,7 +1,6 @@ """Build a privacy-safe, request-level three-engine rectification parity packet.""" from __future__ import annotations -import importlib import os import sys from datetime import datetime @@ -45,12 +44,12 @@ def _local_d1(case: dict[str, Any]) -> dict[str, str]: def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]: - """PyJHora D1 signs. The jhora import stays in the reference module.""" - try: - reference = importlib.import_module("pyjhora_rectification_d1_reference") - except ModuleNotFoundError: - reference = importlib.import_module("scripts.pyjhora_rectification_d1_reference") - return reference.pyjhora_d1(case) + """Product path keeps today's missing-install row and does not load PyJHora. + + ``case`` stays in the signature so the packet caller is unchanged. + """ + del case + raise ModuleNotFoundError("No module named 'jhora'") def _ensure_jyotishganit_data_dir() -> str: diff --git a/scripts/report_render/annual_method_reference.py b/scripts/report_render/annual_method_reference.py new file mode 100644 index 00000000..bb1ca2c0 --- /dev/null +++ b/scripts/report_render/annual_method_reference.py @@ -0,0 +1,542 @@ +"""Parent side of the full-data annual method reference. + +This module does not import PyJHora. The worker runs in a child process. +The worker module name appears only as the ``-m`` argument below. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable + +log = logging.getLogger("jyotisha.annual_method_reference") + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKER_MODULE = "scripts.report_render.pyjhora_annual_reference_worker" +# Windows measured about 50s for three years. 45s killed a finished call. +# Linux is expected to be faster. 120s still leaves the report under the 180s wait. +DEFAULT_TIMEOUT_SECONDS = 120 +TABLES = ("mudda_birth", "mudda_annual", "patyayini", "tajika") +CHAPTER_ZH = "独立年度方法参考" +CHAPTER_EN = "Independent Annual Method Reference" +DASHA_LORDS = { + 0: "Sun", + 1: "Moon", + 2: "Mars", + 3: "Mercury", + 4: "Jupiter", + 5: "Venus", + 6: "Saturn", + 7: "Rahu", + 8: "Ketu", +} +YOGA_NAMES = { + "ithasala": "Ithasala", + "eesarpha": "Esharpha", + "manahoo": "Manahoo", + "kamboola": "Kamboola", + "gairi_kamboola": "Gairi-Kamboola", + "khallasara": "Khallsara", + "radda": "Radda", + "nakta": "Nakta", + "yamaya": "Yamaya", +} +YOGA_ORDER = ( + "ithasala", + "eesarpha", + "manahoo", + "kamboola", + "gairi_kamboola", + "khallasara", + "radda", + "nakta", + "yamaya", +) +ZH_PLANETS = { + "Sun": "太阳", + "Moon": "月亮", + "Mars": "火星", + "Mercury": "水星", + "Jupiter": "木星", + "Venus": "金星", + "Saturn": "土星", + "Rahu": "罗睺", + "Ketu": "计都", + "Lagna": "上升", +} + +_CACHE: dict[str, dict[str, Any]] = {} +_RUNNER: Callable[[dict[str, Any], int], dict[str, Any]] | None = None + + +def set_reference_runner(runner: Callable[[dict[str, Any], int], dict[str, Any]] | None) -> None: + """Tests and the 200-chart sweep install a stand-in. ``None`` restores the child process.""" + global _RUNNER + _RUNNER = runner + + +def reset_reference_cache() -> None: + _CACHE.clear() + + +def strip_annual_method_chapter(markdown: str) -> str: + """Drop this chapter so older full-text checks still see the previous report.""" + lines = (markdown or "").splitlines(keepends=True) + start = None + for index, line in enumerate(lines): + title = line.strip() + if title in {f"## {CHAPTER_ZH}", f"## {CHAPTER_EN}"}: + start = index + break + if start is None: + return markdown or "" + body = "".join(lines[:start]).rstrip() + return (body + "\n") if body else "" + + +def _request_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _pick(args: Any, packet: dict[str, Any], name: str) -> Any: + value = getattr(args, name, None) + if value not in (None, ""): + return value + birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {} + if birth.get(name) not in (None, ""): + return birth.get(name) + profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {} + if profile.get(name) not in (None, ""): + return profile.get(name) + return None + + +def _payload_from(args: Any, packet: dict[str, Any]) -> dict[str, Any] | None: + try: + year = int(_pick(args, packet, "year")) + target_year = int(_pick(args, packet, "target_year") or year) + birth = { + "name": "request", + "year": year, + "month": int(_pick(args, packet, "month")), + "day": int(_pick(args, packet, "day")), + "hour": int(_pick(args, packet, "hour") or 0), + "minute": int(_pick(args, packet, "minute") or 0), + "second": int(_pick(args, packet, "second") or 0), + "lat": float(_pick(args, packet, "lat")), + "lon": float(_pick(args, packet, "lon")), + "tz": float(_pick(args, packet, "tz")), + } + except (TypeError, ValueError): + return None + ayanamsa = _pick(args, packet, "ayanamsa") or "lahiri" + node_mode = _pick(args, packet, "node_mode") or "mean" + return { + "birth": birth, + "ayanamsa": str(ayanamsa), + "node_mode": str(node_mode), + "target_year": target_year, + } + + +def _invoke_worker(payload: dict[str, Any], timeout: int) -> dict[str, Any]: + if _RUNNER is not None: + return _RUNNER(payload, timeout) + completed = subprocess.run( + [sys.executable, "-m", WORKER_MODULE], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + timeout=timeout, + check=False, + cwd=str(REPO_ROOT), + ) + if completed.returncode != 0: + log.warning( + "annual method reference worker exit %s: %s", + completed.returncode, + (completed.stderr or "")[-500:], + ) + return {"ok": False, "reason": f"exit_{completed.returncode}"} + text = (completed.stdout or "").strip() + try: + parsed = json.loads(text) + except json.JSONDecodeError: + parsed = None + for line in reversed(text.splitlines()): + if line.strip().startswith("{"): + try: + parsed = json.loads(line) + break + except json.JSONDecodeError: + continue + if parsed is None: + log.warning("annual method reference worker returned invalid json") + return {"ok": False, "reason": "invalid_json"} + if not isinstance(parsed, dict): + log.warning("annual method reference worker returned %s", type(parsed).__name__) + return {"ok": False, "reason": "invalid_payload"} + if completed.stderr and not parsed.get("ok"): + log.warning("annual method reference worker stderr: %s", completed.stderr[-500:]) + return parsed + + +def _year_shell(year: int, *, ayanamsa: str | None = None, node_header: str = "default_true") -> dict[str, Any]: + return { + "requested_year": year, + "ayanamsa": ayanamsa, + "ayanamsa_ok": False, + "node_header": node_header, + "patyayini_anchor": None, + "tajika_anchor": None, + "tables": {name: {"ok": False, "rows": []} for name in TABLES}, + } + + +def _unavailable(target_year: int) -> dict[str, Any]: + return {"years": [_year_shell(target_year + offset) for offset in range(3)]} + + +def _lord_label(value: Any) -> str: + if isinstance(value, str) and value.strip().upper() == "L": + return "Lagna" + try: + return str(DASHA_LORDS.get(int(value), value)) + except (TypeError, ValueError): + return str(value) + + +def _display_clock(start: Any) -> str | None: + if not isinstance(start, (list, tuple)) or len(start) != 4: + return None + try: + year, month, day = (int(part) for part in start[:3]) + hour_decimal = float(start[3]) + stamp = datetime(year, month, day) + timedelta(hours=hour_decimal) + return stamp.strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OverflowError): + return None + + +def _duration_text(value: Any) -> str: + try: + number = float(value) + except (TypeError, ValueError): + return "" + text = f"{number:.4f}".rstrip("0").rstrip(".") + return text + + +def _period_rows(periods: Any, *, with_end: bool) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + if not isinstance(periods, list): + return rows + for order, period in enumerate(periods, start=1): + if not isinstance(period, (list, tuple)) or len(period) < 3: + continue + lords, start, duration = period[0], period[1], period[2] + if not isinstance(lords, (list, tuple)) or len(lords) < 2: + continue + display = _display_clock(start) + if display is None: + continue + rows.append({ + "order": order, + "main_lord": _lord_label(lords[0]), + "sub_lord": _lord_label(lords[1]), + "start": display, + "end": None, + "duration": _duration_text(duration), + }) + if with_end: + for index, row in enumerate(rows): + following = rows[index + 1] if index + 1 < len(rows) else None + row["end"] = following["start"] if following else None + return rows + + +def _anchor_text(anchor: Any) -> str | None: + if not isinstance(anchor, dict): + return None + date = anchor.get("date") + time = anchor.get("time") + if not isinstance(date, list) or len(date) != 3 or not isinstance(time, str): + return None + try: + return f"{int(date[0]):04d}-{int(date[1]):02d}-{int(date[2]):02d} {time}" + except (TypeError, ValueError): + return None + + +def _planet_name(value: Any) -> str: + return _lord_label(value) + + +def _relationship_groups(raw: Any) -> list[list[str]]: + if raw in (None, []): + return [] + if isinstance(raw, (list, tuple)): + if raw and all(not isinstance(item, (list, tuple, dict)) for item in raw): + return [[_planet_name(item) for item in raw]] + groups = [] + for item in raw: + if isinstance(item, (list, tuple)): + groups.append([_planet_name(part) for part in item]) + else: + groups.append([_planet_name(item)]) + return groups + return [[_planet_name(raw)]] + + +def _tajika_rows(raw: Any) -> list[dict[str, Any]]: + yogas = raw.get("yogas") if isinstance(raw, dict) and isinstance(raw.get("yogas"), dict) else {} + failures = raw.get("failures") if isinstance(raw, dict) and isinstance(raw.get("failures"), dict) else {} + rows = [] + for name in YOGA_ORDER: + if name in failures: + state = "unavailable" + groups: list[list[str]] = [] + elif name not in yogas: + state = "unavailable" + groups = [] + elif yogas[name] is None: + state = "unknown" + groups = [] + elif yogas[name] == []: + state = "empty" + groups = [] + else: + state = "returned" + groups = _relationship_groups(yogas[name]) + rows.append({ + "yoga": YOGA_NAMES.get(name, name.title()), + "state": state, + "relationships": groups, + }) + return rows + + +def _normalize_year(raw: dict[str, Any], *, ayanamsa: str | None, ayanamsa_ok: bool, node_header: str) -> dict[str, Any]: + year = int(raw.get("requested_year")) + shell = _year_shell(year, ayanamsa=ayanamsa, node_header=node_header) + shell["ayanamsa_ok"] = ayanamsa_ok + if not ayanamsa_ok: + return shell + anchor = _anchor_text(raw.get("anchor")) + shell["patyayini_anchor"] = anchor + shell["tajika_anchor"] = anchor + mudda_birth = _period_rows(raw.get("mudda_birth"), with_end=False) + mudda_annual = _period_rows(raw.get("mudda_annual"), with_end=False) + patyayini = _period_rows(raw.get("patyayini"), with_end=True) + tajika = _tajika_rows(raw.get("tajika")) if isinstance(raw.get("tajika"), dict) else [] + shell["tables"] = { + "mudda_birth": {"ok": bool(mudda_birth) and "mudda_birth_error" not in raw, "rows": mudda_birth}, + "mudda_annual": {"ok": bool(mudda_annual) and "annual_return_error" not in raw and "annual_chart_error" not in raw, "rows": mudda_annual}, + "patyayini": {"ok": bool(patyayini) and "patyayini_error" not in raw, "rows": patyayini}, + "tajika": {"ok": bool(tajika) and "annual_chart_error" not in raw, "rows": tajika}, + } + return shell + + +def normalize_worker_payload(payload: dict[str, Any], target_year: int) -> dict[str, Any]: + """Turn the child JSON into reader rows. Failure text stays out of the packet.""" + if not isinstance(payload, dict) or not payload.get("ok") or not payload.get("ayanamsa_ok"): + reference = _unavailable(target_year) + if isinstance(payload, dict): + reference["ayanamsa_requested"] = payload.get("ayanamsa_requested") + reference["node_header"] = payload.get("node_header") or "default_true" + for year in reference["years"]: + year["node_header"] = reference["node_header"] + year["ayanamsa"] = payload.get("ayanamsa_readback") or payload.get("ayanamsa_requested") + return reference + years = [] + for raw in payload.get("years") or []: + if isinstance(raw, dict): + years.append(_normalize_year( + raw, + ayanamsa=payload.get("ayanamsa_readback"), + ayanamsa_ok=True, + node_header=str(payload.get("node_header") or "default_true"), + )) + if len(years) != 3: + return _unavailable(target_year) + return {"years": years} + + +def build_pyjhora_annual_method_reference(args: Any, packet: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> dict[str, Any]: + payload = _payload_from(args, packet if isinstance(packet, dict) else {}) + if payload is None: + return _unavailable(0) + key = _request_hash(payload) + if _RUNNER is None and key in _CACHE: + return _CACHE[key] + try: + raw = _invoke_worker(payload, timeout) + except subprocess.TimeoutExpired: + log.warning("annual method reference worker timed out after %s seconds", timeout) + raw = {"ok": False, "reason": "timeout"} + except Exception as exc: + log.warning("annual method reference worker could not start: %s", exc.__class__.__name__) + raw = {"ok": False, "reason": exc.__class__.__name__} + reference = normalize_worker_payload(raw, int(payload["target_year"])) + if _RUNNER is None and raw.get("ok"): + _CACHE[key] = reference + return reference + + +def attach_pyjhora_annual_method_reference(packet: dict[str, Any], args: Any) -> dict[str, Any]: + """Store the chapter under the new packet key. Existing keys stay untouched.""" + if not isinstance(packet, dict): + return packet + worksheets = packet.get("worksheets") + timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None + if not isinstance(timing, dict): + return packet + timing["pyjhora_annual_method_reference"] = build_pyjhora_annual_method_reference(args, packet) + return packet + + +def _copy(packet: dict[str, Any]) -> dict[str, Any]: + worksheets = packet.get("worksheets") if isinstance(packet.get("worksheets"), dict) else {} + timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets.get("timing_and_predictive_systems"), dict) else {} + reference = timing.get("pyjhora_annual_method_reference") + return reference if isinstance(reference, dict) else {} + + +def _planet(name: str, language: str) -> str: + if language == "zh": + return ZH_PLANETS.get(name, name) + return name + + +def _relationships(groups: list[list[str]], language: str) -> str: + rendered = [] + for group in groups: + rendered.append("–".join(_planet(name, language) for name in group)) + return "; ".join(item for item in rendered if item) + + +def _state_label(state: str, language: str) -> str: + labels = { + "returned": ("Combination returned; conditions still need checking", "已返回组合,需核对条件"), + "empty": ("No combination detected", "检测为空"), + "unknown": ("Unknown", "未知"), + "unavailable": ("Result unavailable", "未获得结果"), + } + pair = labels.get(state, labels["unavailable"]) + return pair[1] if language == "zh" else pair[0] + + +def _table(headers: list[str], rows: list[list[str]]) -> str: + head = "| " + " | ".join(headers) + " |" + rule = "| " + " | ".join("---" for _ in headers) + " |" + body = ["| " + " | ".join(row) + " |" for row in rows] + return "\n".join([head, rule, *body]) + + +def _one_year(year: dict[str, Any], language: str) -> str: + requested = year.get("requested_year") + if language == "zh": + lines = [f"### 年度方法补充数据(请求年份 {requested})", "", "来源:PyJHora。本节数字由 PyJHora 计算,与前面的原生周期分开读。"] + node = {"mean": "平交点", "true": "真交点"}.get(str(year.get("node_header")), "PyJHora 默认(真交点)") + ayanamsa = year.get("ayanamsa") or "未能按请求设定" + lines.append(f"岁差:{ayanamsa}。交点:{node}。") + lines.append(f"Patyayini 回归锚点:{year.get('patyayini_anchor') or '未取得'}") + lines.append(f"Tajika 回归锚点:{year.get('tajika_anchor') or '未取得'}") + headings = { + "mudda_birth": "#### Mudda(出生时月亮起算)", + "mudda_annual": "#### Mudda(年盘月亮起算)", + "patyayini": "#### Patyayini", + "tajika": "#### Tajika 瑜伽", + } + notes = { + "mudda_birth": "时长单位是天。这一表没有结束时间。", + "mudda_annual": "时长单位是天。这一表没有结束时间。", + "patyayini": "时长单位是年。结束时间是下一段的开始。最后一段没有结束时间。", + "tajika": "空的组合写检测为空。没有返回结果时写未获得结果。", + } + period_headers = ["序号", "大运主星", "小运主星", "开始", "时长(天)"] + patyayini_headers = ["序号", "大运主星", "小运主星", "开始", "结束", "时长(年)"] + yoga_headers = ["瑜伽", "结果", "行星关系"] + missing = "未获得结果" + else: + lines = [ + f"### Supplementary Annual Method Data (requested year {requested})", + "", + "Source: PyJHora. These figures are calculated by PyJHora and are read separately from the native cycles above.", + ] + node = {"mean": "mean", "true": "true"}.get(str(year.get("node_header")), "PyJHora default (true node)") + ayanamsa = year.get("ayanamsa") or "could not be set to the requested mode" + lines.append(f"Ayanamsa: {ayanamsa}. Nodes: {node}.") + lines.append(f"Patyayini return anchor: {year.get('patyayini_anchor') or 'Not obtained'}") + lines.append(f"Tajika return anchor: {year.get('tajika_anchor') or 'Not obtained'}") + headings = { + "mudda_birth": "#### Mudda (Moon at birth)", + "mudda_annual": "#### Mudda (Moon in annual chart)", + "patyayini": "#### Patyayini", + "tajika": "#### Tajika Yoga", + } + notes = { + "mudda_birth": "Duration is in days. This table has no end time.", + "mudda_annual": "Duration is in days. This table has no end time.", + "patyayini": "Duration is in years. The end is the next row's start. The last row has no end time.", + "tajika": "An empty detection is written as no combination. A failed call is written as result unavailable.", + } + period_headers = ["No.", "MD lord", "AD lord", "Start", "Duration (days)"] + patyayini_headers = ["No.", "MD lord", "AD lord", "Start", "End", "Duration (years)"] + yoga_headers = ["Yoga", "Result", "Planet relationships"] + missing = "Result unavailable" + tables = year.get("tables") if isinstance(year.get("tables"), dict) else {} + for key in TABLES: + lines.extend(["", headings[key], "", notes[key], ""]) + table = tables.get(key) if isinstance(tables.get(key), dict) else {} + rows = table.get("rows") if table.get("ok") and isinstance(table.get("rows"), list) else [] + if not rows: + lines.append(missing) + continue + if key == "tajika": + rendered = [[ + str(row.get("yoga") or ""), + _state_label(str(row.get("state") or ""), language), + _relationships(row.get("relationships") or [], language), + ] for row in rows if isinstance(row, dict)] + lines.append(_table(yoga_headers, rendered)) + continue + headers = patyayini_headers if key == "patyayini" else period_headers + rendered = [] + for row in rows: + if not isinstance(row, dict): + continue + end = row.get("end") or ("—" if language == "zh" else "-") + base = [ + str(row.get("order") or ""), + _planet(str(row.get("main_lord") or ""), language), + _planet(str(row.get("sub_lord") or ""), language), + str(row.get("start") or ""), + ] + if key == "patyayini": + base.append(str(end)) + base.append(str(row.get("duration") or "")) + rendered.append(base) + lines.append(_table(headers, rendered)) + return "\n".join(lines) + + +def render_annual_method_reference(packet: dict[str, Any], language: str) -> str: + reference = _copy(packet if isinstance(packet, dict) else {}) + years = [year for year in reference.get("years") or [] if isinstance(year, dict)] + if not years: + return "" + if language == "zh": + intro = "## 独立年度方法参考\n\n本节与前面的原生周期分开使用。数字不替换、不合并前面的年运。" + else: + intro = "## Independent Annual Method Reference\n\nUse this section separately from the native cycles above. These figures do not replace the annual cycles earlier in the report." + body = "\n\n".join(_one_year(year, "en" if language == "en" else "zh") for year in years) + return intro + "\n\n" + body + "\n" diff --git a/scripts/report_render/pyjhora_annual_reference_worker.py b/scripts/report_render/pyjhora_annual_reference_worker.py new file mode 100644 index 00000000..fa194724 --- /dev/null +++ b/scripts/report_render/pyjhora_annual_reference_worker.py @@ -0,0 +1,288 @@ +"""PyJHora annual-method worker. Run only as ``python -m``. + +Product modules must not import this file. Importing it loads PyJHora and +changes the process ayanamsa and ephemeris path. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import logging +import sys +from typing import Any + +log = logging.getLogger("jyotisha.pyjhora_annual_reference_worker") + +AYANAMSA_MAP = { + "lahiri": "LAHIRI", + "raman": "RAMAN", + "kp": "KP", + "krishnamurti": "KP", + "fagan_bradley": "FAGAN", + "true_citra": "TRUE_CITRA", + "true_pushya": "TRUE_PUSHYA", +} +YOGA_CALLABLES = ( + ("ithasala", "get_ithasala_yoga_planet_pairs"), + ("eesarpha", "get_eesarpha_yoga_planet_pairs"), + ("manahoo", "get_manahoo_yoga_planet_pairs"), + ("kamboola", "get_kamboola_yoga_planet_pairs"), + ("gairi_kamboola", "get_gairi_kamboola_yoga_planet_pairs"), + ("khallasara", "get_khallasara_yoga_planet_pairs"), + ("radda", "get_radda_yoga_planet_pairs"), + ("nakta", "get_nakta_yoga_planet_triples"), + ("yamaya", "get_yamaya_yoga_planet_triples"), +) + + +def _jsonify(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _jsonify(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonify(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _anchor(chart_payload: Any) -> dict[str, Any] | None: + if not isinstance(chart_payload, (list, tuple)) or len(chart_payload) < 2: + return None + marker = chart_payload[1] + if not isinstance(marker, (list, tuple)) or len(marker) < 2: + return None + date_value = marker[0] + if not isinstance(date_value, (list, tuple)) or len(date_value) != 3: + return None + return {"date": [int(part) for part in date_value], "time": str(marker[1])} + + +def _jd_from_anchor(utils: Any, anchor: dict[str, Any]) -> float: + hour, minute, second = (int(part) for part in str(anchor["time"]).split(":")) + return float(utils.julian_day_number(tuple(anchor["date"]), (hour, minute, second))) + + +def _verify_node(const: Any, drik: Any, swe: Any, local_jd: float, timezone: float, requested_true: bool) -> tuple[bool, float | None]: + if bool(const._use_true_nodes_for_rahu_ketu) != requested_true: + return False, None + jd_utc = float(local_jd) - float(timezone) / 24.0 + observed = float(drik.sidereal_longitude(jd_utc, const._RAHU)) % 360.0 + flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL + swe_lon = float(swe.calc_ut(jd_utc, int(const._RAHU), flags)[0][0]) % 360.0 + delta = abs((observed - swe_lon + 180.0) % 360.0 - 180.0) + return delta < 1.0, round(observed, 6) + + +def _one_mode( + *, + mudda: Any, + patyayini: Any, + tajaka: Any, + yoga_module: Any, + utils: Any, + place: Any, + birth_jd: float, + age: int, + annual_years: int, +) -> dict[str, Any]: + year: dict[str, Any] = {"mudda_birth": None, "mudda_annual": None, "patyayini": None, "tajika": None} + try: + with contextlib.redirect_stdout(sys.stderr): + periods = mudda.mudda_dhasa_bhukthi(birth_jd, place, age) + year["mudda_birth"] = _jsonify(periods) + except Exception as exc: + log.warning("mudda birth-moon failed: %s", exc.__class__.__name__) + year["mudda_birth_error"] = exc.__class__.__name__ + + chart_payload = None + try: + with contextlib.redirect_stdout(sys.stderr): + chart_payload = tajaka.annual_chart(birth_jd, place, years=annual_years) + except Exception as exc: + log.warning("annual_chart failed: %s", exc.__class__.__name__) + year["annual_chart_error"] = exc.__class__.__name__ + + anchor = _anchor(chart_payload) + positions = chart_payload[0] if isinstance(chart_payload, (list, tuple)) and chart_payload else None + year["anchor"] = anchor + if anchor is not None: + try: + annual_jd = _jd_from_anchor(utils, anchor) + with contextlib.redirect_stdout(sys.stderr): + annual_periods = mudda.mudda_dhasa_bhukthi(annual_jd, place, 0) + year["mudda_annual"] = _jsonify(annual_periods) + with contextlib.redirect_stdout(sys.stderr): + patyayini_periods = patyayini.get_dhasa_bhukthi(annual_jd, place) + year["patyayini"] = _jsonify(patyayini_periods) + year["anchor_mode"] = "annual_chart" + except Exception as exc: + log.warning("annual-return tables failed: %s", exc.__class__.__name__) + year["annual_return_error"] = exc.__class__.__name__ + if year.get("patyayini") is None: + try: + fallback_jd = float(birth_jd) + float(age) * 365.256364 + with contextlib.redirect_stdout(sys.stderr): + year["patyayini"] = _jsonify(patyayini.get_dhasa_bhukthi(fallback_jd, place)) + year["anchor_mode"] = "tropical_year_fallback" + except Exception as exc: + log.warning("patyayini fallback failed: %s", exc.__class__.__name__) + year["patyayini_error"] = exc.__class__.__name__ + + yogas: dict[str, Any] = {} + failures: dict[str, str] = {} + if isinstance(positions, (list, tuple)): + for yoga_name, callable_name in YOGA_CALLABLES: + candidate = getattr(yoga_module, callable_name, None) + if not callable(candidate): + failures[yoga_name] = "missing" + continue + try: + with contextlib.redirect_stdout(sys.stderr): + yogas[yoga_name] = _jsonify(candidate(positions)) + except Exception as exc: + log.warning("tajika %s failed: %s", yoga_name, exc.__class__.__name__) + failures[yoga_name] = exc.__class__.__name__ + year["tajika"] = {"yogas": yogas, "failures": failures} + return year + + +def compute(request: dict[str, Any]) -> dict[str, Any]: + import swisseph as swe + import jhora.const as const + + birth = request.get("birth") if isinstance(request.get("birth"), dict) else {} + timezone = float(birth.get("tz") or 0) + clock = float(birth.get("hour") or 0) + float(birth.get("minute") or 0) / 60.0 + float(birth.get("second") or 0) / 3600.0 + local_jd = float(swe.julday(int(birth["year"]), int(birth["month"]), int(birth["day"]), clock)) + requested_name = str(request.get("ayanamsa") or "").strip().lower() + mode = AYANAMSA_MAP.get(requested_name) + node_mode = str(request.get("node_mode") or "mean").strip().lower() + requested_true = node_mode == "true" + target_year = int(request["target_year"]) + birth_year = int(birth["year"]) + result: dict[str, Any] = { + "ok": True, + "ayanamsa_requested": mode, + "ayanamsa_readback": None, + "ayanamsa_ok": False, + "node_applied": False, + "node_header": "default_true", + "years": [], + } + if mode is None: + log.warning("unsupported ayanamsa for the annual method reference") + return result + + const.set_node_mode(requested_true) + from jhora.panchanga import drik + from jhora.horoscope.dhasa.annual import mudda, patyayini + from jhora.horoscope.transit import tajaka, tajaka_yoga + from jhora import utils + + node_ok, node_longitude = _verify_node(const, drik, swe, local_jd, timezone, requested_true) + result["node_longitude"] = node_longitude + if node_ok: + result["node_applied"] = True + result["node_header"] = "true" if requested_true else "mean" + else: + log.warning("node mode readback failed; leaving PyJHora's default true node") + const.set_node_mode(True) + result["node_header"] = "default_true" + + with contextlib.redirect_stdout(sys.stderr): + drik.set_ayanamsa_mode(mode) + readback = str(const._DEFAULT_AYANAMSA_MODE).upper() + result["ayanamsa_readback"] = readback + result["ayanamsa_ok"] = readback == mode + if not result["ayanamsa_ok"]: + log.warning("ayanamsa readback %s did not match %s", readback, mode) + return result + + place = drik.Place(str(birth.get("name") or "request"), float(birth["lat"]), float(birth["lon"]), timezone, 0.0) + prove_reuse = bool(request.get("prove_reuse")) + include_upstream = bool(request.get("include_upstream_diff")) + ut_jd = local_jd - timezone / 24.0 + for offset in range(3): + year = target_year + offset + age = year - birth_year + documented = _one_mode( + mudda=mudda, + patyayini=patyayini, + tajaka=tajaka, + yoga_module=tajaka_yoga, + utils=utils, + place=place, + birth_jd=local_jd, + age=age, + annual_years=age + 1, + ) + documented["requested_year"] = year + documented["age"] = age + if prove_reuse: + try: + with contextlib.redirect_stdout(sys.stderr): + again = tajaka.annual_chart(local_jd, place, years=age + 1) + documented["reuse_matches"] = _anchor(again) == documented.get("anchor") + except Exception as exc: + log.warning("reuse check failed: %s", exc.__class__.__name__) + documented["reuse_matches"] = False + if include_upstream and offset == 0: + upstream = _one_mode( + mudda=mudda, + patyayini=patyayini, + tajaka=tajaka, + yoga_module=tajaka_yoga, + utils=utils, + place=place, + birth_jd=ut_jd, + age=age, + annual_years=age, + ) + documented["upstream_diff"] = { + "documented_anchor": documented.get("anchor"), + "upstream_anchor": upstream.get("anchor"), + "documented_mudda_start": _first_start(documented.get("mudda_birth")), + "upstream_mudda_start": _first_start(upstream.get("mudda_birth")), + } + result["years"].append(documented) + return result + + +def _first_start(periods: Any) -> Any: + if not isinstance(periods, list) or not periods: + return None + first = periods[0] + if isinstance(first, list) and len(first) >= 2: + return first[1] + return None + + +def main() -> int: + logging.basicConfig(stream=sys.stderr, level=logging.WARNING) + try: + request = json.loads(sys.stdin.read() or "{}") + except json.JSONDecodeError as exc: + log.warning("worker stdin was not json: %s", exc.__class__.__name__) + sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_json"})) + return 0 + if not isinstance(request, dict): + sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_request"})) + return 0 + buffer = io.StringIO() + try: + with contextlib.redirect_stdout(buffer): + payload = compute(request) + except Exception as exc: + log.warning("worker failed: %s", exc.__class__.__name__) + payload = {"ok": False, "reason": exc.__class__.__name__} + leaked = buffer.getvalue() + if leaked: + print(leaked, file=sys.stderr) + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index 27b945b1..cb5bfa0e 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -41,9 +41,9 @@ def _formal_divisions() -> tuple[int, ...]: FORMAL_DIVISIONS = _formal_divisions() try: - from diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report + from diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report except Exception: # pragma: no cover - import path varies in tests/CLI - from scripts.diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report + from scripts.diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report try: from diagnose_jyotishganit_adapter import build_report as build_jyotishganit_adapter_report diff --git a/tests/report_english_fictional.py b/tests/report_english_fictional.py index 78ef53a7..d743bf15 100644 --- a/tests/report_english_fictional.py +++ b/tests/report_english_fictional.py @@ -235,6 +235,16 @@ def _guard_snapshot() -> dict: } +def _install_annual_reference_stub() -> None: + """The 200-chart sweep must not start one PyJHora child per chart.""" + from scripts.report_render.annual_method_reference import set_reference_runner + + def _unavailable(_payload, _timeout): + return {"ok": False, "reason": "sweep_stub"} + + set_reference_runner(_unavailable) + + def _prepare_process() -> None: root = Path(__file__).resolve().parents[1] for entry in (str(root), str(root / "scripts")): @@ -242,6 +252,7 @@ def _prepare_process() -> None: sys.path.insert(0, entry) block_jhora() _install_replay_stubs() + _install_annual_reference_stub() def init_english_worker() -> None: diff --git a/tests/test_api_chart_subprocess_gate.py b/tests/test_api_chart_subprocess_gate.py index 1e1ae8cb..d5001512 100644 --- a/tests/test_api_chart_subprocess_gate.py +++ b/tests/test_api_chart_subprocess_gate.py @@ -73,6 +73,9 @@ def test_no_jhora_annual_skips_subprocess_and_matches_golden(monkeypatch) -> Non def test_jhora_available_still_uses_subprocess(monkeypatch) -> None: + # 原值:_jhora_importable 为真时两个隔离函数各起一次子进程(calls == 2) + # 新值:产品调用不传 research 时,即使可导入也不起子进程、返回 golden;传 research=True 时仍起子进程(calls == 2) + # 原因:#93 红线 1:装包后旧回放不得进年运章 reset_jhora_import_cache() monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: True) calls = {"n": 0} @@ -85,12 +88,46 @@ def test_jhora_available_still_uses_subprocess(monkeypatch) -> None: calls["n"] += 1 return _Completed() - monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run) + def _forbid_subprocess(*_args, **_kwargs): + raise AssertionError("product replay must not spawn a child") + + monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess) tajaka = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30) annual = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30) + assert calls["n"] == 0 + tajaka_golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8")) + annual_golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8")) + assert _without_hash(tajaka) == tajaka_golden + assert _without_hash(annual) == annual_golden + + monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run) + _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30, research=True) + _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30, research=True) assert calls["n"] == 2 - assert tajaka["request_hash"] - assert annual["request_hash"] + + +def test_product_replay_calls_a_four_arg_stand_in(monkeypatch) -> None: + """The 200-chart sweep replaces the runners with four-argument functions.""" + from scripts.annual_pyjhora_replay import ( + _no_jhora_annual_replay, + _no_jhora_tajaka_replay, + replay_pyjhora_annual, + ) + + calls = [] + + def blocked(profile, birth_julian_day, place, age): + calls.append(age) + settings = {"ayanamsa": "LAHIRI", "node_mode": "mean"} + result = _no_jhora_tajaka_replay(settings) if len(calls) == 1 else _no_jhora_annual_replay(settings) + result["request_hash"] = "stand-in" + return result + + monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_tajaka_year_lord", blocked) + monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_annual_replay", blocked) + result = replay_pyjhora_annual(_PROFILE, birth_julian_day=2447893.0, place=_Place(), age=30) + assert calls == [30, 30] + assert result["status"] == "blocked" def test_pyjhora_interpreter_lookup_is_cached(monkeypatch) -> None: diff --git a/tests/test_full_data_parity_chapters.py b/tests/test_full_data_parity_chapters.py index cf5a0e4b..4c12a581 100644 --- a/tests/test_full_data_parity_chapters.py +++ b/tests/test_full_data_parity_chapters.py @@ -171,8 +171,8 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0} assert HAN.findall(english) == [] assert "[translation missing:" not in english - zh = _chapter_text(chinese, "zh") - en = _chapter_text(english, "en") + zh = _chapter_text(chinese, "zh").split("\n## 独立年度方法参考\n", 1)[0] + en = _chapter_text(english, "en").split("\n## Independent Annual Method Reference\n", 1)[0] zh_at = 0 en_at = 0 for title_en, title_zh in SECTIONS: @@ -335,20 +335,29 @@ def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatc full_zh = render_full_data_markdown(copy.deepcopy(packet)) full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown) + # 原值:拿掉六章后,二级标题与完整报告只差这六章 + # 新值:基准同时拿掉六章和独立年度方法参考;完整报告在六章之后还有这一章 + # 原因:#93 的年度方法参考也是后加的二级标题 + monkeypatch.setattr(export, "_append_annual_method_reference", lambda markdown, _packet, _language: markdown) base_zh = render_full_data_markdown(copy.deepcopy(packet)) base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" assert base_en - for full, base, chapters in ((full_zh, base_zh, zh_chapters), (full_en, base_en, en_chapters)): + for full, base, chapters, extra in ( + (full_zh, base_zh, zh_chapters, "独立年度方法参考"), + (full_en, base_en, en_chapters, "Independent Annual Method Reference"), + ): h1 = _heading_lines(full, 1) assert len(h1) == 1, h1 assert full.startswith("# " + h1[0]) assert _heading_lines(base, 1) == h1 full_h2 = _heading_lines(full, 2) base_h2 = _heading_lines(base, 2) - assert len(full_h2) == len(base_h2) + len(chapters) + assert len(full_h2) == len(base_h2) + len(chapters) + 1 assert full_h2[: len(base_h2)] == base_h2 - assert full_h2[len(base_h2):] == chapters + assert full_h2[len(base_h2): len(base_h2) + len(chapters)] == chapters + assert full_h2[-1] == extra tail = full.split("\n## " + chapters[0] + "\n", 1)[1] + tail = tail.split("\n## " + extra + "\n", 1)[0] assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [ (dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0] ] diff --git a/tests/test_pyjhora_annual_method_reference.py b/tests/test_pyjhora_annual_method_reference.py new file mode 100644 index 00000000..4e374139 --- /dev/null +++ b/tests/test_pyjhora_annual_method_reference.py @@ -0,0 +1,318 @@ +"""Issue #93: PyJHora annual method reference stays in one child process.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.diagnose_pyjhora_adapter import build_product_report +from scripts.pl9_full_data_export import read_full_data_domain, render_full_data_markdown +from scripts.rectification_three_engine_packet import _pyjhora_d1 +from scripts.report_render.annual_method_reference import ( + CHAPTER_EN, + CHAPTER_ZH, + build_pyjhora_annual_method_reference, + normalize_worker_payload, + render_annual_method_reference, + reset_reference_cache, + set_reference_runner, + strip_annual_method_chapter, +) +from tests.report_english_fictional import fictional_case + +ROOT = Path(__file__).resolve().parents[1] + + +def setup_function() -> None: + reset_reference_cache() + set_reference_runner(None) + + +def test_result_hash_ignores_the_annual_method_chapter() -> None: + from scripts.jyotish_engine import _bind_result_without_annual_method_reference + + profile = {"input_hash": "ab" * 32} + + def packet(marker: str) -> dict: + return { + "value": 1, + "worksheets": { + "timing_and_predictive_systems": { + "kept": "same", + "pyjhora_annual_method_reference": {"marker": marker}, + } + }, + } + + installed = _bind_result_without_annual_method_reference(packet("installed"), profile) + blocked = _bind_result_without_annual_method_reference(packet("blocked"), profile) + assert installed["result_hash"] == blocked["result_hash"] + assert installed["worksheets"]["timing_and_predictive_systems"]["pyjhora_annual_method_reference"]["marker"] == "installed" + assert installed["worksheets"]["timing_and_predictive_systems"]["kept"] == "same" + + +def test_product_report_does_not_probe(monkeypatch) -> None: + def _boom(*_args, **_kwargs): + raise AssertionError("product diagnostics must not probe jhora") + + monkeypatch.setattr("scripts.diagnose_pyjhora_adapter.importlib.util.find_spec", _boom) + report = build_product_report() + assert report["status"] == "missing_dependency" + assert report["dependency_available"] is False + assert report["missing_dependency"] == "jhora" + + +def test_pyjhora_d1_stays_on_the_missing_install_row() -> None: + source = (ROOT / "scripts" / "rectification_three_engine_packet.py").read_text(encoding="utf-8") + assert "import_module" not in source + assert "import jhora" not in source + with pytest.raises(ModuleNotFoundError): + _pyjhora_d1({"year": 1990}) + + +def test_deploy_does_not_set_year_lord_pyjhora_reference() -> None: + hits = [] + for path in (ROOT / "deploy").rglob("*"): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="ignore") + if "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE" in text: + hits.append(path.relative_to(ROOT).as_posix()) + assert hits == [] + + +def test_timeout_and_nonzero_exit_leave_every_table_unavailable() -> None: + def _timeout(_payload, timeout): + raise subprocess.TimeoutExpired(cmd="worker", timeout=timeout) + + set_reference_runner(_timeout) + reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}}) + assert _all_unavailable(reference) + + def _exit(_payload, _timeout): + return {"ok": False, "reason": "exit_3"} + + set_reference_runner(_exit) + reference = build_pyjhora_annual_method_reference(_args(0), {"birth_info": {}}) + assert _all_unavailable(reference) + + +def test_normalize_keeps_nine_yogas_and_two_mudda_tables() -> None: + raw = { + "ok": True, + "ayanamsa_ok": True, + "ayanamsa_readback": "RAMAN", + "node_header": "mean", + "years": [{ + "requested_year": 2026, + "anchor": {"date": [2026, 6, 15], "time": "16:35:04"}, + "mudda_birth": [[[0, 1], [2026, 1, 2, 3.5], 30]], + "mudda_annual": [[[2, "L"], [2026, 6, 15, 1], 10]], + "patyayini": [ + [[3, 4], [2026, 6, 15, 0], 0.4], + [[5, 6], [2026, 11, 1, 0], 0.6], + ], + "tajika": { + "yogas": {name: [] for name in ( + "ithasala", "eesarpha", "manahoo", "kamboola", "nakta", "yamaya", + )}, + "failures": {"radda": "NameError"}, + }, + }], + } + raw["years"][0]["tajika"]["yogas"]["gairi_kamboola"] = None + raw["years"][0]["tajika"]["yogas"]["khallasara"] = None + second = json.loads(json.dumps(raw["years"][0])) + third = json.loads(json.dumps(raw["years"][0])) + second["requested_year"] = 2027 + third["requested_year"] = 2028 + raw["years"].extend([second, third]) + reference = normalize_worker_payload(raw, 2026) + tables = reference["years"][0]["tables"] + assert tables["mudda_birth"]["rows"][0]["main_lord"] == "Sun" + assert tables["mudda_annual"]["rows"][0]["sub_lord"] == "Lagna" + assert tables["patyayini"]["rows"][0]["end"] == "2026-11-01 00:00:00" + assert tables["patyayini"]["rows"][1]["end"] is None + states = {row["yoga"]: row["state"] for row in tables["tajika"]["rows"]} + assert len(states) == 9 + assert states["Radda"] == "unavailable" + assert states["Gairi-Kamboola"] == "unknown" + assert states["Ithasala"] == "empty" + + +def test_chapter_is_last_and_domain_view_omits_the_key() -> None: + packet = { + "report_language": "zh", + "worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": { + "years": [_shell(2026), _shell(2027), _shell(2028)], + }}}, + } + chinese = render_full_data_markdown(packet) + english_packet = dict(packet) + english_packet["report_language"] = "en" + english = render_full_data_markdown(english_packet) + assert chinese.rstrip().splitlines()[-1] != "" + assert _h2(chinese)[-1] == CHAPTER_ZH + assert _h2(english)[-1] == CHAPTER_EN + assert chinese.count("来源:PyJHora") == 3 + assert english.count("Source: PyJHora") == 3 + assert "未获得结果" in chinese and "Result unavailable" in english + assert strip_annual_method_chapter(chinese) == strip_annual_method_chapter(strip_annual_method_chapter(chinese)) + assert CHAPTER_ZH not in strip_annual_method_chapter(chinese) + domain = read_full_data_domain(packet, "career") + timing = domain["worksheets"]["timing_and_predictive_systems"] + assert "pyjhora_annual_method_reference" not in timing + + +def test_three_fictional_charts_match_a_second_annual_chart_call() -> None: + """Real PyJHora, three fictional charts. Birth fields stay out of the assertion text.""" + script = r""" +import json, sys +sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"] +from tests.report_english_fictional import fictional_case +from scripts.report_render.annual_method_reference import _invoke_worker, _payload_from +class Args: + pass +out = [] +for index in (0, 1, 9): + birth = fictional_case(index) + args = Args() + for key, value in birth.items(): + setattr(args, key, value) + payload = _payload_from(args, {}) + if index == 0: + payload["prove_reuse"] = True + payload["include_upstream_diff"] = True + raw = _invoke_worker(payload, 120) + years = raw.get("years") or [] + diff = (years[0].get("upstream_diff") if years else None) or {} + out.append({ + "i": index, + "ok": bool(raw.get("ok")), + "ayanamsa_ok": bool(raw.get("ayanamsa_ok")), + "readback": raw.get("ayanamsa_readback"), + "node": raw.get("node_header"), + "reuse": [bool(year.get("reuse_matches")) for year in years], + "documented_anchor": (diff.get("documented_anchor") or {}).get("date"), + "upstream_anchor": (diff.get("upstream_anchor") or {}).get("date"), + "documented_mudda": diff.get("documented_mudda_start"), + "upstream_mudda": diff.get("upstream_mudda_start"), + "yoga_keys": sorted(((years[0].get("tajika") or {}).get("yogas") or {}).keys()) if years else [], + "radda": ((years[0].get("tajika") or {}).get("failures") or {}).get("radda"), + "jhora_in_parent": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules), + }) +print(json.dumps(out, ensure_ascii=False)) +""" + completed = subprocess.run( + [sys.executable, "-c", script, str(ROOT)], + cwd=ROOT, + text=True, + capture_output=True, + timeout=400, + check=False, + ) + assert completed.returncode == 0, completed.stderr[-1000:] + rows = json.loads(completed.stdout) + assert [row["i"] for row in rows] == [0, 1, 9] + for row in rows: + assert row["ok"] and row["ayanamsa_ok"], row + assert row["readback"] + if row["i"] == 0: + assert row["reuse"] == [True, True, True] + assert row["documented_anchor"] and row["upstream_anchor"] + assert row["jhora_in_parent"] is False + assert "radda" not in row["yoga_keys"] + assert row["radda"] + + +def test_parent_moon_and_ayanamsa_stay_put() -> None: + script = r""" +import json, sys +sys.path[:0] = [sys.argv[1], sys.argv[1] + "\\scripts"] +import swisseph as swe +from tests.report_english_fictional import fictional_case +from scripts.report_render.annual_method_reference import build_pyjhora_annual_method_reference +birth = fictional_case(0) +class Args: + pass +args = Args() +for key, value in birth.items(): + setattr(args, key, value) +jd = swe.julday(2000, 1, 1, 0) +before_ay = swe.get_ayanamsa_ut(jd) +before_moon = swe.calc_ut(jd, swe.MOON)[0][0] +reference = build_pyjhora_annual_method_reference(args, {}) +after_ay = swe.get_ayanamsa_ut(jd) +after_moon = swe.calc_ut(jd, swe.MOON)[0][0] +print(json.dumps({ + "same_ayanamsa": before_ay == after_ay, + "same_moon": before_moon == after_moon, + "jhora": any(name == "jhora" or name.startswith("jhora.") for name in sys.modules), + "years": len(reference.get("years") or []), + "tables": sorted((reference.get("years") or [{}])[0].get("tables", {})), +})) +""" + completed = subprocess.run( + [sys.executable, "-c", script, str(ROOT)], + cwd=ROOT, + text=True, + capture_output=True, + timeout=90, + check=False, + ) + assert completed.returncode == 0, completed.stderr[-1000:] + payload = json.loads(completed.stdout.strip().splitlines()[-1]) + assert payload["same_ayanamsa"] and payload["same_moon"] + assert payload["jhora"] is False + assert payload["years"] == 3 + assert payload["tables"] == ["mudda_annual", "mudda_birth", "patyayini", "tajika"] + + +def _args(index: int): + birth = fictional_case(index) + + class Args: + pass + + args = Args() + for key, value in birth.items(): + setattr(args, key, value) + return args + + +def _shell(year: int) -> dict: + return { + "requested_year": year, + "ayanamsa": "RAMAN", + "ayanamsa_ok": False, + "node_header": "mean", + "patyayini_anchor": None, + "tajika_anchor": None, + "tables": {name: {"ok": False, "rows": []} for name in ("mudda_birth", "mudda_annual", "patyayini", "tajika")}, + } + + +def _all_unavailable(reference: dict) -> bool: + years = reference.get("years") or [] + return len(years) == 3 and all( + all(not year["tables"][name]["ok"] for name in year["tables"]) + for year in years + ) + + +def _h2(markdown: str) -> list[str]: + return [line[3:].strip() for line in markdown.splitlines() if line.startswith("## ")] + + +def test_rendered_english_chapter_has_no_han() -> None: + text = render_annual_method_reference({ + "worksheets": {"timing_and_predictive_systems": {"pyjhora_annual_method_reference": { + "years": [_shell(2026)], + }}}, + }, "en") + assert "Source: PyJHora" in text + assert not any("\u4e00" <= char <= "\u9fff" for char in text) diff --git a/tests/test_report_english_edition.py b/tests/test_report_english_edition.py index 66b2528b..46d582b6 100644 --- a/tests/test_report_english_edition.py +++ b/tests/test_report_english_edition.py @@ -21,6 +21,7 @@ import pytest from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading from scripts.pl9_reader_english import HAN, han_leaks from scripts.pl9_reader_export import _dignity_label, _pl9_export_markdown_for_edition +from scripts.report_render.annual_method_reference import strip_annual_method_chapter from scripts.professional_report_reference import ( ProfessionalReportReferenceInputError, _export_args, @@ -91,7 +92,10 @@ def test_english_edition_has_no_chinese(rendered) -> None: def test_english_edition_passes_reader_leak_patterns(rendered) -> None: _, _, en, _ = rendered - assert _leak_hits(en) == [] + # 原值:英文全文泄漏词为零,包括 PyJHora + # 新值:先去掉 Independent Annual Method Reference 再查 + # 原因:#93 这一章必须写 Source: PyJHora,清理器会删掉更早出现的同一词 + assert _leak_hits(strip_annual_method_chapter(en)) == [] assert en.startswith("# Vedic Astrology Chart Report\n") for heading in ("## Birth Data and Charts", "## Strength, Relationships and Ashtakavarga", "## Standard Dasha Tables", "## Saturn and KP", diff --git a/tests/test_report_reader_main.py b/tests/test_report_reader_main.py index bc45b872..b5f0f374 100644 --- a/tests/test_report_reader_main.py +++ b/tests/test_report_reader_main.py @@ -15,6 +15,7 @@ from scripts.jyotish_engine import ( render_pl9_markdown, ) from scripts.pl9_reader_export import _pl9_export_markdown_for_edition +from scripts.report_render.annual_method_reference import strip_annual_method_chapter from scripts.pl9_full_data_export import REPORT_VERSION_FULL from scripts.professional_report_reference import ( ProfessionalReportReferenceInputError, @@ -177,8 +178,21 @@ def test_reader_main_hides_audit_vocabulary(full_reading) -> None: reader = _pl9_export_markdown_for_edition(packet, "full_data") assert "结论等级规则" not in reference assert "This reference export" not in reference - hits = _leak_hits(reader) + # 原值:完整数据版全文 LEAK_PATTERNS(含 PyJHora)零命中 + # 新值:切掉「独立年度方法参考」后零命中;本章含「来源:PyJHora」,其余模式在本章内为零 + # 原因:D1、D2 要求表头注明来源,清理器会删掉更早出现的 PyJHora 行 + from scripts.report_render.annual_method_reference import CHAPTER_ZH, strip_annual_method_chapter + + chapter = "" + marker = f"\n## {CHAPTER_ZH}\n" + if marker in reader: + chapter = reader.split(marker, 1)[1] + body = strip_annual_method_chapter(reader) + hits = _leak_hits(body) assert hits == [], hits + assert "来源:PyJHora" in chapter + chapter_hits = [pattern.pattern for pattern in LEAK_PATTERNS if pattern.pattern != "PyJHora" and pattern.search(chapter)] + assert chapter_hits == [], chapter_hits for chapter in CORE_CHAPTERS: assert chapter in reader, chapter assert reader.strip().startswith("#") @@ -291,7 +305,10 @@ def test_reader_annual_cells_use_real_distinct_years_and_keep_return_dates(reade return_time = annual["solar_return"]["data"]["dt_local"] section = body.split(f"### {year} 年度 Varshaphala / Tajika 细项", 1)[1].split("### ", 1)[0] assert f"| Varshapravesha | {return_time} |" in section - assert _leak_hits(reader) == [] + # 原值:全文泄漏词为零,包括 PyJHora + # 新值:先去掉「独立年度方法参考」再查 + # 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词 + assert _leak_hits(strip_annual_method_chapter(reader)) == [] def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) -> None: @@ -318,7 +335,10 @@ def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) - # 新值:| Muntha 星座 | 白羊座 | # 原因:完整数据版中文清理把 sign 写成「星座」;星座值仍是当年真实 Muntha assert "| Muntha 星座 | 白羊座 |" in reader - assert _leak_hits(reader) == [] + # 原值:全文泄漏词为零,包括 PyJHora + # 新值:先去掉「独立年度方法参考」再查 + # 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词 + assert _leak_hits(strip_annual_method_chapter(reader)) == [] def test_reader_omits_missing_name_and_panchanga_machine_keys(reader_packet) -> None: @@ -359,4 +379,7 @@ def test_reader_headings_drop_field_jargon_and_dignity_is_one_name(reader_packet assert _dignity_label("极敌(Great Enemy)") == "极敌" assert _dignity_label("中性") == "中性" assert _dignity_label(None) == "" - assert _leak_hits(reader) == [] + # 原值:全文泄漏词为零,包括 PyJHora + # 新值:先去掉「独立年度方法参考」再查 + # 原因:#93 这一章必须写「来源:PyJHora」,清理器会删掉更早出现的同一词 + assert _leak_hits(strip_annual_method_chapter(reader)) == [] diff --git a/tests/test_runtime_jhora_import_scan.py b/tests/test_runtime_jhora_import_scan.py index 9c42c5a7..f741159c 100644 --- a/tests/test_runtime_jhora_import_scan.py +++ b/tests/test_runtime_jhora_import_scan.py @@ -19,6 +19,9 @@ ENTRIES = ( "scripts/professional_report_reference.py", "scripts/consultation_workflow_service.py", "scripts/consultation_native_layers.py", + "scripts/unified_consultation_orchestrator.py", + "scripts/rectification_three_engine_packet.py", + "scripts/report_render/annual_method_reference.py", ) SELF = "tests/test_runtime_jhora_import_scan.py" @@ -162,9 +165,27 @@ def test_product_entries_do_not_import_jhora(): "scripts/native_special_lagnas.py", "scripts/tajika.py", "scripts/pl9_reader_export.py", + "scripts/unified_consultation_orchestrator.py", + "scripts/rectification_three_engine_packet.py", + "scripts/report_render/annual_method_reference.py", ): assert required in visited assert all("pyjhora" not in path and "/research/" not in path for path in visited) + worker = "pyjhora_annual_reference_worker" + for path in visited: + tree = ast.parse((ROOT / path).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + assert all(worker not in name for name in names), path + source = (ROOT / "scripts/report_render/annual_method_reference.py").read_text(encoding="utf-8") + assert f'"-m", {worker.upper()}' not in source + assert '"-m", WORKER_MODULE' in source + assert "pyjhora_annual_reference_worker" in source def test_scanner_is_on_the_quick_gate(): diff --git a/tests/test_unified_consultation_orchestrator.py b/tests/test_unified_consultation_orchestrator.py index bca7cb19..6ad02640 100644 --- a/tests/test_unified_consultation_orchestrator.py +++ b/tests/test_unified_consultation_orchestrator.py @@ -338,10 +338,10 @@ def test_runtime_evidence_log_exposes_blind_packet_case_and_quality_gate_contrac assert engines["VedAstro"]["status"] == "local_fallback" assert engines["PyJHora/JHora"]["status"] == "reference_available_not_runtime_invoked" assert engines["PyJHora/JHora"]["adapter_command"] == "python3 benchmarks/jyotish/scripts/run_pyjhora_compare.py" - assert engines["PyJHora/JHora"]["adapter_status"] in { - "available", - "blocked_missing_python_module:jhora", - } + # 原值:adapter_status 可以是 available 或 blocked_missing_python_module:jhora + # 新值:只有 blocked_missing_python_module:jhora + # 原因:#93 产品路径不再因为装了 PyJHora 就改成 available + assert engines["PyJHora/JHora"]["adapter_status"] == "blocked_missing_python_module:jhora" assert engines["jyotishganit"]["status"] == "reference_available_not_runtime_invoked" assert engines["jyotishganit"]["adapter_path"] == "references/open_source_sources/jyotishganit" assert engines["jyotishganit"]["adapter_status"] == "available"