/data/ephe)`(该目录只有 txt 星表,没有 `.se1`);`drik.set_ayanamsa_mode` 还会改写 `const._DEFAULT_AYANAMSA_MODE`(PyJHora 默认 `TRUE_PUSHYA`);交点默认真交点(`const._use_true_nodes_for_rahu_ketu = True`,我方默认平交点)。**PyJHora 只能在子进程里跑,不得在 API 进程内导入。**
+- 子进程实测(虚构盘 1985 年北京,Raman,三年一个子进程,按上游调用次数):导入 0.3~0.6 s,每年 1.9~2.3 s,合计约 **7 s**,峰值 RSS 约 **60 MB**。生产 2 核,预计 10~14 s,由 T6 实测。
+- 上游两处输入口径与 PyJHora 文档不符(实测,见 D6):
+ 1. 上游传入的 `jd` 是 **UT** JD(`swe.julday(..., hour − tz)`),PyJHora 约定是**当地时** JD(`drik` 内部 `jd_utc = jd − place.timezone/24`,`annual_chart` 文档也这样写)。同一虚构盘(东八区):回归时刻 08:34:53 vs 16:35:04,正好差 8 小时;Mudda 首段起点差约 8.5 天。我方 `tajika.py` 的 PyJHora sidecar 已按当地时传(`annual_jd + tz/24`)。
+ 2. `years=age` 时 `annual_chart`(Patyayini、Tajika 用它)落在**目标年前一年**(请求 2026 返回 2025-06-15),Mudda 却落在 2026。上游没修,只在表头写「请求年份不等于实际返回年份」。`years=age+1` 才落在目标年。
+- PyJHora 4.8.7 自身问题:`get_radda_yoga_planet_pairs` 抛 `NameError`;`gairi_kamboola`、`khallasara` 返回 `None`。上游把它们分别印成「未获得结果」「未知」。
+
+### 1.3 运行时会因「装了 PyJHora」而换路的地方(全部要保持今天的结果)
+
+| # | 位置(按符号) | 今天(生产无 PyJHora) | 装上后若不改 | 处理 |
+| --- | --- | --- | --- | --- |
+| S1 | `annual_pyjhora_replay._jhora_importable` → `_run_isolated_annual_replay`、`_run_isolated_tajaka_year_lord`(经 `replay_pyjhora_annual`、`probe_pyjhora_year_lord`,由 `jyotish_engine._build_pl9_full_annual_section` 每年调用) | 短路,返回 golden | 每年起 2 个子进程、旧口径数据进年运章(见 1.2) | 产品路径一律走短路,返回与今天逐字节相同的 golden;只有研究脚本显式传参才起子进程 |
+| S2 | `unified_consultation_orchestrator._external_engine_cross_validation` → `diagnose_pyjhora_adapter.build_report()` | `find_spec` 为空,`missing_dependency` | **在 API 进程内** `import_module("jhora.utils" / ".horoscope.chart.charts" / ".panchanga.drik")`(导入即改星历路径);`dependency_available`、`missing_dependency` 变;仓库里 `benchmarks/.../run_pyjhora_compare.py` 存在时(测试环境)`status` 从 `missing_dependency` 变 `available` | 产品调用改为不探测、不导入,返回今天的「缺依赖」字典;`build_report()` 默认行为留给命令行诊断 |
+| S3 | `rectification_three_engine_packet._pyjhora_d1`(`/api/rectification…` 高严谨分支 `build_packet`) | `ModuleNotFoundError`,该引擎行为错误 | **在 API 进程内**导入 `pyjhora_rectification_d1_reference` → `jhora`,三引擎包多出 PyJHora 星座行 | 产品路径固定返回今天的错误行,不导入;不改 `jyotish_api_server.py` |
+| S4 | `tajika._external_pyjhora_panchavargiya_score`(进程内 `pyjhora_panchavargiya_reference`,失败再走 `_lookup_external_pyjhora_python` 外部解释器) | 只在 `JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1` 时由 `tajika_year_lord._select` 传入 `annual_jd`,否则不触发 | 不变(仍由环境变量控制);但镜像里 `python3` ≠ `sys.executable` 的字面路径,变量一旦被设就会真跑 | 不改代码;加测试:部署文件(`deploy/**`)不得设置该变量;变量未设时装了 PyJHora 年主结果不变 |
+| S5 | `jyotish_engine` 里 `from pyjhora_canonical_adapter import …`(Chara `pyjhora_blackbox` 口径)、`from pyjhora_yogi_replay import replay_pyjhora_yogi`(Yogi 外部回放) | 两个模块仓库里都**不存在**,走桩函数 / blocked | 不变 | 不得新增这两个模块(红线) |
+| S6 | `ephemeris_backend_probe._python_module_status("jhora")`、`diagnose_external_engine_adapters`、`scripts/_compute_one_chart.py` | 开发诊断、离线基准工具 | 输出变(显示可用) | 不在产品路径(T1 核实 `ephemeris_backend_probe` 只被 `run_quality_gate.py`、测试引用),不改;PROGRESS 记一句 |
+| S7 | 测试:`test_ashtakavarga_sodhita_chain.py::test_trikona_step_matches_pyjhora_when_installed`、`test_dasha_api_engine_parity.py` 里经 `_pyjhora_rows`(`pytest.importorskip("jhora")`)的用例 | CI `.venv` 无 PyJHora,跳过 | 门禁 `.venv` 装 `requirements.txt` 后会真跑 | 允许「跳过 → 通过」,必须通过;列进 PROGRESS |
+
+`tests/test_runtime_jhora_import_scan.py`(BUG-1273,快速门内)按 AST 从全盘、报告、对话卡入口走导入图,**文件名含 `pyjhora` 的模块和 `scripts/research` 不进图**。S2、S3 不在它的入口里,所以今天没被拦。
+
+### 1.4 依赖
+
+| 项 | 实测 / 现状 |
+| --- | --- |
+| 上游固定版本 | `containers/pyjhora-observation/Dockerfile`:`PyJHora==4.8.7`、`pyswisseph==2.10.3.2`;`requirements-dev.txt`:`PyJHora>=4.8` |
+| 我方 | `requirements-reference-engines.txt` 已写 `PyJHora==4.8.7`、`geocoder==1.38.1`、`geopy==2.4.1`,注释写「AGPL,不得并入生产 requirements」;`requirements.txt` 里 `pyswisseph` **不带版本号** |
+| PyJHora 4.8.7 元数据 | `Requires-Dist` 为空(pip 不会因为它动 `pyswisseph`);但 `jhora.utils` 导入 `geocoder`、`geopy`、`timezonefinder`、`numpy`、`pytz`、`dateutil`、`requests`。分类器写 MIT,包内 LICENSE 是 AGPL-3.0 |
+| 安装体积 | 约 94~97 MB:`data/` 56 MB(`geonames_places_5k_IN.csv` 37 MB、`geonames_places_5k.csv` 7 MB、`data/ephe` 10 MB txt)、`images/` 15 MB、`horoscope/` 9 MB、`tests/` 5 MB、`lang/` 5 MB |
+| 镜像 | `deploy/railway-api.Dockerfile` 只 `pip install -r requirements.txt`,不需要改 Dockerfile;`requirements*.txt` 在 `deploy/gated-paths.txt` 内,推上去会触发门禁、构建新镜像 |
+| 门禁 `.venv` | `.gitea/workflows/backend-quality-gate.yml` 安装 `requirements.txt` + `requirements-dev.txt` → 合入后门禁环境也有 PyJHora(S7)。**不改 workflow** |
+
+### 1.5 渲染出口的现有拦截
+
+- `pl9_full_data_export._sanitize_pl9_ai_density_markdown_en` 与 `pl9_reader_english.render_pl9_user_markdown_en` 的 `drop_fragments` 含 `"PyJHora"`:**任何含 PyJHora 的行会被整行删掉**。
+- `tests/test_report_reader_main.py::LEAK_PATTERNS` 含 `PyJHora`、`\bparity\b`、`_unavailable`、`\bschema\b`、`\bsource_path\b`;`test_reader_main_hides_audit_vocabulary` 对完整数据版全文断言零命中。
+- 所以本节必须在两个清理器和 `_apply_local_report_conventions` **之后**拼接,并且不得印出任何内部失败原因、键名。
+
+## 2. 根因 / 背景
+
+1. 上游交付版「独立年度方法参考」是 PyJHora 子进程回放的产物;我方 BUG-1273 起约定 PyJHora 只作离线对照,生产镜像不装,所以这节在我方一直缺。
+2. 我方代码里早已散落「检测到 PyJHora 就换路」的开关(1.3 的 S1~S5),以前靠「镜像里没有」兜底。装包之后这些兜底全部失效,其中 S1~S3 会在 API 进程内导入 PyJHora 或把旧口径数据印进现有章节。
+3. PyJHora 改的是 Swiss Ephemeris 的**进程级全局状态**(岁差模式、星历路径),与我方引擎共用一个 `swisseph` 模块,进程内调用必然串味。
+
+## 3. 决策记录
+
+| # | 决定 | 来源 |
+| --- | --- | --- |
+| D1 | **推翻** BUG-1273 / `TASK-special-lagnas-native-20261008` 的约定「PyJHora 只作外部对照、不进生产」:正式环境安装 PyJHora,**仅**用于本节。AGPL 许可证风险由产品负责人承担,已知情。`requirements-reference-engines.txt` 与 `diagnose_pyjhora_adapter` 里「不得进生产」的文字随之过时:前者注释改写;后者属于对话输出,按红线 1 不改 | 产品 10-21 |
+| D2 | 照上游 `_annual_observation_lines` 出三张表:Mudda(各起点口径,即出生时月亮 / 年盘月亮)、Patyayini、Tajika 瑜伽;表头注明「来源:PyJHora」;与前文我方年运分开读,不替换、不合并、不比对 | 产品 10-21 |
+| D3 | 中英两版都要有;英文汉字为 0(#89 的扫描);中文用中文标签(上游中文版列名是英文,不照搬) | 产品 10-21 + 本单 |
+| D4 | 某张表调用失败(含 PyJHora 不可用、超时、岁差不支持),该表写「未获得结果 / Result unavailable」,报告照常生成;其他表照常出 | 产品 10-21 |
+| D5 | 年份范围:与上游 `annual_method_observations` 一致,目标年 + 后两年(与 #91 D8 的三年对应),每年一组三张表 | 照上游 |
+| D6 | **输入口径(待产品确认,默认如下,Grok 可按默认开工)**:上游两处与 PyJHora 文档不符(1.2)。默认:`jd` 传**当地时** JD(PyJHora 文档口径,与我方 `tajika.py` sidecar 一致);`annual_chart` 用 `years = age + 1`,让 Patyayini、Tajika 的回归锚点落在请求年,Mudda 仍按上游 `years = age`(其起点本来就落在请求年)。实际锚点照上游印出。若产品要求与上游逐字一致,改回 UT JD 与 `years = age`,其余不变。两种做法的差异(回归时刻、首段起点)在 PROGRESS 用 3 张虚构盘列表 | 本单(Claude) |
+| D7 | 岁差:按资料包 `settings.ayanamsa` 映射到 PyJHora 名称(lahiri→LAHIRI、raman→RAMAN、kp / krishnamurti→KP、fagan_bradley→FAGAN、true_citra→TRUE_CITRA、true_pushya→TRUE_PUSHYA);djwhal_khul、sassanian 等 PyJHora 不支持的写三张表「未获得结果」,**不得**让 PyJHora 静默退回它的默认 TRUE_PUSHYA。设置后回读 `const._DEFAULT_AYANAMSA_MODE` 核对 | 照上游 + 本单 |
+| D8 | 交点:上游不设(标「适配器不支持」)。本单在子进程里按资料包 `node_mode` 调 `const.set_node_mode(node_mode == "true")`,**在导入 `drik` 之前**设,并在子进程里用一颗交点经度回读核对;核对不过就不设,表头写「交点:PyJHora 默认(真交点)」 | 本单 |
+| D9 | 我方 BUG-1275 的真恒星口径不套用到 PyJHora:本节数值就是 PyJHora 自己算的,不与我方年运比对、不标一致 / 不一致 | 本单 |
+| D10 | 位置:完整数据版最后一个二级标题章(在 #91 的六章附录与「结构化资料附录 / Structured Data Appendix」之后),与上游「正文之后追加」一致;只出现在完整数据版,标准版不出 | 本单 |
+| D11 | 生时校正冻结身份、对话卡、标准版报告、星盘接口不碰本节,也不因装包变化 | 产品 10-21 红线 1 |
+
+## 4. 硬红线
+
+1. **装 / 不装逐字节一致(产品红线 1)**:除新增这一节以外,现有所有输出不得变化。验收:同一批盘「装 / 不装」两套全量 pytest 逐条一致(S7 的「跳过 → 通过」除外,必须通过);完整数据版中英 Markdown 切掉本章后逐字节相同;资料包 JSON 去掉本节新键后逐字节相同;校正冻结身份(`scripts/research/sealed_holdout_rerun.py::implementation_identity`)与封存回放(`tests/test_sealed_holdout_contract_freshness.py`、`tests/test_rectification_validation_integrity_gate.py`)逐项相同;对话卡(`read_full_data_domain`)3 张虚构盘逐字节相同。
+2. **API 进程内不得导入 `jhora`**:PyJHora 只在一个子进程 worker 里导入。生成报告、全盘、对话、校正之后,父进程 `sys.modules` 里不得有 `jhora`;`swisseph` 的岁差(`get_ayanamsa_ut`)与同刻月亮经度在调用本节前后不变。
+3. **版本固定(产品红线 2)**:`PyJHora==4.8.7`(与上游观测容器、我方 reference 文件一致);`geocoder==1.38.1`、`geopy==2.4.1` 照 `requirements-reference-engines.txt`。**不得改 `requirements.txt` 里 `pyswisseph` 那一行**,不得升级或更换 `pyswisseph`;新旧两个本地镜像里 `pip show pyswisseph` 必须相同,星位一律不变。
+4. **只有本节显式调用 PyJHora**:S1~S3 的产品路径保持不导入、不起子进程,结果与今天逐字节相同;不得新增 `pyjhora_canonical_adapter.py`、`pyjhora_yogi_replay.py`;不得把 PyJHora 数据写进 `annual_tajika_pack.*`、`normalized_tables`、`external_engine_comparison` 等现有键(现有渲染会读它们)。
+5. **增长冻结**:不改 `scripts/jyotish_api_server.py`;`tests/test_api_server_growth_contract.py` 不放宽。`scripts/*.py` 根目录已满 448 个(`tests/test_python_hygiene_budget.py::ROOT_PY_BUDGET`),新文件进 `scripts/report_render/`,不得调高预算。
+6. **不新增静默吞错**:`SILENT_EXCEPT_BUDGET = 1` 不放宽。子进程失败、超时、JSON 不合法都记日志(含原因),表里只写「未获得结果」。
+7. **报告卫生**:本节不得出现内部键名、`blocked`、`partial_verified`、`_unavailable`、`schema`、`source_path`、Python 异常名;英文汉字 0;中文不出现英文列名(行星编号、瑜伽专名、PyJHora 名称除外)。
+8. 不动数据库结构与迁移;不改 `.gitea/`、Dockerfile;不推 `main`。
+9. **冻结文件不碰**:`sealed_holdout_rerun.PRODUCTION_FILES` 所列 11 个文件(`scripts/rectification/*` 10 个 + `scripts/functional_benefics.py`)本单一行不改。
+10. **隐私**:只用虚构盘与公开名人盘;不复制上游个案文件;PROGRESS 与 Bug 历史实测段只写「虚构盘」。
+
+## 5. 任务分解(一条任务一个提交,按顺序)
+
+| # | 内容 | 验收标准 |
+| --- | --- | --- |
+| T0 | **先量风险,再动代码**:在 #91 合入后的 `origin/staging` 上,用两套环境(见第 6 节「装 / 不装」做法)对 3 张固定虚构盘跑完整数据版中英 Markdown 与资料包 JSON,`diff` 两套;全量 pytest 两套逐条比对。预期今天就有差异(S1、S2、S7),逐条归到 1.3 的 S 编号;本地构建基线镜像,记 `docker image inspect --format '{{.Size}}'` 与 `pip show pyswisseph` | PROGRESS 有一张「装 / 不装差异清单」,每条落到 S 编号;出现 S 表以外的差异,**先补进 S 表再继续**(新增条目也要在 T1 关掉);基线镜像大小与 pyswisseph 版本已记 |
+| T1 | **关掉现有开关(红线 1、4)**:S1 产品路径固定走短路(例如 `replay_pyjhora_annual` / `probe_pyjhora_year_lord` 增加只有研究脚本才传的显式参数,产品调用不传就返回现有 golden,不看 `find_spec`);S2 产品调用改为不探测、不导入,返回今天无 PyJHora 时的字典(`diagnose_pyjhora_adapter` 命令行默认行为不变);S3 产品路径固定返回今天的错误行;S4 加部署文件不设变量的测试。改动只在 `annual_pyjhora_replay.py`、`unified_consultation_orchestrator.py` / `diagnose_pyjhora_adapter.py`、`rectification_three_engine_packet.py`、`tajika_year_lord.py`(如需) | 装了 PyJHora 的环境下:`tests/fixtures/api-chart-subprocess-gate/*.json` 两个 golden 仍逐字节相等、`subprocess.run` 一次都不调;orchestrator 交叉验证段与无 PyJHora 时逐字节相同;三引擎包 PyJHora 行与无 PyJHora 时相同;父进程 `sys.modules` 无 `jhora`。T0 清单里 S1~S3 的差异全部消失。改到的既有断言写三栏(至少下表 A1) |
+| T2 | **依赖**:`requirements.txt` 加 `PyJHora==4.8.7`、`geocoder==1.38.1`、`geopy==2.4.1`,注释写明「仅供完整数据版『独立年度方法参考』子进程使用,AGPL,产品 10-21 决定」;`pyswisseph` 行不动;`requirements-reference-engines.txt` 注释改成与 D1 一致;不改 Dockerfile、workflow。本地用 `deploy/railway-api.Dockerfile` 构建新镜像 | 新镜像构建成功;`docker run --rm <新镜像> python -c "import jhora, swisseph, geocoder, geopy; ..."` 能导入;新旧镜像 `pip show pyswisseph` 相同;镜像大小增量写 PROGRESS;`pip check` 无新冲突 |
+| T3 | **子进程 worker**:新增 `scripts/report_render/pyjhora_annual_reference_worker.py`(文件名必须含 `pyjhora`,只在子进程里 `python -m` 运行,**任何产品模块不得 import 它**)与父进程侧 `scripts/report_render/annual_method_reference.py`(不导入 `jhora`、不导入 worker;用 `sys.executable -m scripts.report_render.pyjhora_annual_reference_worker`、`cwd` = 仓库根、stdin 传 JSON、stdout 收 JSON、PyJHora 的打印重定向到 stderr、超时 45 s 起步按 T6 实测调整)。一个子进程算完三年。子进程内按 1.1 表格的调用顺序、D6~D8 的口径;`annual_chart` 每年可只调一次复用结果(须在 3 张虚构盘上证明与逐次调用结果相同)。整理函数(`_normalize_mudda_replay_rows`、`_normalize_patyayini_replay_rows`、`_normalize_pyjhora_tajika_yoga_rows`、锚点、主星编号映射)搬到父进程侧,纯函数。资料包挂在**新键** `worksheets.timing_and_predictive_systems.pyjhora_annual_method_reference`(每年三表 + 实际锚点 + 实际岁差 / 交点 + 每表成功与否;失败原因只进日志)。挂载点放在报告资料包组装处(`professional_report_reference` 或 `jyotish_engine._attach_annual_tajika_pack` 之后,只加一行调用);中英两次渲染共用同一份资料包时不得算两遍,若两种语言各自组装资料包,加按请求哈希的进程内小缓存 | 3 张虚构盘:三年 × 三表都有行;Mudda 两个起点口径各一张;Tajika 9 个瑜伽各一行(`radda` 为「未获得结果」);岁差回读与请求一致;D6 两种口径的差异表进 PROGRESS;父进程 `sys.modules` 无 `jhora`;挡住 `jhora` 时三年 × 三表全部「未获得结果」、报告照常生成;超时与非零退出各有一个单元测试(替身子进程) |
+| T4 | **渲染**:照 `_annual_observation_lines` 出「独立年度方法参考 / Independent Annual Method Reference」二级标题(D10 位置),章首一句「本节与前面的原生周期分开使用……」;每年一个三级标题「年度方法补充数据(请求年份 2026)/ Supplementary Annual Method Data (requested year 2026)」;紧接「来源:PyJHora。……」/「Source: PyJHora. ……」与实际岁差、交点一行,再印 Patyayini、Tajika 两条实际回归锚点;四个四级标题:Mudda(出生时月亮起算)/ Mudda (Moon at birth)、Mudda(年盘月亮起算)/ Mudda (Moon in annual chart)、Patyayini、Tajika 瑜伽 / Tajika Yoga;每表上方照上游的单位与边界说明(中英);列名中英两套(序号 / No.、大运主星 / MD lord、小运主星 / AD lord、开始 / Start、结束 / End、时长(天)/ Duration (days)、时长(年)/ Duration (years)、瑜伽 / Yoga、结果 / Result、行星关系 / Planet relationships);主星中文用我方中文星名,`L` 写「上升 / Lagna」;Tajika 结果三态照上游(已返回组合,需核对条件 / 检测为空 / 未获得结果);某表失败只印说明行「未获得结果 / Result unavailable」,不印空表。拼接点在 `render_full_data_markdown` 中英两支的 `_apply_local_report_conventions` **之后** | 3 张虚构盘中英:本章是最后一个二级标题;三年各有四个四级标题;「来源:PyJHora / Source: PyJHora」各出现三次;英文全文汉字 0(#89 扫描);中文本章无英文列名;红线 7 卫生扫描对本章零命中;切掉本章后与 T1 完成时的输出逐字节相同 |
+| T5 | **红线测试**:①新增动态测试:装了 PyJHora 时生成一份完整数据版(中英)、跑一次全盘与一次对话卡,前后比较父进程 `swe.get_ayanamsa_ut(jd)`、同刻月亮经度、`sys.modules` 中无 `jhora`;②`tests/test_runtime_jhora_import_scan.py` **只加不减**:入口加 `scripts/unified_consultation_orchestrator.py`、`scripts/rectification_three_engine_packet.py`、`scripts/report_render/annual_method_reference.py`,并新增断言「产品导入图里没有任何模块 import `pyjhora_annual_reference_worker`」、「`annual_method_reference.py` 以 `-m` 字符串引用该 worker」;③完整数据版 golden / 快照类测试改为切掉本章再比(三栏);④批量生成报告的测试(如 #89 的 200 盘扫描)注入替身 runner,不真起 200 个子进程,本节自己的测试用真 PyJHora 跑 3 张盘 | 变异检查:在 `annual_method_reference.py` 加一行 `import jhora` 后①或②失败;把 S1 的强制短路去掉后 T1 的 golden 测试失败;快速门新增耗时写 PROGRESS |
+| T6 | **量测与文档**:①生产镜像大小(新旧 `docker image inspect` 与压缩后层大小);②单份报告生成时长增量(中、英各 5 次取中位,Linux 由 Claude 复测,Windows 照报);③内存:子进程峰值 RSS、API 容器在生成报告时的 `docker stats` 峰值;④本章中英字符数;⑤全量 pytest 耗时变化。文档:`docs/BUG_HISTORY.md`(BUG-1273 记录末尾追加「10-22 产品决定推翻『不进生产』,见 #93」,S2 / S3 进程内导入隐患按新编号记)、`CHANGELOG.md`、`deploy/README.md`(镜像含 PyJHora、仅子进程使用、AGPL 由产品决定)、PROGRESS、`docs/testing/full-data-parity-3-20261022.md`(真机:生成完整数据版、翻到最后一章、中英切换、导出 `.md`、旧报告仍能打开、标准版无本章) | 文档齐;中英合计生成仍在 180 s(`ENGINE_TIMEOUT_MS`)以内,超了停下回报 |
+
+需要改的既有断言(三栏写进 PROGRESS 与测试注释):
+
+| # | 测试 | 原值 | 新值 | 原因 |
+| --- | --- | --- | --- | --- |
+| A1 | `tests/test_api_chart_subprocess_gate.py::test_jhora_available_still_uses_subprocess` | `_jhora_importable` 为真时两个隔离函数各起一次子进程(`calls == 2`) | 产品调用不传研究参数时,即使可导入也不起子进程、返回 golden;传研究参数时仍起子进程(保留 `calls == 2`) | #93 红线 1:装包后旧回放不得进年运章 |
+| A2 | `tests/test_report_reader_main.py::test_reader_main_hides_audit_vocabulary` | 完整数据版全文 `LEAK_PATTERNS`(含 `PyJHora`)零命中 | 切掉「独立年度方法参考 / Independent Annual Method Reference」一章后零命中;本章内必须含「来源:PyJHora」或「Source: PyJHora」,且本章对其余 `LEAK_PATTERNS` 零命中 | D1、D2:产品要求表头注明来源 |
+| A3 | 完整数据版 golden / 快照 / 逐字节比较类测试(T0 列出具体文件) | 全文比较 | 切掉本章后全文比较;本章另有结构测试 | 本章内容依赖是否装 PyJHora |
+| A4 | `tests/test_runtime_jhora_import_scan.py` | 现有断言 | 现有断言全部保留,只新增入口与断言(T5②) | 加严,不是放宽 |
+
+## 6. 验收分工
+
+「装 / 不装」两套环境的做法:
+
+- **Windows(Grok)**:两个虚拟环境——`.venv`(`requirements.txt` 全装,含 PyJHora)与 `.venv-nojhora`(同一份 requirements 去掉三行新依赖后安装);两者 `pip show pyswisseph` 必须相同。
+- **Linux(Claude)**:系统 `python3` 已装 PyJHora 4.8.7 + pyswisseph 2.10.3.2;「不装」用 #64 的做法:只含下面 `sitecustomize.py` 的目录放在 `PYTHONPATH` 最前(子进程会继承,worker 里同样导入失败)。
+
+```python
+# <临时目录>/nojhora/sitecustomize.py
+import sys
+class _Block:
+ def find_spec(self, name, path=None, target=None):
+ if name.split('.')[0] in {'jhora'}:
+ raise ModuleNotFoundError(f"No module named '{name}'")
+ return None
+sys.meta_path.insert(0, _Block())
+```
+
+| 检查 | Grok 自跑 | Claude 验收 |
+| --- | --- | --- |
+| T0 差异清单(3 盘中英 Markdown + 资料包 + 全量 pytest,装 / 不装) | 必跑 | 复跑一次(Linux) |
+| 改到与新增的 Python 测试文件(至少 `test_api_chart_subprocess_gate`、`test_runtime_jhora_import_scan`、`test_report_reader_main`、`test_report_english_edition`、`test_report_full_data_fix`、`test_full_data_domain_reader`、`test_python_hygiene_budget`、`test_api_server_growth_contract`、`test_special_lagnas_birth_instant`、`test_year_lord_*`、`test_tajika_*`、`test_pyjhora_adapter_diagnostics`、本单新增测试)及 #89 的 200 盘扫描,**装、不装各一次** | 必跑 | 复验 |
+| 本地构建新旧 API 镜像:大小、`pip show pyswisseph`、`pip check`、容器内生成一份完整数据版并量 `docker stats` | 必跑 | 无 Docker,环境缺口,以 Grok 数据为准;部署后看门禁镜像发布日志 |
+| Python 快速门 `run_quality_gate.py --profile quick` | 不跑 | 以 Claude 为准(装、不装各一次) |
+| **Python 全量 pytest 与开工基线逐条比对,装 / 不装两套**(红线 1) | 不跑 | 以 Claude 为准 |
+| 3 盘中英完整数据版切掉本章后逐字节比对(分支装 vs 分支不装 vs 基线不装) | 必跑 | 以 Claude 为准 |
+| 封存回放与冻结身份(`test_sealed_holdout_contract_freshness`、`test_rectification_validation_integrity_gate`、`implementation_identity()` 输出),装 / 不装 | 必跑 | 复验 |
+| 生成时长(中、英各 5 次中位) | Windows 数照报 | 以 Claude 的 Linux 数判是否碰 180 s |
+| 前端 | 本单不改前端,不要求 | — |
+| `test:db` | 不动数据库,不要求 | — |
+| 部署后 | — | `/api/health` 的 `deployment.gitCommit` 等于合入 SHA;`swisseph_available = true`;真机清单留给产品 |
+
+## 7. 让步顺序
+
+不让步:红线全部(尤其 1~4)、T0、T1、T5、D4 的逐表失败、英文汉字 0。时间不够时依次让出:T3 的 `annual_chart` 复用优化(照上游逐次调用)→ D8 的交点设置(照上游不设,表头写 PyJHora 默认)→ T6 的 `docker stats` 内存峰值(只报子进程 RSS)。让出的写进 PROGRESS。
+
+## 8. 开工前置命令
+
+```bash
+git fetch origin --prune
+git log origin/staging --oneline | grep -iE "#91|full-data-parity-1" | head # 确认 #91 已合入,否则不开工
+git log origin/staging --oneline | grep -iE "#92|full-data-parity-2" | head # #92 若已合入或在途,记进 PROGRESS
+git worktree add -b codex/full-data-parity-3-20261022 .worktrees/full-data-parity-3-20261022 origin/staging
+cd .worktrees/full-data-parity-3-20261022
+# 上游参照放仓库外,只读(#91 已 clone 的可复用)
+git clone --filter=blob:none https://github.com/732642856/yinduzhanxing.git ../yinduzhanxing-ref
+git -C ../yinduzhanxing-ref fetch origin codex/customer-export-frozen-20261016
+git -C ../yinduzhanxing-ref show 1d549fe9:customer-export/registry/delivery_timing_supplement.py | grep -n "_annual_observation_lines"
+git -C ../yinduzhanxing-ref show 1d549fe9:scripts/annual_pyjhora_replay.py | grep -n "_isolated_annual_worker\|_call_mudda\|_call_patyayini\|_probe_tajika_yogas"
+# 现有开关(1.3)
+grep -n "_jhora_importable\|def replay_pyjhora_annual\|def probe_pyjhora_year_lord" scripts/annual_pyjhora_replay.py
+grep -n "replay_pyjhora_annual\|pyjhora_yogi_replay\|pyjhora_canonical_adapter" scripts/jyotish_engine.py
+grep -n "build_pyjhora_adapter_report" scripts/unified_consultation_orchestrator.py
+grep -n "_pyjhora_d1" scripts/rectification_three_engine_packet.py
+grep -rn "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE" deploy/ scripts/ | head
+grep -n '"PyJHora"' scripts/pl9_full_data_export.py scripts/pl9_reader_english.py
+grep -n "PyJHora" tests/test_report_reader_main.py
+grep -n "^pyswisseph\|PyJHora" requirements.txt requirements-reference-engines.txt
+ls scripts/*.py | wc -l # 448 = ROOT_PY_BUDGET
+grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1
+```
+
+## 9. BUG 编号
+
+写作时 `docs/BUG_HISTORY.md` 最大号 **BUG-1403**。该文件自 `400af57e8` 起有整段重复,必须用上面命令(`sort -u` 后取最大)核对,**开工时最大号 + 1 起**(#89~#92 可能先占号)。预计至少两条新编号:S2 对话交叉验证在 API 进程内导入 PyJHora 的隐患(装包即触发);S3 校正三引擎包在 API 进程内导入 PyJHora 的隐患。BUG-1273 只追加「决策推翻」一段,不改原文、不改状态。
diff --git a/docs/tasks/TASK-goel-jaimini-strength-20261022.md b/docs/tasks/TASK-goel-jaimini-strength-20261022.md
new file mode 100644
index 00000000..7c0676d7
--- /dev/null
+++ b/docs/tasks/TASK-goel-jaimini-strength-20261022.md
@@ -0,0 +1,201 @@
+# 任务书:搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra 计算(agent 循环 issue #94,2026-10-22)
+
+## 基线
+
+- 写作基线:`origin/staging` = `f90020efd`。开工以当时的 `origin/staging` 为准。
+- 分支:`codex/goel-jaimini-strength-20261022`;工作树:`.worktrees/goel-jaimini-strength-20261022`。执行方 **Grok(Windows + Docker)**,只推这个分支。
+- 顺序:**可与 #89(英文漏译)并行**,#89 只改渲染文件,不碰本单的文件。**必须在 #91 B 段印「Goel Jaimini Strength」这一章之前合入**。#91 的 T4(`yogi_sphuta`)改的是 `scripts/jyotish_engine.py` 里 full-reading 的同一段 jaimini 组装代码,与本单 T3 相邻;后开工的一方先 `git merge origin/staging`,不 rebase。
+- 上游:`https://github.com/732642856/yinduzhanxing.git` `main` 上的提交 `5fb8379c`(2026-10-21,作者应我方请求公开)。只读,放在仓库外。
+- 关联:#84 / BUG-1393、BUG-1283(Niryaana Shoola,PVR Ayur 口径);#81 决策 A(上游原样文件放根目录,预算按文件数调高);BUG-1275(真恒星口径);ERR-110、ERR-115(冻结身份);#91 `TASK-full-data-parity-1-20261021`(D7 当时定为「不做」,因为源码未公开;本单公开后由 #91 或其后续单印这一章)。
+
+## 1. 现状实证(Claude,Linux,只读)
+
+### 1.1 上游 `5fb8379c` 改了什么
+
+共 6 个文件,+522 / −1:
+
+| 文件 | 变化 | 行数 | blob 哈希(上游 `5fb8379c`) |
+| --- | --- | --- | --- |
+| `scripts/goel_jaimini_strength.py` | 新增 | 137 | `2170f781740affbad7b8ff4e048b88d87f997bcc` |
+| `tests/test_goel_jaimini_strength.py` | 新增 | 134 | `e29c47182c03c2277cff46e271a7f664c3349601` |
+| `docs/research/goel_public_calculation_module_2026_10_21.md` | 新增 | 204 | `c0bef9d9ad5f708093589ce852db20f9e2613995` |
+| `scripts/jyotish_engine.py` | +26 / −1 | — | 新增 `_goel_jaimini_evidence`;`cmd_jaimini` 在 `mode in ('all','special')` 时并入;`_cmd_full_reading` 在 `report['modules']['jaimini'] = jaimini_result` 前并入;另改 `render_pl9_markdown` 里 p89 一行说明文字 |
+| `tests/test_bphs_validation_runtime.py` | +5 | — | 在 `test_full_reading_exposes_lagna_vargottamamsa_receipt` 末尾加 Goel 四条断言 |
+| `docs/research/pre_work_error_ledger.md` | +16 | — | 上游自己的台账条目(Goel 首/七强度 ≠ PVR Niryaana) |
+
+新模块依赖的上游文件(**这次提交之前就在上游,我方都没有**):
+
+| 文件 | 行数 | blob 哈希(上游 `5fb8379c`) | 为什么需要 |
+| --- | --- | --- | --- |
+| `scripts/trikona_raman.py` | 233 | `1fc9283396acba98b9113bde7af0e27a80841a30` | Goel 取 `CLASSICAL`、`DIGNITY_PROFILE`、`_dignity`、`_validate_positions` |
+| `scripts/sthira_brahma_goel_short_method.py` | 112 | `6e010b54f5ba480db3e6d8d79e711a1cc2d59fbf` | Goel 取 `SIGNS`、`SIGN_LORDS`(按整数索引的表) |
+| `scripts/trikona_dasha.py` | 166 | `e6c8a27cf5e23e5c686154406ba5183226d12d79` | **issue 没列,但必需**:`trikona_raman.py` 模块顶层 `from scripts.trikona_dasha import RAMAN_AD_PROFILE, RAMAN_SOURCE, build_trikona_raman_antardasa_report`,不搬就导入失败 |
+
+依赖我方已有的两个文件,逐项核对与上游相同,**不用改**:
+
+| 我方文件 | 用到的符号 | 结论 |
+| --- | --- | --- |
+| `scripts/narayana_dasha.py` | `_rasi_aspects`、`SIGNS`、`SIGN_LORDS`、`_NARAYANA_DIGNITY` | 四个定义与上游逐字相同(整文件不同,不影响) |
+| `scripts/jaimini.py` | `FRIENDSHIP` | 逐字相同 |
+
+### 1.2 口径与安全检查
+
+| 检查 | 结果 |
+| --- | --- |
+| 位置框架 | 四个上游文件都**不算位置**:没有 `swe`、`FLG_MOSEPH`、`FLG_TRUEPOS`、视位置。只接受调用方给的恒星黄道经度(`[0,360)`,九星齐全,罗睺计都必须对冲,误差 1e-7)。位置由我方引擎提供即为真恒星口径 |
+| PyJHora | 四个文件都不导入 `jhora`;`trikona_dasha.py` 只有一句字符串提到 PyJHora,`test_runtime_jhora_import_scan` 不把字符串算导入 |
+| 隐私 | 测试只有虚构位置(`SYNTHETIC`)和 Goel 书第 15 页公开例题(只有度数,无出生资料);文档无个案资料。**不搬**上游台账条目里关于客户导出 pin 的段落 |
+| 静默吞错 | 四个文件没有宽泛 `except`;`_goel_jaimini_evidence` 捕获的是具名异常元组,不计入 `SILENT_EXCEPT_BUDGET` |
+| 来源字符串 | `goel_jaimini_strength.SOURCE` 是上游本地书稿路径(含 `Z-Library` 字样),我方仓库没有这份书稿。**这一行是唯一允许的改动**(Claude 10-22 定):公开仓库不出现盗版书库字样与他人本地路径,改成书名与作者的纯引用:`SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'`;**不得**搬入书稿 |
+| 已有同名概念 | 我方 `scripts/goel_short_strength.py` 是 Goel **第 5 章简法**(供 `rashi_dashas_native` 的 Sthira / Niryaana 等用),与本单的**第 1 章完整法**不是一套,互不替换 |
+
+### 1.3 试跑(Claude,临时目录,未改仓库)
+
+把四个上游文件放进临时包,`narayana_dasha.py` / `jaimini.py` / `rashi_dashas_native.py` / `goel_short_strength.py` 用我方 `f90020efd` 的版本:
+
+- `tests/test_goel_jaimini_strength.py` 中**不涉及引擎**的 16 条全过(含第 15 页例题逐值、虚构盘五分量、交点两种口径、度数相同 → blocked、非法输入)。
+- 涉及引擎的 3 条需要本单接线后才能跑。其中 `test_legacy_lagna_seventh_alias_does_not_replace_pvr_second_eighth` 从 `scripts.niryaana_shoola_dasha` 导入 `select_niryaana_shoola_start_sign`;**我方这个函数在 `scripts/rashi_dashas_native.py`**(#84)。用我方函数跑虚构盘:选中 Taurus(`selected_sign_idx == 1`),`profile == 'pvr_niryaana_shoola_ayur_rasi_strength_v1'`,与上游断言一致。
+- Linux 验收机未装 swisseph,没跑真实排盘;真实盘上的结果由 T5 验证。
+
+### 1.4 我方接线点(按符号定位)
+
+| 位置 | 现状 |
+| --- | --- |
+| `scripts/jyotish_engine.py::cmd_jaimini` | 除上游新增那一行外与上游逐字相同。`planet_lons` 取 `pd['degree']`(**四舍五入到 4 位小数**) |
+| `scripts/jyotish_engine.py::cmd_full_reading`(Step 6: Jaimini 系统) | `planet_lons` 取 `degree_raw`(未舍入),`asc_deg` 取上升 `lon`;jaimini 组装末尾 `report['modules']['jaimini'] = jaimini_result`,整段包在 `try … except Exception` 里,任何未捕获异常会让**整个** jaimini 模块丢失 |
+| API | `/api/jaimini`(`_compute_jaimini`,mode=all)走 `cmd_jaimini`;咨询与报告走 full-reading。**不需要改 `scripts/jyotish_api_server.py`** |
+| 报告 | `pl9_reader_export.py` 按键名取 jaimini 子项,不会自动印新键;`build_pl9_style_export_packet` 会把整个 `modules.jaimini` 放进资料包 JSON(体积略增,不出正文)。`jyotish_engine.py` 里受限资料 `jaimini_special_points.reason` 仍写「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」——本单不改(属报告文字,交 #91) |
+| 关键路径快照 | `tests/golden/consultation_contract_keypaths_v1.json` 只记两层(`modules.jaimini` 为止),新键在第三层,**不变** |
+| 键名冲突 | 我方没有任何代码读 `niryaana_shoola_strength`、`lagna_seventh_strength`、`goel_full_strength`、`goel_special_points`;我方 Niryaana 读的是 `dasha_families['niryaana_shoola']` 等精确键 |
+
+### 1.5 冻结身份(ERR-110 / ERR-115)
+
+冻结身份按**文件字节哈希**核对三份清单:`scripts/research/sealed_holdout_rerun.py` 的 `PRODUCTION_FILES`(11 个,校正与 `functional_benefics.py`)、`RESEARCH_FILES`(7 个),以及 `references/real_case_calibration/minute_rectification_holdout_v3.json` 的 `frozen_scoring.files`(12 个,**含 `jaimini.py`、`narayana_dasha.py`**)。
+
+- 本单新增的四个文件、`jyotish_engine.py`、两个测试文件都**不在**清单里。
+- 本单只**导入** `jaimini.py`、`narayana_dasha.py`,不改它们。
+- 校正打分不调用 full-reading,也不读 `modules.jaimini`。
+
+结论:按设计冻结身份**不会变**。若执行中发现必须改清单里的文件,或下面第 9 节的检查命令非空 / 四个身份测试转红,按红线 4 停下回报。
+
+### 1.6 根目录文件预算
+
+`scripts/*.py` 现为 **448** = `tests/test_python_hygiene_budget.py` 的 `ROOT_PY_BUDGET`,已满。先例:**#81 决策 A(产品 2026-10-18)**——「**上游原样文件**可以放在 `scripts/` 根目录(与上游同路径,方便以后同步),预算为这几个文件相应调高;我们自己写的文件仍不许增加」,当时 446 → 448,常量旁写了三栏。#91 把 `yogi_sphuta` 等放子包,是因为那些模块要改位置口径,不算原样文件;本单四个文件**一字不改**,正好落在 #81 A 的范围内。
+
+## 2. 背景
+
+Goel《Predicting through Jaimini Astrology》第 1 章(印刷页 10–17)的完整强度:行星五分量(尊贵、Karaka 名次分、从 AK 起的 Kendra 分、Kartari、Adhipati),星座五分量(固定分、所含 Karaka 分、Sthana、木星 / 水星 / 宫主三角色相位、宫主强度),再据此选 Brahma、Maheshwara、Rudra。上游交付版的「Goel Jaimini Strength and Special-Point Evidence」一章(约 4.8 万字符)就印这些数,我方此前因源码未公开没法做(#91 D7)。
+
+## 3. 决策记录(产品负责人,issue #94,2026-10-21)
+
+| # | 决定 |
+| --- | --- |
+| D1 | 搬 `goel_jaimini_strength.py` 及其依赖 `trikona_raman.py`、`sthira_brahma_goel_short_method.py`;`narayana_dasha._rasi_aspects` 两边已相同,不动。按 blob 哈希锁定上游版本,写进进度记录。(Claude 核实:`trikona_raman.py` 还硬依赖 `trikona_dasha.py`,一并按原样搬,见 1.1) |
+| D2 | 按上游 `jyotish_engine.py` 的改动,把 `goel_full_strength`、`goel_special_points` 接进我方 jaimini 与 full-reading 输出 |
+| D3 | 搬上游测试 `tests/test_goel_jaimini_strength.py`(书中公开数值表 + 虚构盘),必须全过;`test_bphs_validation_runtime.py` 的相应改动一并对照 |
+| D4 | 口径照上游文档:交点默认计入 Sthana(遵循正文,`include_nodes_text`);排除交点的示例表口径也保留(`worked_example_node_exclusion`);七个 Karaka 度数相同时为「无法判定」(`status: blocked`),**不得自造排序** |
+| D5 | 上游新增的 `lagna_seventh_strength` 与我方 #84 的 Niryaana Shoola(PVR Ayur)不是同一套:只搬字段本身,**不改**我方 Niryaana Shoola 的算法 |
+| D6 | 校正打分不读这两个新字段;封存回放、偏移扫描前后逐项相同。若只是因为 full-reading 多了字段导致冻结身份变化,**停下来报告,不得自行重冻** |
+| D7 | 位置一律用我方引擎口径(真恒星位置) |
+| D8 | 本单只做计算,不改报告渲染;报告里印这一章由 #91 负责 |
+| D9 | 可与 #89 并行;必须在 #91 印这一章之前合入 |
+| D10 | 根目录预算:按 **#81 决策 A** 先例,四个上游原样文件放 `scripts/` 根目录,`ROOT_PY_BUDGET` 只为这四个文件调高(448 → 452)。这是对先例的适用,不是新决定;若产品另有意见,按第 8 节让步改放子包 |
+
+## 4. 硬红线
+
+1. **上游四个文件一字不改**(唯一例外:`goel_jaimini_strength.py` 的 `SOURCE` 一行改为书目引用,见 1.2):其余三个文件落地后 `git hash-object` 必须等于 1.1 表里的 blob 哈希;`goel_jaimini_strength.py` 与上游 blob 的 `diff` 只能有 `SOURCE` 这一行(Windows 注意 `core.autocrlf=false`,LF 换行)。需要的适配一律写在调用方(`jyotish_engine.py`)或测试里。
+2. **不改我方 Niryaana Shoola 与简法强度**:`scripts/rashi_dashas_native.py`、`scripts/niryaana_shoola_dasha.py`、`scripts/goel_short_strength.py` 零改动;`tests/test_rashi_dashas_native.py`、`tests/test_niryaana_shoola_dasha.py` 不改断言且全绿。新增代码不得把 `lagna_seventh_strength` 或 `niryaana_shoola_strength` 接到任何 Niryaana 选择或大运计算上。
+3. **不改冻结清单里的文件**:`jaimini.py`、`narayana_dasha.py`、`ashtakavarga.py` 等 `frozen_scoring.files` 与 `PRODUCTION_FILES`、`RESEARCH_FILES` 中的任何文件零改动;校正代码(`scripts/rectification/**`、`minute_rectification_*`、`active_rectification_*`)不得读新字段。
+4. **冻结身份变化即停**:第 9 节检查命令输出非空,或 `test_sealed_holdout_contract_freshness`、`test_rectification_validation_integrity_gate`、`test_reported_offset_research`、`test_rectification_engine_memoization` 任一条转红——停下,推分支,PROGRESS 写清哪个文件、哪条测试,**不得重新冻结、不得改冻结记录或 golden**。
+5. **口径**:Goel 输入的经度一律取我方 `compute_chart_data` 的结果(Swiss Ephemeris + 真岁差,BUG-1275 之后),full-reading 用 `degree_raw`;不引入任何新的位置计算。
+6. **PyJHora**:运行时不得导入 `jhora`(`tests/test_runtime_jhora_import_scan.py`)。
+7. **增长冻结**:不改 `scripts/jyotish_api_server.py`;`tests/test_api_server_growth_contract.py` 不放宽。
+8. **静默吞错**:`SILENT_EXCEPT_BUDGET = 1` 不变(只给 `classical_astrology.py`)。Goel 失败必须 `logger.warning` 记具体异常类与信息,返回的 `reason` 带同样信息;不得让 Goel 失败把整个 `modules.jaimini` 带掉。
+9. **不改报告渲染**(D8):`pl9_*`、`scripts/report_render/**`、`professional_report_reference.py`、前端一概不动。3 张固定虚构盘的中英完整数据版 Markdown 改前改后逐字节相同(见 T5)。
+10. **对话不受影响**:`read_full_data_domain` 经卡片选择后的对话卡,对 3 张固定虚构盘逐字节不变。
+11. 不动数据库与迁移;不改 `.gitea/`;不推 `main`。
+12. **隐私**:只用虚构盘与书中公开例题;不搬上游书稿、客户导出文件、上游台账里的客户 pin 段落。PROGRESS 实测段只写「虚构盘」。
+
+## 5. 任务分解
+
+| # | 内容 | 验收标准 |
+| --- | --- | --- |
+| T1 | **原样搬四个模块**到 `scripts/` 根目录(`goel_jaimini_strength.py`、`trikona_raman.py`、`sthira_brahma_goel_short_method.py`、`trikona_dasha.py`),用 `git -C <上游> show 5fb8379c:<路径> > <路径>` 取,不经编辑器。`tests/test_python_hygiene_budget.py`:`ROOT_PY_BUDGET` 448 → 452,常量旁写三栏——原值 448 / 新值 452 / 原因「产品 2026-10-18 #81 决策 A 先例,issue #94:上游原样文件 goel_jaimini_strength、trikona_raman、sthira_brahma_goel_short_method、trikona_dasha 按上游路径放根目录;本地新写的文件仍不得增加」。合并时若根目录计数已因别的单变化,按合并后实数重算,**只为这四个文件加名额** | 三个文件 `git hash-object` 与 1.1 表逐一相等,`goel_jaimini_strength.py` 与上游的 diff 只有 `SOURCE` 一行(贴进 PROGRESS);仓库内 `grep -rn "Z-Library" scripts tests docs` 无命中;`ls scripts/*.py \| wc -l` = 新预算;`test_python_hygiene_budget` 全绿(静默处理仍只有 `classical_astrology.py:12558`) |
+| T2 | **搬测试**:`tests/test_goel_jaimini_strength.py` 按原样取,**只改一行**:`from scripts.niryaana_shoola_dasha import select_niryaana_shoola_start_sign` → `from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign`(我方 #84 的位置);PROGRESS 记上游 blob 与这一行改动。**对照** `test_bphs_validation_runtime.py`:我方这个文件没有上游的 `test_full_reading_exposes_lagna_vargottamamsa_receipt`,新增一条测试 `test_full_reading_exposes_goel_jaimini_strength`,用文件里已有的 `BASE_BIRTH_ARGS`(虚构盘)与 `_run_engine("full-reading", …)`,断言上游那四条:`goel_full_strength.status == 'executed'`、`rashi_strengths` 12 行、`goel_special_points` 键集合为 Brahma / Maheshwara / Rudra、`niryaana_shoola_strength.deprecated is True` | `tests/test_goel_jaimini_strength.py` 全部 19 条通过(含 3 条引擎接线条);新增 full-reading 测试通过;屏蔽 PyJHora 再跑一次同样通过 |
+| T3 | **接线**(`scripts/jyotish_engine.py`):照上游加 `_goel_jaimini_evidence(planet_longitudes, ascendant_longitude)`,返回 `goel_full_strength`、`goel_special_points`,以及有就带上的 `lagna_seventh_strength` 与弃用别名 `niryaana_shoola_strength`(上游模块原样产出,带 `deprecated: true`、`replacement`、`not_for` 标注,保留以便与上游测试一致)。与上游的两处差异,写进 PROGRESS:(a) 捕获异常时 `logger.warning` 记异常类与信息(红线 8),`reason` 写成 `f'{exc.__class__.__name__}: {exc}'`;(b) `cmd_jaimini` 传给 Goel 的经度用 `pd.get('degree_raw', pd['degree'])`——我方 `degree` 舍入到 4 位小数,罗睺计都对冲检查容差 1e-7,用舍入值可能误判为不对冲而整组 blocked(上游测试的模拟盘只有 `degree`,回退后仍过)。`cmd_jaimini` 在 `mode in ('all','special')` 时并入;full-reading 在 `report['modules']['jaimini'] = jaimini_result` 之前并入,**不得**放在会被外层 `except Exception` 吞掉整个模块的位置之外再抛异常 | `cmd_jaimini` 与 full-reading 对 3 张固定虚构盘都输出四个新键;`modules.jaimini` 原有各键逐值不变(改前改后 JSON 去掉四个新键后逐字节相同);`git diff` 只动 `jyotish_engine.py` 的这三处 |
+| T4 | **口径与「无法判定」**:确认 D4 的三件事在接线后的输出里成立:默认 `node_occupancy_policy == 'include_nodes_text'`;`goel_full_strength.worked_example_node_exclusion` 存在且 `node_occupancy_policy == 'exclude_nodes_worked_example'`;七星度数相同时 `status == 'blocked'`、三个特殊点全部 `blocked`,不出 AK、不出排序。另加一条测试:full-reading 里喂给 Goel 的经度与 `modules.chart.planets.*.degree_raw` 差 ≤ 1e-6° | 测试覆盖三件事与经度一致;不新增任何排序兜底 |
+| T5 | **实盘回归与不变性**:(1) 200 张固定种子随机虚构盘(可复用 #89 的生成器,未合入则自写固定种子)跑 full-reading 的 jaimini 段:`goel_full_strength.status` 全部 `executed`(若有 `blocked`,逐张列原因,只允许七星度数相同一种);`goel_full_strength.atmakaraka` 与我方 `chara_karaka_7` 的 AK 一致(不一致逐张列出并停下回报,不改任何一方);mean / true 交点各跑一遍。(2) 3 张固定虚构盘:中英完整数据版 Markdown 改前改后逐字节相同;对话卡(`read_full_data_domain`)逐字节相同。(3) 冻结身份检查(第 9 节命令)输出为空 | 三项结果写进 PROGRESS(盘数、blocked 数、AK 一致率、耗时增量);任一不满足按红线停下 |
+| T6 | **文档**:上游 `docs/research/goel_public_calculation_module_2026_10_21.md` 原样放到同路径(docs 不占根目录名额;文中唯一一处书稿本地路径同样改成书目引用,`grep -n "Z-Library"` 无命中),开头加一行我方说明:「上游 `5fb8379c` 原文;我方 Niryaana 选择器在 `scripts/rashi_dashas_native.py`;书稿未入库」。`docs/research/pre_work_error_ledger.md` 加一条我方台账(新 ERR 号):Goel 首/七强度 ≠ PVR Niryaana 二/八,弃用别名不得接入 Niryaana;Goel 完整法 ≠ `goel_short_strength` 简法。`CHANGELOG.md` 一行(引擎新增 Goel 强度与三个特殊点,报告暂不显示;Skill 版本不 bump)。PROGRESS 记:四个 blob、测试改动的那一行、接线与上游的两处差异、T5 数据、交给 #91 的事项(见下) | 文档齐;`.venv/bin/python -m pytest tests/test_repo_privacy_markers.py -q` 通过 |
+
+交给 #91(写进 PROGRESS「移交」段,本单不做):
+
+- 报告印「Goel Jaimini 强度与特殊点」章,中英标签齐全;`blocked` 的中文写「无法判定」、英文写「Not determinable」,并注明原因(七星度数相同 / 强度相同无唯一选择)。
+- `jyotish_engine.py` 受限资料 `jaimini_special_points.reason` 里「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」随印章一并改。
+- 交点两种口径并列印出,标明默认遵循正文。
+
+## 6. 验收分工
+
+| 检查 | Grok 自跑 | Claude 验收 |
+| --- | --- | --- |
+| 改到与新增的 Python 测试文件:`test_goel_jaimini_strength`、`test_bphs_validation_runtime`、`test_python_hygiene_budget`、`test_rashi_dashas_native`、`test_niryaana_shoola_dasha`、`test_jaimini`、`test_runtime_jhora_import_scan`、`test_api_server_growth_contract`、`test_consultation_contract_golden`、四个冻结身份测试(红线 4)、`test_repo_privacy_markers` | 必跑(工作树 `.venv`),屏蔽 PyJHora 再跑一次 | 复验 |
+| T5 的 200 盘与 3 盘逐字节比对 | 必跑,结果写 PROGRESS | 抽 3 盘复跑 |
+| Python 快速门 `run_quality_gate.py --profile quick` | 不跑 | 以 Claude 为准 |
+| **Python 全量 pytest 与开工基线逐条比对**(引擎输出有新增键) | 不跑 | 以 Claude 为准;屏蔽 PyJHora 模拟生产 |
+| 前端 | 不动,不要求 | — |
+| `test:db` | 不动数据库,不要求 | — |
+
+## 7. 提交顺序(一个分支)
+
+T1 → T2 → T3 → T4 → T5 → T6,一条任务一个提交。T1、T2 的提交里不得夹带对上游文件的任何格式化。
+
+## 8. 让步顺序
+
+不让步:红线全部;T1 的 blob 一致;T2 上游测试全过;T3 两个输出入口;冻结身份不变。
+
+时间不够时依次让出:T5 的 true 交点那一遍 → T5 的 200 盘缩到 50 盘(写明)→ T6 的上游文档原样副本(台账与 CHANGELOG 不让)。
+
+若产品不同意按 #81 A 调预算:四个文件改放 `scripts/goel_jaimini/`(子包,含 `__init__.py`),把模块内 `from scripts.trikona_raman` / `scripts.sthira_brahma_goel_short_method` / `scripts.trikona_dasha` 三处导入改成子包路径,测试里的 `scripts.goel_jaimini_strength` 跟着改;PROGRESS 记上游 blob + 本地导入改动的 diff,此后上游同步需人工比对。默认不走这条。
+
+## 9. 开工前置命令
+
+```bash
+git fetch origin --prune
+git worktree add -b codex/goel-jaimini-strength-20261022 .worktrees/goel-jaimini-strength-20261022 origin/staging
+cd .worktrees/goel-jaimini-strength-20261022
+git config core.autocrlf false
+
+# 上游参照放仓库外,只读
+git clone --filter=blob:none https://github.com/732642856/yinduzhanxing.git ../yinduzhanxing-ref # 已有则 git -C ../yinduzhanxing-ref fetch origin
+git -C ../yinduzhanxing-ref show --stat 5fb8379c
+for f in scripts/goel_jaimini_strength.py scripts/trikona_raman.py scripts/sthira_brahma_goel_short_method.py scripts/trikona_dasha.py tests/test_goel_jaimini_strength.py docs/research/goel_public_calculation_module_2026_10_21.md; do
+ echo "$f $(git -C ../yinduzhanxing-ref rev-parse 5fb8379c:$f)"; done
+
+ls scripts/*.py | wc -l # 期望 448(= ROOT_PY_BUDGET)
+grep -n "ROOT_PY_BUDGET\|SILENT_EXCEPT_BUDGET" tests/test_python_hygiene_budget.py
+grep -n "^def cmd_jaimini\|report\['modules'\]\['jaimini'\] = jaimini_result" scripts/jyotish_engine.py
+grep -n "^def select_niryaana_shoola_start_sign" scripts/rashi_dashas_native.py
+grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -u | sort -t- -k2 -n | tail -1
+grep -oE "^## ERR-[0-9]+" docs/research/pre_work_error_ledger.md | sort -u | sort -t- -k2 -n | tail -1
+```
+
+冻结身份检查(T5 与交回前各跑一次,输出必须是 `[]`):
+
+```bash
+git diff --name-only origin/staging...HEAD > /tmp/goel_changed.txt
+.venv/bin/python - <<'EOF'
+import json, re
+src = open("scripts/research/sealed_holdout_rerun.py", encoding="utf-8").read()
+listed = set(re.findall(r'"(scripts/[^"]+\.py)"', src)) # PRODUCTION_FILES + RESEARCH_FILES
+ds = json.load(open("references/real_case_calibration/minute_rectification_holdout_v3.json", encoding="utf-8"))
+listed |= set(ds["frozen_scoring"]["files"])
+changed = {l.strip() for l in open("/tmp/goel_changed.txt") if l.strip()}
+print(sorted(listed & changed))
+EOF
+.venv/bin/python -m pytest -q tests/test_sealed_holdout_contract_freshness.py tests/test_rectification_validation_integrity_gate.py tests/test_reported_offset_research.py tests/test_rectification_engine_memoization.py
+```
+
+非空或转红 → 红线 4:停下、推分支、PROGRESS 写明,不重冻。
+
+## 10. BUG 编号
+
+本单是上游功能移植,预计**不新增** BUG 记录。若执行中确认了缺陷(例如 `cmd_jaimini` 舍入经度致对冲检查误判、Goel 失败带掉整个 jaimini 模块),按 `docs/BUG_HISTORY.md` 全文 `sort -u` 后的最大号 **+1** 起编(写作时最大号 BUG-1403;#88~#93 可能先占号,以开工时为准)。台账新 ERR 号同样开工时取最大号 +1。
diff --git a/docs/tasks/TASK-other-subject-birth-time-edit-20261021.md b/docs/tasks/TASK-other-subject-birth-time-edit-20261021.md
new file mode 100644
index 00000000..28d1da41
--- /dev/null
+++ b/docs/tasks/TASK-other-subject-birth-time-edit-20261021.md
@@ -0,0 +1,83 @@
+# TASK · 其他人物的出生时间可以修改(2026-10-21)
+
+## 0. 基线与顺序
+
+- 基线:`origin/staging` 117d8cbe5(开工时以最新 `origin/staging` 为准)。
+- **串行依赖**:必须等 Gitea issue **#86**(星盘页人名切换 / 出生资料就地编辑 / 报告行星表)合入 staging 之后再开工,并基于含 #86 的 staging 新建分支。#86 在星盘页编辑框里把「其他人的出生时间」锁住,本单负责解锁;两单都改 `people-page.tsx` 与同一编辑框,不得并行。
+- 不进 agent 循环:本单动数据库(新迁移 + 触发器),按 `tools/agent-loop` 规则由 Claude 或其派出的执行方在独立 worktree 完成,必须真跑 `npm run test:db`。
+- 分支 / 工作树:`codex/other-subject-birth-time-edit-20261021`,`.worktrees/other-subject-birth-time-edit-20261021`。
+
+## 1. 事故实证(行号按符号定位)
+
+用户在星盘档案或星盘页发现别人的出生时间填错了,改了保存,被拒「填报的出生时间不能再改」。挡住的有三层:
+
+1. 数据库:`frontend/supabase/migrations/20260925020000_chart_subject_typed_columns.sql` 的 `public.chart_profiles_guard_birth()`。`tg_op = 'UPDATE'` 分支里,只要 `old.reported_birth_time is not null` 且新值不同,就 `raise exception 'reported_birth_time_is_immutable'`。
+2. 接口:`frontend/src/app/api/chart-profiles/[id]/route.ts` 的 `PUT`。它先查当前 `reported_birth_time`,与新值不同就直接返回 409 `reported_birth_time_is_immutable`;随后 `if (storedReported) delete columns.reported_birth_time`,把新时间静默丢掉。同文件和 `route.ts` 顶部的 `databaseFailure` 也把该异常映射成 409。
+3. 界面:`frontend/src/components/people/people-page.tsx` 的 `PeopleArchiveView` 中,`ChartProfileForm` 的 `description` 对他人写「填报的出生时间保存后不能再改。」(#86 合入后,星盘页编辑框里还会多一处时间格锁定和同样的说明。)
+
+本人没有这个限制:`profiles` 表的同类锁已在 `20260721140000_repair_reported_birth_time_revision.sql` 去掉,本人走 `PATCH /api/account`,由 `resolveAccountBirthTimeApplicationPatch` 处理已采用时间的重置。
+
+## 2. 根因
+
+这把锁来自 `TASK-people-archive-p1-20260924.md` §验收 2:「他人类型化列具备与户主同级的守卫:`reported_*` 写入后不可改」。当时照抄的是户主 07-17 的守卫(`guard_birth_time_journey`),但户主那条 07-21 已为「用户可修改自己的声明」而撤销,抄过来的是过时规则。
+
+他人目前**没有任何校正链路**:`use-rectification-surface.ts` 与 `consultation-tools.ts` 的 `not_self` 都明确「生时校正暂时只支持本人」;`chart_profiles_guard_birth` 在 INSERT/UPDATE 都禁止写 `active_birth_*` 和 `accepted|confirmed`,所以他人的 `active_birth_*` 恒为空。也就是说,锁住 `reported_birth_time` 没有在保护任何已采用的结果。
+
+## 3. 决策记录(产品 2026-10-21 授权)
+
+- **推翻** `TASK-people-archive-p1-20260924.md` §验收 2 中「他人 `reported_*` 写入后不可改」这一条,以及该单 §验收 中 `npm run test:db`「`reported_birth_time` 更新被拒」这一用例。其余守卫保持不变:`active_*` 仍只能由(P2 的)校正流程写入,客户端与 `/api/chart-profiles` 仍不得写 `active_*` 或 `accepted|confirmed`;人数上限不变。
+- 产品原话:「他人时间也放开改」,场景是「用户突然发现自己写错时间」,对任何人都应该能改。
+- 改了他人的出生时间、日期或地点后:星盘、星历、对话按新资料重排。已生成的个人报告、合盘报告保持原样,不自动重算、不删除,与本人改资料时的现行行为一致。
+- 原型(产品已确认):https://claude.ai/artifact/7j5Z1raYBMsSVG87KX1JE5
+
+## 4. 硬红线
+
+1. 只放开 `reported_birth_time`(以及与它同属声明层的 `birth_time_source / birth_time_period / declared_window_* / uncertainty_*`,若当前 PUT 本就允许改则保持)。`active_birth_time / active_birth_date / active_birth_timezone_offset` 的锁,以及 `birth_time_status` 不得被改成 `accepted|confirmed` 的锁,**一字不动**。
+2. 新增迁移,不改历史迁移文件:`frontend/supabase/migrations/20261021xxxxxx_chart_subject_reported_time_editable.sql`,用 `create or replace function public.chart_profiles_guard_birth()` 重定义,保留 `revoke all ... from public` 等权限语句与 `set search_path = ''`。
+3. 不碰 `profiles` 表、`guard_birth_time_journey`、`PATCH /api/account`。
+4. 不改 `.gitea/workflows/**`,不顺带升级依赖。
+5. Bug 历史 / 测试 / 文档不得出现任何真实用户资料,示例用虚构数据。
+6. 部署前须先在 staging 跑迁移(staging 门禁会触发迁移工作流;执行方在进度记录里写明迁移 run 号)。
+
+## 5. 任务分解
+
+**T1 迁移:去掉他人填报时间不可改**
+- 新迁移按 §4.2 重定义触发器,删除 `reported_birth_time_is_immutable` 这一段,其余逻辑原样保留。
+- 向后兼容(AGENTS §7.6):staging 迁移先于部署应用,此时线上旧代码的 PUT 仍会自行返回 409,只是放宽、不会出错,可以与 T2 同轮。
+- 验收:`npm run test:db` 中 `frontend/tests/database-chart-subject.test.ts` 用例改为「他人 `reported_birth_time` 可以从 08:00 改成 08:01,且改后 `active_birth_*` 仍为空、`birth_time_status` 不变」;原有「写 `active_*` 被拒」「改成 confirmed 被拒」用例保持并通过。原断言 `/reported_birth_time_is_immutable/` 的改动按 AGENTS §7.3 写「原值 / 新值 / 原因」三栏。
+
+**T2 接口:PUT 接受新时间**
+- `chart-profiles/[id]/route.ts` 的 `PUT`:删除与 `storedReported` 比较后返回 409 的分支,以及 `delete columns.reported_birth_time`。`databaseFailure` 里 `reported_birth_time_is_immutable` 的映射可删(迁移后不再出现);POST 路由的同名映射一并清理。
+- 验收:新增或改写接口测试,覆盖「PUT 改他人时间 → 200 且返回新时间」「PUT 请求体带 `active_birth_time` → 仍被拒」。
+
+**T3 界面:解锁他人时间**
+- `people-page.tsx`:他人编辑说明去掉「填报的出生时间保存后不能再改。」,与本人统一为「称呼和出生资料。」
+- #86 在星盘页编辑框里加的「他人时间格锁定 + 说明」:解除锁定、删掉说明。改了时间、日期或地点时,提示文案对他人用「改了出生时间、日期或地点,星盘会按新资料重排。」,不得出现「校正作废」字样(他人没有校正)。
+- 保存后,星盘页、星历页立即显示新时间,不需要手动刷新:对该人物沿用 #86 中本人保存后的缓存失效方式(以 #86 合入后的实现为准)。
+- 验收:前端测试覆盖他人时间可编辑、提示文案按本人/他人区分;`frontend/DESIGN.md` 与 `CHANGELOG.md` 同一提交更新。
+
+**T4 记录**
+- `docs/BUG_HISTORY.md` 新增一条(编号开工时核对最大号;写本单时最大号为 BUG-1403,#86 也会占号):状态、现象、根因(照抄过时的户主守卫)、修复、验证、防复发(守卫测试改为「可改 reported、不可改 active」)、关联 BUG-196 与 people-archive P1。
+- `docs/tasks/PROGRESS-other-subject-birth-time-edit-20261021.md` 写执行记录。
+
+## 6. 验收口径
+
+- 前端:`tsc --noEmit` 0 错;`npm run lint` 0 error;全量 `npm test` 与开工基线逐条比对,fail 不增;`next build` 后 `/` 仍 Static;首屏 gzip ±2%。
+- 数据库:`npm run test:db --prefix frontend` 真跑(本机无 Docker 时按 `local-postgres-without-docker` 做法用 PG17 二进制),`database-chart-subject` 全绿。
+- Python:`.venv/bin/python scripts/run_quality_gate.py --profile quick` 通过(`tests/test_birth_time_journey_contract.py` 断言的是 07-17 旧迁移文件,不应受影响;若受影响,停下来报告)。
+- 部署:staging 迁移 run 绿,`/api/health` 的 `deployment.gitCommit` 等于本单提交。
+- 真机清单写进 `docs/testing/`:在星盘档案和星盘页各改一次他人的出生时间,确认保存成功、星盘立即重排、本人资料不受影响。
+
+## 7. 让步顺序
+
+时间不够时:T1、T2 不可让步(合在一起才能解锁)→ T3 的星盘页编辑框可以晚于档案页 → 记录类最后补,但不得缺。
+
+## 8. 开工前置命令
+
+```bash
+cd /workspace/Jyotisha && git status -sb | head -1
+git fetch origin --prune
+git log origin/staging --oneline | grep -m1 '#86' # 确认 #86 已合入,否则不开工
+git worktree add -b codex/other-subject-birth-time-edit-20261021 .worktrees/other-subject-birth-time-edit-20261021 origin/staging
+grep -o 'BUG-1[0-9]\{3\}' docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
+```
diff --git a/docs/tasks/TASK-report-english-han-leak-20261021.md b/docs/tasks/TASK-report-english-han-leak-20261021.md
new file mode 100644
index 00000000..580fb3eb
--- /dev/null
+++ b/docs/tasks/TASK-report-english-han-leak-20261021.md
@@ -0,0 +1,137 @@
+# 任务书:个人报告英文版约 4 成被整份丢弃——漏译汉字与吃空格(issue #89,BUG-1272 复发)(2026-10-21)
+
+## 基线
+
+- `origin/staging` = `bd3ce0e7b`。开工前 `git fetch origin --prune`,以 `origin/staging` 为基线。
+- 执行方:**Grok(Windows + Docker)**。分支 `codex/report-english-han-leak-20261021`,工作树 `.worktrees/report-english-han-leak-20261021`,**只推这个分支**,不推 staging、不碰 main。
+- 关联:BUG-1272(本单复发自它)、BUG-1218 / BUG-1225(Rath 版 Narayana 并列展示)、BUG-1273(生产镜像无 PyJHora)、BUG-1276、BUG-1277。
+- **串行顺序**:本单最先做。#91、#92 之后改同一批渲染文件(`pl9_reader_export.py`、`pl9_full_data_export.py`),并沿用本单的 200 张盘测试。本单合入 staging 前,#91 不开工。
+
+## 事故实证
+
+Claude 在 `bd3ce0e7b` 上复现:两组固定种子各生成 60 张、80 张随机虚构盘,屏蔽 PyJHora(模拟生产镜像),走 `professional_report_reference._english_edition`。
+
+| 项 | 结果 |
+| --- | --- |
+| 140 张盘中被判 `han_leak`、整份英文丢弃 | **56 张(40%)**,与 issue 的 16/40 一致 |
+| 是否与 PyJHora 有关 | 无关。同一批盘装与不装 PyJHora,泄漏完全相同 |
+| 现有测试的 3 张虚构盘(`tests/test_report_english_edition.py` 的 `CASES`) | 3 张都干净,所以测试一直是绿的 |
+| 每张盘耗时(Linux,本机) | 建资料包约 2.5 s,英文渲染约 0.9 s,`_english_edition` 全程约 1.0 s |
+
+### 漏译汉字(按出现盘数)
+
+| # | 残留汉字 | 140 张中出现 | 英文里的样子 | 来源(按符号定位) |
+| --- | --- | --- | --- | --- |
+| L1 | 座内 | 21 | `period start Libra(house 5 level: lord[source text retained: 座内]degreecount)` | `narayana_legacy_label.rath_summary_line_zh`:英文版也调用它,把 `STRENGTH_LEVEL_ZH[5]`「宫主星座内度数」交给末尾词表拆着译。`STRENGTH_LEVEL_EN` 已有 `lord's degree in sign`,没人用 |
+| L2 | 凶 | 21 | `\| overall nature \| [source text retained: 凶](Asubha) \|` | `muhurta.calc_panchanga` 的 `overall_quality` 只有中文(`吉(Subha)` / `中(Mixed)` / `凶(Asubha)`);`pl9_reader_export.render_pl9_parity_markdown` 的 Panchanga 小表不分语言打印标签「综合性质」和这个值 |
+| L3 | 计都、反向 | 5 | `- order: [source text retained: 计都]inperiod start: [source text retained: 反向](Table 12)` | `narayana_legacy_label.rath_report_lines_zh` 的「- 顺序:」行用 `direction_text`(中文)。`direction_text_en` 已在 `rath_narayana_view` 里存好,没人用 |
+| L4 | 计都、反向(issue 未列) | 9 | `- sub-period direction: Dashasignpresent[source text retained: 计都]: [source text retained: 反向](Table 14), retrograde(the book itself disagrees, …` | `narayana_legacy_label._ad_direction_lines_zh` 用 `rule_text`(中文)。`narayana_rath` 已给出 `rule_text_en` |
+| L5 | 观察 | 7 | `\| Punarphoo Yoga \| relationship_observation \| [source text retained: 观察] \| …` | `render_pl9_parity_markdown` 瑜伽表(表头 `\| Yoga \| Category \| Strength \|`)英文分支换了 `effects_en`、`combination_en`,**强度列仍打印 `row['strength']`**。`yoga_engine` 已为每行写 `strength_en`(Punarphoo 规则自带 `observational`,其他走 `STRENGTH_EN`)。「强 / 中 / 弱」之所以没漏,是末尾词表碰巧有单字条目 |
+| L6 | 完全(issue 未列) | 7 | `\| Kaal Sarp Dosha \| True \| … \| [source text retained: 完全]Kaal Sarp Dosha——atpresent7planetsinRahu(6)—Ketubetween the axis \|` | `yogas_doshas.calc_kaal_sarp_dosha` 的 `summary` 只有中文;`render_pl9_parity_markdown` 的 Dosha 结果表(`### p163-p171 Mangala / Sade Sati / Dosha Results`)打印 `row['summary']` |
+
+### 吃空格与错译(不留汉字,但英文读不通)
+
+末尾词表把中文按字词换成英文,换出来的词之间不加空格;「第」一律换成 `house `。在 140 张盘里这些不是偶发,而是**每张盘都有**:
+
+| 粘连 / 错译 | 140 张中出现 | 来源线索 |
+| --- | --- | --- |
+| `scoreandtimeline`、`house seven-round decision`(「第七轮裁定」)、`house 5 level`(「第 5 级」) | 每张盘 | `RATH_LABEL_ZH` 与 `rath_summary_line_zh` 的「第 N 级」;`RATH_LABEL_EN` 已存在 |
+| `inperiod start`、`Dashasignpresent`、`aturninperiod` | 与 L3 / L4 同盘 | 同上,Rath 顺序与子运方向行 |
+| `atpresent7planetsinRahu`、`planetsinbetween`、`partialKaal Sarp Dosha——houses 6/7 condition planets…`(「6/7 颗行星」被译成 `6/7 condition planets`,意思错了) | 约 2 成 | `yogas_doshas.calc_kaal_sarp_dosha` 的 full / partial 两句 |
+| `read together withSaturnjudge from the actual position whetherinunderway` | 每张盘 | `yogas_doshas` 的 Sade Sati 框架摘要(「需结合Saturn实际位置判断是否在进行中」) |
+| `careerplanetAmK`、`careerdirection`、`marriagequality` | 每张盘 | Jaimini 星位表:「事业星AmK」等名称与 `jaimini.py` 的职责描述(如 `'Amatyakaraka': '事业方向、主要谋士、权力代理'`) |
+| `supportingstrongdegree` | 每张盘 | 标题 `### Vimsopaka / Pushkara / Vargottama …` 的中文后半 |
+| `beneficfactor count` | 每张盘 | Panchanga 小表标签「吉性要素数量」(随 T3 一并改) |
+| `comparativelystrong`、`occupiesplanetcount` | 每张盘 / 约 4 成 | Rath 子运比较行「较强」与 `STRENGTH_LEVEL_ZH[1]` |
+| `lordandstrong`、`lordstrongand`、`weakafflicted`、`partialreducedweak`、`debilitatedsign`、`planetenterssign`、`planetsdistributed`、`beneficlocated` | 2~8 成 | 瑜伽表组合列:部分规则没有 `combination_en`(或只有中文 `combo_template`),英文分支回落到中文再被拆译 |
+
+`pl9_full_data_export._sanitize_pl9_ai_density_markdown_en` 的 `phrase_terms` 里有 `"在": "in"`、`"与": "and"`、`"第": "house "`、`"宫": ""` 这类单字条目,且按长度从长到短一次替换;`pl9_language_terms.normalize_en_markdown_terms` → `apply_en_residual_han` 再补一轮。长短词表分在不同轮次,先跑的短词会把后面才有的长句拆碎。这就是 L1~L6 和粘连的共同机制。
+
+## 根因
+
+1. **英文版不是按语言渲染出来的,而是中文渲染后再在末尾逐词替换。** `render_full_data_markdown` 的英文分支仍然调用 `render_pl9_parity_markdown`,而它对 Rath 段落、Panchanga、Dosha 摘要、瑜伽强度列等处不分语言,打印中文。英文能否干净,取决于末尾三套词表(`_sanitize_pl9_ai_density_markdown_en` 内的 `replacements` / `reverse_terms` / `phrase_terms`,以及 `pl9_language_terms` 的 `_EN_RESIDUAL_HAN`)是否恰好覆盖、替换顺序是否恰好不拆句。只要某张盘触发一条没有覆盖的句子,就会留下汉字,整份英文被撤回。
+2. **英文孪生字段早已存在却没接上。** `strength_en`、`direction_text_en`、`strength_level_text_en`、`rule_text_en`、`method_note_en`、`RATH_LABEL_EN`、`LEGACY_NOTE_EN` 都已经在资料包或常量里,渲染层没用。旧阅读版有 `pl9_reader_english._english_packet` 负责把 `strength_en` 换进去,完整数据版的 `_english_edition` 只做了 `deepcopy` + `report_language = "en"`,没有这一步。
+3. **BUG-1272 的测试为什么没拦住**:
+ - 只有 3 张固定虚构盘(另 3 张公开盘只在当时手工跑过,没进测试)。泄漏都是按盘触发的:Rath 起运宫裁到第 5 级约 15%,Panchanga 综合为凶约 15%,计都在起运宫或子运方向走 Table 14 约 10%,月土合相 ≤10° 约 5%,完全 Kaal Sarp 约 5%,合起来约 40%。3 张盘恰好都没碰上。
+ - `tests/test_narayana_legacy_label.py::test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines` 断言 Rath 各行能被 `pl9_reader_english_terms.PHRASES_EN / REGEX_EN` 译干净。**完整数据版英文根本不走这张表**(只有旧阅读版走),所以这条测试一直通过,生产路径却漏。
+ - `tests/test_report_english_edition.py` 不在快速门清单(`scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`),staging 门禁不跑它,只有手动的 release 门禁全量会跑。
+ - BUG-1272 的修法本身是「把拆开后剩下的碎片补进英文术语」,即继续往末尾词表加条目;遇到新的句子或新的盘况就会再漏。
+4. **BUG-1272 的防复发措施仍在**:`test_english_edition_has_no_chinese`(3 张盘汉字为 0)、`test_api_drops_english_when_chinese_would_leak`(人为写入汉字时接口撤回英文)、前端 `acceptEnglishEdition` 发现汉字时判 `han_leak`、`frontend/tests/report-english-edition.test.tsx` 的对应断言,都还在,且都通过。它们没有失效,只是覆盖面太窄。
+
+## 决策记录(产品负责人,issue #89)
+
+| # | 决定 |
+| --- | --- |
+| D1 | **从源头补齐英文**:英文分支直接用已有的英文孪生字段或新增的英文孪生字段渲染(例如 Punarphoo 强度走 `strength_en` / `STRENGTH_EN`)。**不准只在末尾做字符串替换**——本单不得为修这些泄漏往 `_sanitize_pl9_ai_density_markdown_en` 的三张表、`pl9_language_terms._EN_RESIDUAL_HAN`、`pl9_en_glossary`、`pl9_reader_english_terms` 新增条目 |
+| D2 | 修好吃空格(`inperiod start`、`scoreandtimeline` 等),同样在源头改 |
+| D3 | 回归测试:**200 张固定种子的随机虚构盘**,英文汉字数为 0,且不出现 `[source text retained`。时长允许放进快速门,否则放进全量测试并在 PROGRESS 写明(见 T6 的拆分) |
+| D4 | 不需要迁移。已生成的报告要重新生成才有英文版(CHANGELOG 写明) |
+| D5 | `docs/BUG_HISTORY.md` 标明**复发自 BUG-1272**,并写清当时 4 张盘的测试为什么没拦住(用上面「根因 3」) |
+| D6 | 顺序:最先做,#91 / #92 在后 |
+| — | 接口与前端「发现汉字就整份撤回英文」的判定**保留不变**(BUG-1272 既有设计);`_retain_remaining_cjk_lines` 的标记方式也保留。本单是让英文不再产生汉字,不是放宽判定 |
+
+## 硬红线
+
+1. **中文版逐字节不变**。用同一组固定种子(至少 30 张,含 L1~L6 各至少一张),在 `bd3ce0e7b` 与本分支各渲染一次中文完整数据版,SHA-256 全部相同。生产者里只能**新增** `*_en` 字段,不得改中文字段的值。
+2. 不得放宽 `_english_edition` 与 `acceptEnglishEdition` 的汉字判定;不得删 `[source text retained` 标记逻辑去「凑」汉字为 0。
+3. 不往末尾词表加条目(D1)。若某一处确实只能靠词表(例如上游 PL9 原样搬来的整段说明),先停下来在 PROGRESS 列出,交 Claude 判断。
+4. Rath 版仍然只做展示:英文行函数放在 `narayana_legacy_label.py`,不得让 `narayana_rath` 被第二个文件导入(`tests/test_narayana_rath_book_tables.py::test_rath_profile_is_display_only`);不得碰冻结的打分文件(`references/rectification_sealed_holdout.v1.json` 列出的文件,ERR-110 / ERR-115)。
+5. 测试必须屏蔽 PyJHora(照 `tests/test_special_lagnas_birth_instant.py` 的 `_block_jhora` 做法;多进程时在每个子进程初始化里装同样的屏蔽),种子、`today`、`target_year` 固定,同一提交重跑结果相同。
+6. 改任何既有断言写「原值 / 新值 / 原因」三栏(AGENTS §7.3);先 grep 按源码文本断言的合同测试(§7.8),见「开工前置命令」。
+7. 不改 `.gitea/workflows/**`,不改数据库,不升级依赖。
+8. 测试与文档只用虚构盘,不写任何真实出生资料(AGENTS §8)。
+
+## 任务分解
+
+| # | 内容 | 验收标准 |
+| --- | --- | --- |
+| T1 | **Rath 段按语言渲染**(L1、L3、L4 与 Rath 段全部粘连):在 `narayana_legacy_label.py` 新增 `rath_summary_line_en` / `rath_report_lines_en` / `_ad_direction_lines_en`,用 `RATH_LABEL_EN`、`strength_level_text_en`、`direction_text_en`、`method_note_en`、`dual_lord_basis_en`、`rule_text_en`(含书内分歧那几条的 `rule_text_en`),「第 N 级」写成 `level N`,表头用英文。`render_pl9_parity_markdown` 的 Narayana 段英文分支改调英文函数,并改用 `LEGACY_NOTE_EN` 写算法行;去掉现在英文分支里拿 `dual_lord_basis` 做字符串替换的那段(被新函数取代);防重复判断(现为 `startswith('- 并列:')`)按语言各判各的 | 单测:用一个 Rath 视图分别覆盖第 1~7 级、Table 10 / 11 / 12、子运方向带书内分歧,英文行无汉字、无 `house N level`,含 `level 5: lord's degree in sign`、`Ketu in the start sign: reversed (Table 12)` 原文;中文行与改前逐字相同 |
+| T2 | **瑜伽强度列**(L5):`render_pl9_parity_markdown` 瑜伽表英文分支用 `row['strength_en']`,缺了再用 `yoga_engine.STRENGTH_EN`;两者都没有时不得回落到中文——让 200 张盘测试暴露出来,并在生产者补 `strength_en`。同一分支里,组合列中仍回落到中文的规则(`lordandstrong`、`weakafflicted`、`debilitatedsign` 等)补 `combo_template_en` / `combination_en`,改 `references/yoga_rules.json` 或对应检测器,只加英文字段 | 单测:Punarphoo 行英文强度列不含汉字;200 张盘中瑜伽表组合列无上表所列粘连词 |
+| T3 | **Panchanga 综合性质**(L2):`muhurta.calc_panchanga` 新增 `overall_quality_en`(`Auspicious (Subha)` / `Mixed` / `Inauspicious (Asubha)`,按同一分数档位);`render_pl9_parity_markdown` 的 Panchanga 小表英文分支用英文标签(`Overall quality`、`Auspicious elements`、`Total elements`)和 `overall_quality_en` | 单测:三个档位英文值正确;中文值不变。若新增字段让 `tests/golden/*keypaths*` 一类键路径快照变化,按三栏更新并说明 |
+| T4 | **Dosha 摘要**(L6 与 Kaal Sarp / Sade Sati 粘连):`yogas_doshas` 中 Mangal、Kaal Sarp、Pitra、Sade Sati 的检测函数各新增 `summary_en`(`calc_kaal_sarp_dosha` 的 full / partial / none 三句;partial 句的「N/M 颗行星」写成 `N of M planets`);Dosha 结果表与 Sade Sati 小节英文分支用 `summary_en` 并把「当前阶段 / 是否 active / 摘要」等标签按语言写 | 单测:完全与部分 Kaal Sarp 的英文摘要原文断言;200 张盘中无 `atpresent`、`planetsinbetween`、`whetherinunderway`、`withSaturn` 粘连 |
+| T5 | **其余每张盘都有的粘连**:Jaimini 星位表的名称与职责(`careerplanetAmK`、`careerdirection`、`marriagequality`,看 `jaimini.py` 的职责表与渲染处)、`### Vimsopaka / Pushkara / Vargottama` 标题后半。都按语言渲染或补英文孪生字段 | 200 张盘中这些词计数为 0 |
+| T6 | **回归测试,分两个文件**:① `tests/test_report_english_sweep.py`:200 张固定种子虚构盘(年 1940–2015、月日时分随机、纬度 −45~60、经度 −120~150、时区按经度取半小时、`ayanamsa` 在 lahiri / raman / kp 间、`node_mode` 在 mean / true 间随机;`today` / `target_year` 固定),屏蔽 PyJHora,对每张盘调 `_english_edition(packet, True)`:必须返回 `markdown_en`、不得有 `english_unavailable`;`markdown_en` 与 `reader_dasha_applicability_en` 汉字数为 0、不含 `[source text retained`;不含粘连黑名单(本任务书「吃空格」表里出现的全部词)。允许用进程池(不超过 4 个进程)。失败信息列出盘号与残留片段,不打印出生资料以外的东西也够定位。另提供 `--dump-dir`(或环境变量)把 200 份英文 Markdown 写到临时目录,供 Claude 验收时跑前端判定。② `tests/test_report_english_sweep_smoke.py`:从同一生成器中挑 12~16 张,必须覆盖 L1~L6 每一类至少一张,断言同上;**把这个文件加进 `scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`**(注释写明 issue #89 / BUG 号)。旧的 `test_reference_edition_shows_both_and_english_terms_cover_the_rath_lines` 改为断言完整数据版英文路径本身(三栏:原值「Rath 中文行经 `PHRASES_EN` 可译干净」/ 新值「完整数据版英文渲染的 Rath 段无汉字且用英文函数」/ 原因「完整数据版不走 `PHRASES_EN`,旧断言测的不是生产路径」) | 两个文件在本分支全绿;把 T1 的英文函数临时改回中文时,smoke 文件会红(PROGRESS 写这次变异的结果);PROGRESS 记录 Windows 上两个文件的耗时(串行与进程池各一次) |
+| T7 | **文档**:`docs/BUG_HISTORY.md` 新增两条(见「BUG 编号」);`CHANGELOG.md` 写「英文版不再因个别漏译整份消失;已生成的报告需重新生成才有英文版」;`docs/tasks/PROGRESS-report-english-han-leak-20261021.md` 写清改了哪些源头、各粘连词前后计数、两份测试耗时、中文逐字节对比结果、真机复测步骤(登录 staging → 新建一份个人报告 → 揭幕后切到 English → 全文搜不到汉字、L1~L6 对应段落读得通 → 导出英文版) | 文档齐;BUG 记录写明复发自 BUG-1272 与根因 3 的四点;不含任何真实资料(推送前跑 `python -m pytest tests/test_repo_privacy_markers.py -q`) |
+
+### 自跑与验收分工
+
+| 检查 | Grok(Windows + Docker)自跑 | Claude(Linux)验收 |
+| --- | --- | --- |
+| 改到的 Python 测试文件(含 T6 两个新文件、`test_report_english_edition.py`、`test_narayana_legacy_label.py`、`test_narayana_rath_book_tables.py`、`test_year_lord_blocked_no_fallback.py`、`test_year_lord_basis_label.py`、`test_report_full_data_fix.py`、`test_report_chart_blank_columns.py`、`test_runtime_jhora_import_scan.py`,以及改到的 muhurta / yoga 相关测试) | 必跑(工作树 `.venv`) | 复验 |
+| 中文逐字节对比(红线 1) | 必跑,结果写 PROGRESS | 复验(独立重算哈希) |
+| `tsc --noEmit`、`npm run lint`、改到的前端测试单文件 | 只在动了 `frontend/` 时跑 | 复验 |
+| Python 快速门 `scripts/run_quality_gate.py --profile quick` | 不跑 | **以 Claude 为准** |
+| Python 全量 pytest,失败名单与 `bd3ce0e7b` 逐名比对 | 不跑 | **以 Claude 为准**(本单改的是引擎输出文字,必须全量) |
+| 前端全量 `npm test` | 不跑 | 以 Claude 为准 |
+| 200 份英文 Markdown 过前端判定:`acceptEnglishEdition`(含 `ordinaryOutputLeaks(…, "en")` 与 `assembleReportFactTables(…, "en")` 的汉字检查) | 不跑 | Claude 用 T6 的 `--dump-dir` 输出跑一次;有 `public_leak` / `fact_tables_han` 时另开修复单 |
+| `npm run test:db` | 不涉及数据库,不跑 | — |
+
+## 让步顺序
+
+1. 不可让步:英文汉字为 0 与无 `[source text retained`(200 张)、中文逐字节不变、不往末尾词表加条目、Rath 只做展示。
+2. 先交 T1~T4(issue 点名的泄漏与粘连)+ T6 + T7,作为第一个提交;T5 作为第二个提交。
+3. 若 T5 中某一处源头改动牵涉过大(例如要改上游原样搬来的整段说明),可以把该处从 T6 粘连黑名单中拿出,在 PROGRESS 列出词、出现盘数和源头位置,留给 #91 一并处理;汉字为 0 不在可让步之列。
+4. 若 200 张盘测试在 Windows 上即便用进程池也超过 10 分钟,在 PROGRESS 写明实测时间,Claude 决定是否请产品负责人改为只在验收时跑;不得自行减少盘数。
+
+## 开工前置命令
+
+```bash
+git fetch origin --prune
+git worktree add -b codex/report-english-han-leak-20261021 .worktrees/report-english-han-leak-20261021 origin/staging
+cd .worktrees/report-english-han-leak-20261021
+git status -sb
+# 按源码文本断言、或直接 import 这些符号的测试(§7.8)
+grep -rln "rath_report_lines_zh\|rath_summary_line_zh\|_ad_direction_lines_zh\|STRENGTH_LEVEL_\|DIRECTION_ZH\|RATH_LABEL_" tests frontend/tests
+grep -rln "overall_quality\|calc_panchanga\|calc_kaal_sarp_dosha\|summary_en\|strength_en\|STRENGTH_EN" tests frontend/tests
+grep -rln "source text retained\|han_leak\|PHRASES_EN\|REGEX_EN" tests frontend/tests
+grep -n "CORE_PYTEST_TARGETS" -A5 scripts/run_quality_gate.py
+grep -rln "CORE_PYTEST_TARGETS\|test_report_english" tests
+# BUG 编号:取整个文件的最大号(文件里有整段重复,见下)
+grep -o "BUG-1[0-9]\{3\}" docs/BUG_HISTORY.md | sort -u | tail -3
+```
+
+## BUG 编号
+
+- 当前 `docs/BUG_HISTORY.md` 最大号 BUG-1403;#85 已预留 1404 起的若干号。**开工时取全文件最大号 + 1**,预计在 BUG-1410 前后。
+- 建议两条:① 英文版漏译致整份丢弃(复发自 BUG-1272;写根因 3 的四点);② 英文版吃空格与「第 N 级」错译成 house(关联 BUG-1272、BUG-1218)。
+- 注意:`docs/BUG_HISTORY.md` 自 `400af57e8`(2026-10-14 合并)起整份内容重复了一遍(同一编号出现两次)。本单**只在文件末尾追加**,不顺手去重(另行处理)。
diff --git a/docs/tasks/TASK-report-export-dialog-polish-20261021.md b/docs/tasks/TASK-report-export-dialog-polish-20261021.md
new file mode 100644
index 00000000..21c54aef
--- /dev/null
+++ b/docs/tasks/TASK-report-export-dialog-polish-20261021.md
@@ -0,0 +1,113 @@
+# 任务书:导出弹窗——英文不可用提示改顶部浮层,选中态改用主题色(2026-10-21)
+
+## 基线
+
+- `origin/staging` = `bd3ce0e7b`。开工前 `git fetch origin --prune`,以届时的 `origin/staging` 为准。
+- 执行方:**Grok(Windows + Docker)**;分支 `codex/report-export-dialog-polish-20261021`,工作树 `.worktrees/report-export-dialog-polish-20261021`,**只推这个分支**。
+- 关联:issue #90;BUG-1078(导出面板选中态深红褐底)、BUG-1079(全站实心按钮配色)、BUG-1094(导出显示真实大小)、BUG-1121(结果提示只经 `notify.ts`);`TASK-report-reader-polish-and-english-20260929`、导出面板 2026-09-30 重设计。
+- 纯前端,不涉及数据库、Python、`scripts/`。
+
+## 现状实证(按符号定位)
+
+| # | 现象 | 位置 |
+| --- | --- | --- |
+| 1 | 英文版不可用时,弹窗里多出一行灰字占位置 | `frontend/src/components/personal-report/report-export-drawer.tsx` 的 `ReportExportDrawer`:`hasEnglish` 为假时渲染 ``,两句固定文案——`englishEdition === "unavailable"` →「这份报告的英文版没有生成成功,只能导出中文版。」;否则(`"legacy"`)→「这份报告生成于英文版上线前,重新生成即可获得中英两版。」 |
+| 2 | 英文是否可用在弹窗打开时就已知,不需要再请求 | 同组件的 `hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length)`。`englishEdition` 来自 `report-actions.tsx` 的 `ReportActions` 属性,再上游是 `personal-report-page.tsx` 的 `readEnglishEdition`(接口 `englishEdition` + 前端汉字复核)。弹窗由 `ReportActions` 的 `exportOpen` 状态控制,**每次打开都重新挂载**,挂载时 `useEffect(…, [])` 调 `showModal()`。注意两个组件的 `englishEdition` 默认值都是 `"legacy"` |
+| 3 | 英文可用时语言分段下方还有一行建议「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」,同样是 `.report-export-caption` | issue 只点名「英文不可用」与 legacy 两句,见决策 D1 |
+| 4 | 顶部浮层 API 已有 | `frontend/src/lib/notify.ts`:`notifySuccess` / `notifyInfo` / `notifyError(message, { id?, duration? })`,info 3 秒;同 `id` 后一条替换前一条;每次调用先 `liftToastsAboveModal()`——**只有当页面上已有 `dialog:modal` 时**才把浮层提到弹窗之上。弹窗现有用法:`download()` 里 `notifySuccess("已导出")`、`notifyError(…)`。其它处用 `id` 去重的例子:`personal-report-center.tsx` 的 `{ id: "reports-load-more" }`、`email-otp-login.tsx` 的 `AUTH_NOTICE` |
+| 5 | 选中态是灰底+黑字 | `frontend/src/app/globals.css`(按选择器找):`.report-export-segment button[aria-checked="true"]`(`--color-canvas` 底 + `--color-ink` + 阴影,轨道 `.report-export-segment` 是 `--color-canvas-muted`);`.report-export-mode[aria-checked="true"]`(边 `--color-ink-secondary`、底 `--color-canvas-muted`);`.report-export-mode[aria-checked="true"] .report-export-radio`(`border: 5px solid var(--color-ink)`);`.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box`(边与底 `--color-ink`,勾为 `--color-canvas`) |
+| 6 | 要推翻的那句设计规范 | `frontend/DESIGN.md`「导出报告 sheet (redesigned 2026-09-30 …)」条目末尾:「Selected states use `--color-canvas-muted` + `--color-ink`, never `--color-action-soft` (BUG-1078).」同一条目里还有「otherwise one caption saying why there is no English」;`frontend/docs/VOICE.md`「导出面板(2026-09-30 重设计)」条目末尾写了两句无英文说明 |
+| 7 | 主题色 token | DESIGN.md 色表:`--color-action-strong` 是「fill rank:rings, carets, **checked radios**」,`--color-action` 是文字/图标档。值:`--color-action-strong` 浅 `#cc785c`、深 `#d78064`;`--color-action` 浅 `#a9583e`、深 `#d78064`(`globals.css` `:root`、`@media (prefers-color-scheme: dark) :root:not([data-theme="light"])`、`:root[data-theme="dark"]` 三处)。BUG-1078 的深红褐是 **`--color-action-soft` 深色 `#3a2620`**;按钮专用 `--color-action-fill` 深色 `#8f4a33` 也偏深红褐。实测对比度:`#cc785c` 对浅底 `#fbfaf7` 3.14:1、`#d78064` 对深底 `#262624` 5.17:1(非文字 3:1 线都过);`#a9583e` 对浅底 4.85:1 |
+| 8 | 页面顶栏还有一个「中文 / English」胶囊 `.report-language-switch`(不在弹窗内),选中态也是 `--color-canvas-muted` + ink(DESIGN.md「中文 / English (2026-09-29)」条) | 见决策 D3 |
+
+## 根因
+
+1. 导出面板 2026-09-30 重设计时,把「为什么没有英文」当成面板内容写成常驻说明行;按 DESIGN.md「提示与报错:toast」口径,这是一次性告知,应走顶部浮层,不占布局。
+2. BUG-1078 当时为了躲开深色模式下 `--color-action-soft` 的深红褐色块,把选中态全部改成中性灰 + 黑,导致选中与未选中区分弱、不带品牌色。正确做法是**只把描边、圆点、勾选框换成主题色,底色保持中性**,既不出现大块深色底,也能一眼看出选中。
+
+## 决策记录(产品负责人,issue #90)
+
+| # | 决定 |
+| --- | --- |
+| D0 | 打开导出弹窗时若英文版不可用(`unavailable` 与 `legacy` 两种),出**一次**顶部浮层提示(复用 `notify.ts`),删除弹窗内对应的那行说明。文案沿用原两句,不改字 |
+| D0' | 「整篇报告 / 自选章节」选中态:描边与单选圆点用主题色,底色中性;深色模式不得出现 BUG-1078 那种深红褐底。勾选框、弹窗内中英切换的选中态同样处理 |
+| D0'' | **推翻 DESIGN.md「Selected states use `--color-canvas-muted` + `--color-ink`, never `--color-action-soft` (BUG-1078).」这一句**,同一提交更新 DESIGN.md。BUG-1078 的主语「导出面板选中态不得用 `--color-action-soft` 深色块」保留(不推翻) |
+| D1 | (Claude 按 issue 字面定,产品可否决)英文**可用**时那句「拿去问 ChatGPT…建议选 English」保留在语言分段下方,不改成浮层——它是选项说明,不是事件提示。`.report-export-caption` 类保留给它用 |
+| D2 | (Claude 定)主题色用 DESIGN 色表里的 fill 档 `--color-action-strong` 画描边/圆点/勾选框底;文字若需着色用文字档 `--color-action`。**不用** `--color-action-soft`、`--color-action-fill`、任何新色值 |
+| D3 | 页面顶栏的 `.report-language-switch` 胶囊**不在本单范围**(issue 写的是弹窗内);若产品要一并改,另开 issue |
+
+## 硬红线
+
+1. `./node_modules/.bin/tsc --noEmit` 0 错;`npm run lint` 0 error。
+2. 前端全量 `npm test` 失败清单与开工时 `origin/staging` 基线逐条一致(`# fail`、`# cancelled` 与退出码一起看);测试总数不低于基线。
+3. `next build` 后 `/` 仍 `○ Static`;首屏 gzip 变化 ±2% 内(导出弹窗不在首屏,预期 0 变化)。
+4. 改 UI 的同一提交更新 `frontend/DESIGN.md`(含推翻那一句)与 `frontend/docs/VOICE.md`。
+5. 浮层**每次打开弹窗只出一次**:只在挂载时的 effect 里、`showModal()` **之后**调用(否则 `liftToastsAboveModal` 找不到 `dialog:modal`,浮层落在遮罩下);切换整篇/自选、勾选、切换语言等重渲染不得再出;必须带固定 `id`(如 `"report-export-english"`),React 严格模式开发环境 effect 双跑时也只剩一条。不得在 render 里调用 notify。
+6. 不新增任何加载动画、spinner、骨架、额外请求(`report-export-drawer.test.tsx` 现有 `doesNotMatch(/InlineSpinner|navigator\.clipboard|fetch\(/)` 保持)。
+7. 选中态不得出现 `--color-action-soft` / `--color-action-fill`,不得新造色值,不得改任何主题 token 值(`tests/test_frontend_theme_contract.py`、`dark-theme-contract`、`design-token-contract` 不动)。
+8. 改既有断言必须写「原值 / 新值 / 原因」三栏;不得静默弱化。不改 `.gitea/`、不升依赖、不顺手修范围外 warning。
+9. 公开仓库:测试与文档只用虚构 / 已有 golden fixture,不写任何真实个人资料。
+
+## 必须同轮更新的源码文本合同测试(AGENTS §7.8)
+
+| 文件 · 用例 | 现断言 | 本单后必须改成 |
+| --- | --- | --- |
+| `frontend/tests/report-buttons-scope-20260928.test.ts`「the BUG-1078 report-only override is gone; the selected export preset stays neutral」 | 对 `.report-export-mode[aria-checked="true"]`、`.report-export-segment button[aria-checked="true"]` 断言 `doesNotMatch(/--color-action-soft\|--color-action\b/)` 且 `match(/--color-ink\|--color-canvas/)`。**注意 `--color-action\b` 会命中 `--color-action-strong`**,改完必红 | 三栏:保留「不得用 `--color-action-soft` / `--color-action-fill`」;新增「选中描边 / 圆点用 `--color-action-strong`」;底色只许中性 token(`transparent` / `--color-canvas*`)。原因:issue #90 推翻 DESIGN 那一句,BUG-1078 主语不变 |
+| `frontend/tests/report-english-edition.test.tsx`「the export drawer suggests English for AI only when it can switch」 | legacy 时断言 `h.container.text.includes("重新生成即可获得中英两版")` | 三栏:改为断言弹窗内**不含**该句、`toast.info` 被调用一次且文案为该句;保留「没有 English 选项」与「建议选 English」两条断言 |
+| `frontend/tests/report-export-drawer.test.tsx`「drawer contract uses native dialog …」 | 只读 CSS 片段与源码 | 不需改既有断言;新增用例写在本文件(见 T1、T2) |
+
+改前再按 §7.8 grep 一次:`grep -rn "report-export-\|英文版没有生成成功\|英文版上线前\|canvas-muted\` + \`--color-ink" frontend/tests tests`,以实际命中为准补进上表。
+
+## 任务分解
+
+| # | 内容 | 验收标准 |
+| --- | --- | --- |
+| T1 | `report-export-drawer.tsx`:删掉 `hasEnglish` 为假时的 `
` 分支;在挂载 effect 中 `showModal()` 之后,若英文不可用则 `notifyInfo(<原两句之一>, { id: "report-export-english" })`。判断条件与原分支完全一致(`!hasEnglish`;`englishEdition === "unavailable"` 用第一句,其余用 legacy 句)。英文可用时的 ChatGPT 建议句不动(D1) | 新增单测(`report-export-drawer.test.tsx`,桩 `toast.info`):① `legacy` 打开 → info 恰好 1 次、文案为 legacy 句、带 `id`;② `unavailable` 打开 → 1 次、文案为失败句;③ `ready` 打开 → 0 次;④ 打开后切「自选章节」、勾选 / 取消、展开分组 → 次数不变;⑤ 关闭(`render(null)`)再打开 → 再出 1 次;⑥ 弹窗 DOM 里不再有这两句。`report-english-edition.test.tsx` 按上表三栏改 |
+| T2 | `globals.css` 选中态(只改 `.report-export-*` 下列选择器,其余不动):`.report-export-mode[aria-checked="true"]` → `border-color: var(--color-action-strong)`,底色保持中性(`transparent`,与未选一致);`.report-export-mode[aria-checked="true"] .report-export-radio` → `border: 5px solid var(--color-action-strong)`;`.report-export-row:is([aria-checked="true"], [aria-checked="mixed"]) .report-export-box` → 边与底 `--color-action-strong`,勾 / 横线保持 `--color-canvas`;`.report-export-segment button[aria-checked="true"]` → 保留 `--color-canvas` 底与阴影,文字改 `--color-action`、加 `box-shadow` 内描边 1px `--color-action-strong`(或等价写法),字重 600 不变 | 按上表改 `report-buttons-scope-20260928.test.ts`;新增断言:四条选中规则都不含 `--color-action-soft` / `--color-action-fill`、背景值只在 `transparent` / `--color-canvas*` / `--color-action-strong`(仅勾选框 18px 小方块)之内;三处主题块 `--color-action-strong` 值仍为 `#cc785c` / `#d78064` / `#d78064` |
+| T3 | `frontend/DESIGN.md`「导出报告 sheet」条:删除「Selected states use `--color-canvas-muted` + `--color-ink`, never `--color-action-soft` (BUG-1078).」,换成「Selected states (issue #90, 2026-10-21, overrides the 2026-09-30 neutral rule): the scope card's border and radio dot, checked / mixed boxes, and the selected language segment use `--color-action-strong` (text `--color-action`); backgrounds stay neutral; never `--color-action-soft` or `--color-action-fill` (BUG-1078: no dark red-brown block in dark mode).」;「otherwise one caption saying why there is no English」改为「otherwise no caption: opening the sheet raises one info toast (id `report-export-english`) saying why」。`frontend/docs/VOICE.md` 导出面板条末句改为「没有英文版时弹窗内不写字,打开时顶部浮层提示一次:……(两句原文)」 | 文档 diff 与实现一致;同一提交 |
+| T4 | 记录:`docs/BUG_HISTORY.md` 新编号一条(现象:英文不可用说明占弹窗布局、选中态不带主题色;关联 BUG-1078 / 1079 / 1121;写明推翻 DESIGN 那一句、BUG-1078 主语保留);`CHANGELOG.md` 一行;`docs/tasks/PROGRESS-report-export-dialog-polish-20261021.md`(三栏、测试数字、Grok 自跑结果);`docs/tasks/README.md` 状态板改「待验收」;真机清单 `docs/testing/report-export-dialog-20261021.md`(Claude 已写好,执行方按实际偏差修订) | 文档齐;隐私守卫 `.venv/bin/python -m pytest tests/test_repo_privacy_markers.py -q` 通过 |
+
+### 验收分工(按 `TASK-agent-loop-pilot-20261013.md`「T1 结果与定稿分工」)
+
+| 检查 | Grok 自跑 | Claude 验收 |
+| --- | --- | --- |
+| `tsc --noEmit`、`npm run lint` | 必跑 | 复验 |
+| `report-export-drawer.test.tsx`、`report-english-edition.test.tsx`、`report-buttons-scope-20260928.test.ts`、`class-name-definition-contract.test.ts`、`dark-theme-contract.test.ts`、`design-token-contract.test.ts` | 必跑 | 复验 |
+| 前端全量 `npm test`(与基线逐条比对) | 不跑 | **以 Claude 为准** |
+| `npm run build`:`/` Static、首屏 gzip ±2% | 不跑 | **以 Claude 为准** |
+| `test:db` | 不需要(不动数据库访问层) | — |
+| 浅色 / 深色 / 手机宽度真机 | 不做 | 环境缺口,交产品按清单走 |
+
+## 与 #89 的并行关系
+
+#89(英文版漏译)预计改 `scripts/professional_report_reference.py`、`scripts/pl9_reader_english_terms.py`、`scripts/yoga_engine.py`、`tests/test_report_english_edition.py`,可能改 `frontend/src/lib/personal-report-english-edition.ts`(`acceptEnglishEdition`)。**业务代码无重叠,可并行**。可能撞车的只有:
+
+- `frontend/tests/report-english-edition.test.tsx`:#89 若在此加 `acceptEnglishEdition` 用例会与本单 T1 改同一文件(不同用例,通常可自动合并)。本单只改上表那一个用例,新用例一律写进 `report-export-drawer.test.tsx`。
+- `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`docs/tasks/README.md`:都是追加,后合入的一方 rebase 时手工合并,**BUG 编号以合入时最大号 +1 重新核对**。
+
+## 让步顺序
+
+时间不够时:T1(浮层 + 删说明行)> T2(选中态)> T3(文档,但 T2 落地则 T3 必须同提交)> T4。T1 与 T2 可分两个提交,但 T2 与 T3 不得拆开。
+
+## 开工前置命令
+
+```bash
+git fetch origin --prune
+git worktree add -b codex/report-export-dialog-polish-20261021 .worktrees/report-export-dialog-polish-20261021 origin/staging
+cd .worktrees/report-export-dialog-polish-20261021
+git status -sb
+grep -n "report-export-caption\|notifyInfo\|showModal" frontend/src/components/personal-report/report-export-drawer.tsx
+grep -n 'report-export-\(mode\|radio\|box\|segment\)' frontend/src/app/globals.css
+grep -n "Selected states use" frontend/DESIGN.md
+grep -rn "report-export-\|英文版没有生成成功\|英文版上线前" frontend/tests tests
+grep -n "^## BUG-1[0-9][0-9][0-9]" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -3
+```
+
+基线测试:开工时在 `origin/staging` 上先跑一次上表单文件测试,记数字进 PROGRESS。
+
+## BUG 编号
+
+当前最大 **BUG-1403**;**BUG-1404 起预留给 #85**。本单开工时核对 `docs/BUG_HISTORY.md` 最大号与 #85 / #89 已占用号,取当时最大号 +1,在 PROGRESS 写明。
+
+> 备注(不在本单范围):`docs/BUG_HISTORY.md` 当前整份内容重复了一遍(`# Bug History` 出现在第 1 行和约第 18099 行,`## BUG-` 标题 2337 个、去重后 1199 个)。本单新增记录只追加在文件末尾,不要处理重复;已报给 Claude 另行处理。
+
diff --git a/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md b/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md
new file mode 100644
index 00000000..4a75dd7a
--- /dev/null
+++ b/docs/tasks/TASK-synastry-staging-fix-fix-20261022.md
@@ -0,0 +1,45 @@
+# 修复单:婚恋合盘 T7~T11 验收遗留(issue #85,第 1 次打回,2026-10-22)
+
+- 被验提交:`57ee79cc5`(分支 `codex/synastry-staging-fix-20261021`)
+- 原任务书:`TASK-synastry-staging-fix-20261021.md`(T7~T11)
+- 执行方:Grok。同一分支继续,开工前 `git merge origin/staging`(不 rebase),只推这个分支。
+- BUG 编号:本分支已用 1404~1408,保持不变(其他分支撞号由 Claude 合入时重编)。
+
+## 验收结论(Claude,Linux / Node 22)
+
+| 项 | 结论 |
+| --- | --- |
+| tsc / lint | 0 错;0 error |
+| 前端全量 `npm test` | 5101 条,失败 25,名单与 staging `9812f3173` 基线逐名一致 |
+| 逐行审阅 T7~T11 | 通过:范围干净;新迁移只删一个唯一索引,对已部署代码向后兼容,真库测试覆盖「两份 ready 并存」「生成中复用不扣点」「ready 后再建扣点」;改动的断言都有三栏;双击不会重复扣点(`busy` + 每人一份生成中的索引,输方 409 并释放预留);新地址的归属校验仍在服务端 |
+| 全量 pytest | **多 1 条失败**,见 G1 |
+| Python 快速门 | **pytest 一步失败**(同 G1)。进度记录写了「没有跑快速门」,正是这条漏掉的原因 |
+| build | 本机内存不足被系统杀掉,Claude 另行重跑,不算本单问题 |
+
+## G1 · 新页面没登记进路由清单(必修)
+
+`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources` 失败:
+
+```
+At index 34 diff: 'synastry/[reportId]' != 'terms'
+```
+
+该用例断言 `audit['surfaces']['app_routes']` 等于一张写死的前端路由表。T7 新增了 `app/(app)/synastry/[reportId]/page.tsx`,表里没加。按 AGENTS §7.8 同轮改:在表中按字母序加入 `'synastry/[reportId]'`,旁边写三栏(原值:`synastry` 后面直接是 `terms` / 新值:加入 `synastry/[reportId]` / 原因:issue #85 T7 合盘报告独立地址)。
+
+## G2 · 文档与 T11 矛盾(必修,纯文字)
+
+`CHANGELOG.md` BUG-1407 那条(「合盘两人顺序跟选人页一致」)和 `PROGRESS-synastry-staging-fix-20261021.md` T10 节都写着「同一对已经有报告时,仍打开原来那份」。T11 之后这句只对**正在生成**的报告成立。改成「同一对正在生成时,仍打开那份」。
+
+## G3 · 侧栏点亮的测试是空断言(必修,小)
+
+`frontend/tests/synastry-report-surface.test.ts`「the synastry route reuses the reader and the sidebar stays on 婚恋合盘」里 `assert.equal("/synastry/abc".startsWith("/synastry/"), true)` 永远成立,测的不是页面。任务书要求的是:渲染侧栏(`pathname = "/synastry/"`),断言当前项(`aria-current="page"` 或现有的选中类)是「婚恋合盘」、不是「我的报告」。照现有侧栏渲染测试的写法补一条,删掉这句空断言(三栏)。
+
+## 交回前必跑
+
+- `PATH` 用工作树 `.venv`:`python scripts/run_quality_gate.py --profile quick`,回报里写退出码与 pytest 通过 / 失败数(Windows 已知失败按名单列出)。
+- `tsc --noEmit`、`npm run lint`、`synastry-report-surface.test.ts`。
+- 全量 `npm test`、build 由 Claude 复验。
+
+## 不用改的(记录在案)
+
+`stripLeadingSectionTitle` 在保存路径上基本走不到(质检已拦)、质检不认「标题:」紧跟标点的写法、`synastry_order` 行出现在进度列表里、后续对话的人物 id 仍按排序后的顺序——都不影响用户,本轮不动。
diff --git a/docs/testing/chart-ascendant-slash-20261021.md b/docs/testing/chart-ascendant-slash-20261021.md
new file mode 100644
index 00000000..86af5999
--- /dev/null
+++ b/docs/testing/chart-ascendant-slash-20261021.md
@@ -0,0 +1,16 @@
+# 真机走查:上升状态格「/」与说明(issue #87)
+
+用 staging,登录自己的账号,选一个已有星盘的人。
+
+| # | 操作 | 应该看到 |
+| --- | --- | --- |
+| 1 | 电脑打开星盘页,本命表第一行「上升」的「状态」格 | 写「/」,不是「不适用」 |
+| 2 | 鼠标移到这个「/」上 | 弹出一句:上升点是命盘的起点,不是行星,不判断入旺或落陷 |
+| 3 | 切到月亮盘、D9 分盘 | 上升行同样是「/」,悬停有同一句 |
+| 4 | 手机打开同一页,点一下「/」 | 出现同一句,几秒后自动消失;页面不被撑宽 |
+| 5 | 其他行(太阳、月亮等)的状态格 | 照旧,如「入旺」「落陷」,没有变化 |
+| 6 | 打开一份**新生成**的个人报告,找「行星主星与状态」表 | 上升行状态写「/」;表下面有一行同样的说明 |
+| 7 | 把这份报告切到英文 / 导出 PDF | 上升行写「/」,表下是英文说明;PDF 里也能看到这行说明 |
+| 8 | 深色模式下重复 1、2 | 「/」与气泡都看得清 |
+
+旧报告要重新生成才会变;旧报告里仍是「不适用」属正常。
diff --git a/docs/testing/report-export-dialog-20261021.md b/docs/testing/report-export-dialog-20261021.md
new file mode 100644
index 00000000..41e57b22
--- /dev/null
+++ b/docs/testing/report-export-dialog-20261021.md
@@ -0,0 +1,17 @@
+# 真机走查:导出弹窗提示与选中态(issue #90)
+
+环境:`https://staging.jyotisha.chat`,用受控测试账号。需要三类报告各一份:**有英文版**的新报告、**英文版没生成成功**的报告(如有)、**英文版上线前**生成的旧报告。没有某一类就写「无样本」,不算通过。每步在**浅色**、**深色**各走一遍;第 8 步用手机(或桌面浏览器窄到约 390px)。
+
+| # | 操作 | 期望 | 浅色 | 深色 |
+| --- | --- | --- | --- | --- |
+| 1 | 打开旧报告(英文版上线前),点「导出」 | 屏幕**顶部中间**出浮层「这份报告生成于英文版上线前,重新生成即可获得中英两版。」,约 3 秒自动消失;浮层在弹窗之上、没被遮罩盖住;弹窗里**没有**这句灰字,也没有中文 / English 选项 | | |
+| 2 | 同一弹窗里点「自选章节」、勾掉几项、展开「分盘」、再点回「整篇报告」 | 浮层**不再出现** | | |
+| 3 | 关掉弹窗再点「导出」 | 浮层再出一次,只一条不叠 | | |
+| 4 | 打开英文版没生成成功的报告,点「导出」 | 浮层「这份报告的英文版没有生成成功,只能导出中文版。」一次;弹窗内无此句 | | |
+| 5 | 打开有英文版的报告,点「导出」 | **没有**浮层;语言选项下仍有「拿去问 ChatGPT、Claude 等 AI,建议选 English,术语与原典一致。」 | | |
+| 6 | 第 5 步弹窗里看选中样式 | 「整篇报告」卡片描边和圆点是主题珊瑚色,卡片底色与未选中一样(深色下**不是深红褐色块**);切「English」后选中那半边文字变珊瑚色、有一圈细描边,底色仍中性 | | |
+| 7 | 点「自选章节」 | 勾上的方框珊瑚色底 + 勾;部分勾选的分组框同色 + 横线;未勾是灰框 | | |
+| 8 | 手机宽度重复 1、5、6、7 | 弹窗从底部升起;浮层左右各留约 16px、不压住弹窗按钮;选中样式同上 | | |
+| 9 | 勾好后点「导出 …」 | 正常下载;顶部出「已导出」 | | |
+
+发现问题截图(浅 / 深各一张),注明第几步。