新六章有 12 节读错公开包字段,整节只剩未判定,而且六章绕过了口径替换。按资料包改读路径,先拼六章再统一替换措辞,并把中文附录实际丢掉的组合句数出来。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 18:51:54 +08:00
co-authored by Grok (agent loop) Cursor
parent 365b1e647e
commit 92b4e220f2
8 changed files with 647 additions and 135 deletions
@@ -0,0 +1,95 @@
# PROGRESS:#91 A 段第 1 次打回(只修报告写法)
- 分支:`codex/full-data-parity-1-20261021`
- 范围:修复单 J1–J4。B 段 T4–T10、#93 未做。
- 状态:本地完成,未推送。
## 结论
0、1、9 号虚构盘的中英两版里,新六章除下面 4 节以外,每一节都至少有一行真实数据。这 4 节公开资料包没有对应模块,仍印「未判定」,第一行写明原因。
中文附录的年运表列名过滤在 200 张盘上没有丢掉任何列,所以没有补 `FIELD_ZH`。瑜伽「作用」的粘连过滤也没有删条目。瑜伽「组合」会把英文句子整格印成「未判定」:200 张盘全部中招,去重后 1030 条,源头不改。
## J1 十二节
读的是 `build_professional_report_reference_packet` 放进公开工作表的位置。0、1、9 号盘中英两版都由测试确认至少一行不是「未判定 / Not determined」。
| 节 | 公开包位置 |
| --- | --- |
| Vimsopaka 分盘贡献 | `strengths_and_scores.vimsopaka` 下直接是行星键(分数、分档) |
| Karakamsha | `advanced_systems.jaimini.karakamsha` 的星座、度数、宫主 |
| Darakaraka | `advanced_systems.jaimini.darakaraka` 的 `dk_planet`、`dk_sign`、`dk_house`、`dk_degree`、`d9_sign`、`d9_dignity` |
| D9 尊贵 | `divisional_and_special_charts.varga_full.D9_Navamsa`,行星是直接键,尊贵在 `_dignity` |
| 分盘映射 | 同上各分盘的 `Ascendant.sign`。盘名印成 `D9 Navamsa` 这种,不印带下划线的键。D81 / D108 / D144 不进这张表 |
| D30 状态 | `varga_full.D30_Trimsamsa` 与 `_dignity` |
| D30 输入 | `strengths_and_scores.shadbala.planets.*.sthana_bala.sapta_d30` |
| 行星状态输入 | `advanced_systems.avasthas.<planet>.avasthas` 的 Jagrat / Deeptadi 状态,外加度数 |
| 定位星终点与循环 | `advanced_systems.final_dispositors`。自循环印主星,多星循环印「循环 / Cycle」 |
| Sudarshana 参考点 | `divisional_and_special_charts.sudarshana.raw.reference_points` |
| Bhava Chalit 宫心 | `divisional_and_special_charts.bhava_chalit.raw.boundaries.houses` |
| 本命特殊点公式依据 | `advanced_systems.sahams` 里带星座或黄经的点,印名称、星座、度数、黄经。不印公式轨迹 |
| 注明日期的行运 | `timing_and_predictive_systems.transit_multi_reference` 的 `transit_analysis` 与 `target_date`,星宿来自 `advanced_systems.nakshatra_dasha.transit_overlay` |
## 四节白名单
公开报告资料包未带这些模块(只在被拿掉的原始阅读结果里)。每节第一行是「公开报告资料包未带此模块」/ `This module is not in the public report packet`,表内仍是「未判定」。没有为它们改引擎。
| 节 | 原因 |
| --- | --- |
| Navamsha 小运时长 | `navamsha_dasha` 没有复制进公开工作表 |
| 五鸟活动 | `pancha_pakshi` 没有复制进公开工作表 |
| Rashi Tulya Navamsa | `rashi_tulya_navamsa` 没有复制进公开工作表 |
| 当前多系统收敛 | `dasa_convergence` 的逐领域行没有复制进公开工作表。不再用 `convergence_top_domains` 充数 |
## J2 / J3
`tests/test_full_data_parity_chapters.py` 的 `test_every_section_has_a_real_cell_except_the_four_public_gaps`:0、1、9 号盘中英两版,白名单外每一节至少有一个数据单元格不是「未判定 / Not determined」。表头和分隔行不算。白名单只有上表 4 节,每项带了原因。
`test_parity_chapters_go_through_local_conventions`:六章先拼进正文,再走 `_apply_local_report_conventions`。中文源字段「必然」渲染成该函数的结果「可能」;英文字段 `divorce` 渲染成 `relationship strain`。
## J4 丢弃统计
200 张虚构盘,PyJHora 已屏蔽。汉字为 0。计数在中文渲染里,同一条若被阅读表、资料附录和解释包各走一次,调用次数会高于条目数。条目数是每张盘内按规则和原文去重后再相加。
| 处 | 丢掉了什么 | 条目 | 调用 | 受影响盘 |
| --- | --- | --- | --- | --- |
| 年运表列名 | 无。过滤执行了 800 次(每盘 4 张表),没有列被 `FIELD_ZH` 拿掉 | 0 | 800 次过滤 | 0 |
| 瑜伽「作用」 | 无。粘连过滤没有删掉任何一条 | 0 | 0 | 0 |
| 瑜伽「组合」 | 非空的组合句整格变成「未判定」 | 1030 | 2496 | 200 |
年运表优先列本来就有中文名,所以没有新列要补进 `FIELD_ZH`。`reading` 这类额外字段到不了这道过滤:优先列已经有值,回退列名单不会启用。
「作用」里的英文整句没有被删。粘连规则只删「汉字紧挨 in/and/or/from/planet/lord」、英文星座名,以及 `, :` / `in )`。BVR 规则的 `effects` 要么是纯中文,要么是纯英文,纯英文留在格子里。本单不改 `references/yoga_rules.json`。
「组合」变成「未判定」的源头:
- `scripts/yoga_engine.py` 的 `_eval_semantic_condition`:没有中文模板时,用规则里的英文 `note` 当组合句。规则在 `references/yoga_rules.json`。按渲染调用次数:`bvr_krisanga_yoga` 501,`bvr_budha_aaditya_yoga` 315,`bvr_paasa_yoga` 282,`bvr_kedaara_yoga` 162,`bvr_vanchana_chora_bheethi_yoga` 144,`bvr_dehasthoulya_yoga` 125,`bvr_daama_yoga` 126,`bvr_kalpadruma_yoga` 108,`bvr_bahu_sthree_yoga` 102,`bvr_bhaarathi_yoga` 69,`bvr_grihanasa_yoga` 66,`bvr_asubha_yoga` 45,`bvr_subha_yoga` 45,`bvr_bandhu_pujya_yoga` 54,`bvr_guhyaroga_yoga` 27(句中有英文星座名),`bvr_vallaki_yoga` 18,`bvr_soola_yoga` 12,`bvr_rajalakshana_yoga` 9,`bvr_kalaanidhi_yoga` 3。
- 没有 `rule_id` 的 283 次来自 `scripts/yogas_doshas.py`:`calc_raj_yogas` 与 `calc_dhana_yogas` 写出 `H*_Lord(...) in H*` 这种英文组合句。句子里的行星随盘变化,这里不列具体星。
这些句子是中文报告在用的英文字段。本单只计数,不改规则文件,也不改这两个函数。
## 阅读页上的英文星座名
`frontend/src/lib/personal-report-generation.ts` 的 `assembleReportDocumentV2`。它在组装 v2 报告文档时,把 D1 事实里的英文 `sign` 写进「本命基础」的 `narrative`,以及 `keyFactors` 里「第 N 宫落 {sign}」;宫内对象还是英文行星标识,两处句子加这一列,改一处星座映射盖不住。
阅读页 `frontend/src/components/personal-report/personal-report-page.tsx` 渲染的是 Markdown(`PersonalReportMarkdownView`)、盘图和事实表,不渲染这段 `natalFoundation`。`frontend/src/lib/report-public-projection.ts` 的 `foundationSection` 只是把它放到接口文档上。导出图盘列表的 `houseLine` 已经用中文星座名。本单不改前端,留给 Claude。
## 测试
解释器:`G:\Ferti\Jyotisha\.worktrees\agent-loop\.venv\Scripts\python.exe`。`PYTHONUTF8=1`。虚构盘助手已屏蔽 PyJHora。未跑快速门、全量 pytest、前端全量 `npm test`、`next build`。未改前端,未跑 `tsc` / `lint`。
| 命令 | 退出码 |
| --- | --- |
| `pytest tests/test_full_data_parity_chapters.py -q --tb=line` | 0(7 项,约 61 秒) |
| `pytest` 附录、英文冒烟、`test_report_english_edition`、`test_report_full_data_fix`、`test_full_data_domain_reader`、`test_python_hygiene_budget`、`test_api_server_growth_contract`、`test_runtime_jhora_import_scan`、`test_sidereal_nutation_frame`、`test_rashi_dashas_native`、`test_bhava_bala_formal`、`test_year_lord_blocked_no_fallback`、`test_year_lord_basis_label`、`test_repo_privacy_markers` | 0(172 项,约 925 秒) |
| `pytest tests/test_report_english_sweep.py -q --tb=line` | 0(200 张,约 1293 秒;汉字 0) |
隐私标记在写完本文之后再跑一次。
## 偏离
- 没有推送,没有设上游,没有改标签,没有发 Gitea 评论。
- 没有改 `scripts/jyotish_api_server.py`、`scripts/jaimini.py`、冻结打分文件、`references/yoga_rules.json`。没有提高 `ROOT_PY_BUDGET`。
- 没有新的 BUG 编号。BUG-1416、BUG-1417 保持不变。
- 年运列和瑜伽「作用」的实测丢弃是 0,所以没有补中文列名,也没有改瑜伽英文源。
- 组合句的 1030 条是「非空原文被印成未判定」的盘内去重数,比「句中正好有 in/and/or/from/planet/lord」的范围稍宽,多出来的是纯英文说明句。调用次数 2496 是同一条被多处渲染重复计入。
+1 -1
View File
@@ -523,4 +523,4 @@
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `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-20261021.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 待执行 | `codex/full-data-parity-1-20261021` |
| `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 本地完成,待验收 | `codex/full-data-parity-1-20261021` |
+26 -6
View File
@@ -2107,9 +2107,23 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict, language: str = "e
][:6]
if language == "zh":
try:
from scripts.report_render.appendix_language import FIELD_ZH
from scripts.report_render.appendix_language import (
ANNUAL_COLUMN_NAMES_BEFORE_FIX,
FIELD_ZH,
note_annual_table,
note_dropped_annual_column,
)
except ModuleNotFoundError: # pragma: no cover
from report_render.appendix_language import FIELD_ZH
from report_render.appendix_language import (
ANNUAL_COLUMN_NAMES_BEFORE_FIX,
FIELD_ZH,
note_annual_table,
note_dropped_annual_column,
)
note_annual_table()
for field in keys:
if field not in ANNUAL_COLUMN_NAMES_BEFORE_FIX:
note_dropped_annual_column(field)
keys = [field for field in keys if field in FIELD_ZH]
if not keys:
continue
@@ -2365,6 +2379,12 @@ def _append_parity_chapters(markdown: str, packet: dict, language: str) -> str:
def render_full_data_markdown(packet: dict) -> str:
"""Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body."""
if _pl9_report_language(packet if isinstance(packet, dict) else {}) != "en":
try:
from scripts.report_render.appendix_language import reset_appendix_drop_log
except ModuleNotFoundError: # pragma: no cover
from report_render.appendix_language import reset_appendix_drop_log
reset_appendix_drop_log()
attach_full_data_interpretation, opening_focus_block = _interpretation_tools()
if isinstance(packet, dict):
attach_full_data_interpretation(packet)
@@ -2396,8 +2416,8 @@ def render_full_data_markdown(packet: dict) -> str:
english_markdown,
_pl9_english_report_title(packet),
)
rendered = _apply_local_report_conventions(rendered, language="en")
return _append_parity_chapters(rendered, packet, "en")
rendered = _append_parity_chapters(rendered, packet, "en")
return _apply_local_report_conventions(rendered, language="en")
zh_structured_appendix = re.sub(
r"^#\s+.*?(?:\n{2,}|\n)",
"",
@@ -2418,8 +2438,8 @@ def render_full_data_markdown(packet: dict) -> str:
+ "\n\n</details>\n"
)
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
rendered = _apply_local_report_conventions(rendered, language="zh")
return _append_parity_chapters(rendered, packet, "zh")
rendered = _append_parity_chapters(rendered, packet, "zh")
return _apply_local_report_conventions(rendered, language="zh")
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
+74 -2
View File
@@ -368,6 +368,63 @@ FIELD_ZH = {
"months": "月数",
}
# Names that already had a Chinese header before the #91 fix.
# The annual-table filter used to drop every other column. Counts use this set
# so a later Chinese name still shows up as a column the old filter removed.
ANNUAL_COLUMN_NAMES_BEFORE_FIX = frozenset(FIELD_ZH)
_APPENDIX_DROPS: dict[str, dict[str, int]] = {
"annual_columns": {},
"annual_tables": {},
"effects": {},
"combinations_undetermined": {},
"combinations_replaced": {},
}
def reset_appendix_drop_log() -> None:
"""Clear the per-render drop counts. One Chinese render, then snapshot."""
for bucket in _APPENDIX_DROPS.values():
bucket.clear()
def snapshot_appendix_drop_log() -> dict[str, dict[str, int]]:
return {name: dict(bucket) for name, bucket in _APPENDIX_DROPS.items()}
def note_dropped_annual_column(field: str) -> None:
key = str(field)
bucket = _APPENDIX_DROPS["annual_columns"]
bucket[key] = bucket.get(key, 0) + 1
def note_annual_table() -> None:
bucket = _APPENDIX_DROPS["annual_tables"]
bucket["seen"] = bucket.get("seen", 0) + 1
def _drop_identity(item: dict, text: str) -> str:
parts = [
str(item.get("rule_id") or ""),
str(item.get("source") or ""),
str(item.get("bvr_id") or ""),
str(item.get("name") or ""),
" ".join(str(text).split()),
]
return " | ".join(parts)[:240]
def _note_effect(item: dict, text: str) -> None:
key = _drop_identity(item, text)
bucket = _APPENDIX_DROPS["effects"]
bucket[key] = bucket.get(key, 0) + 1
def _note_combination(item: dict, text: str, kind: str) -> None:
key = _drop_identity(item, text)
bucket = _APPENDIX_DROPS[kind]
bucket[key] = bucket.get(key, 0) + 1
def column_header(field: str, language: str) -> str:
if language != "zh":
@@ -591,12 +648,19 @@ def chinese_yoga_label(item: dict) -> str:
def chinese_combination(item: dict) -> str:
"""Use the Chinese combination sentence. An English sentence is not translated word by word."""
combination = str(item.get("combination") or "").strip()
if combination and re.search(r"[\u4e00-\u9fff]", combination) and not _HAN_FUNCTION_RE.search(combination) and not SIGN_EN_RE.search(combination):
han = bool(re.search(r"[\u4e00-\u9fff]", combination))
function = bool(_HAN_FUNCTION_RE.search(combination)) if combination else False
sign = bool(SIGN_EN_RE.search(combination)) if combination else False
if combination and han and not function and not sign:
return combination
planets = item.get("planets") if isinstance(item.get("planets"), list) else []
names = [planet_name(planet, "zh") for planet in planets if planet]
if names:
if combination:
_note_combination(item, combination, "combinations_replaced")
return "与".join(names)
if combination:
_note_combination(item, combination, "combinations_undetermined")
return "未判定"
@@ -610,7 +674,15 @@ def yoga_cells(item: dict, language: str) -> list[Any]:
category_text = "未判定"
strength = item.get("strength") or "-"
effects = item.get("effects") if isinstance(item.get("effects"), list) else []
kept = [str(effect) for effect in effects if str(effect).strip() and not _has_prose_glue(str(effect))]
kept = []
for effect in effects:
text = str(effect).strip()
if not text:
continue
if _has_prose_glue(text):
_note_effect(item, text)
continue
kept.append(text)
effects_text = ";".join(kept) or "-"
return [chinese_yoga_label(item), category_text, strength, chinese_combination(item), effects_text]
+258 -125
View File
@@ -470,6 +470,84 @@ def _rows_from(items: list[Any], language: str, columns: list[tuple[str, str]])
return _md_table(headers, rows)
_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
_MISSING_MODULE = {
"zh": "公开报告资料包未带此模块",
"en": "This module is not in the public report packet",
}
def _missing_module(language: str, columns: list[str]) -> str:
"""The public worksheet does not carry this module. Keep the undetermined row."""
return _MISSING_MODULE[language] + "\n\n" + _blank(language, columns)
def _is_planet(value: Any) -> bool:
return isinstance(value, str) and value.strip() in _PLANETS
def _dignity_token(value: Any, language: str) -> Any:
if not isinstance(value, str):
return value
key = " ".join(value.strip().split()).lower()
if key == "neutral":
return "中性" if language == "zh" else "Neutral"
return {
"exalted": "exalted",
"debilitated": "debilitated",
"own sign": "own",
"own": "own",
"moolatrikona": "mooltrikona",
}.get(key, value)
def _named_planets(chart: Any) -> list[tuple[str, dict]]:
if not isinstance(chart, dict):
return []
nested = chart.get("planets")
source = nested if isinstance(nested, dict) else chart
rows = []
for name in _PLANETS:
item = source.get(name)
if isinstance(item, dict):
rows.append((name, item))
return rows
def _varga_chart(packet: dict, *keys: str) -> dict:
varga = _first(packet, ("divisional_and_special_charts", "varga_full")) or {}
if not isinstance(varga, dict):
return {}
for key in keys:
chart = varga.get(key)
if isinstance(chart, dict):
return chart
return {}
def _varga_label(name: str, chart: dict) -> str:
meta = chart.get("_meta") if isinstance(chart.get("_meta"), dict) else {}
div = meta.get("div")
title = str(meta.get("name") or "")
if div and title and "_" not in title and not re.search(r"[\u4e00-\u9fff]", title):
return f"D{div} {title}".strip()
return " ".join(str(name).replace("_", " ").split())
def _cycle_label(value: str, language: str) -> str | None:
body = value.split(":", 1)[1]
parts = [part.strip(" .") for part in body.replace("->", "|").replace("→", "|").split("|")]
names = []
for part in parts:
if not _is_planet(part):
return None
names.append(planet_name(part, language))
if len(names) < 2:
return None
prefix = "循环" if language == "zh" else "Cycle"
return prefix + " " + " → ".join(names)
def _months(start: Any, end: Any) -> str | None:
if not isinstance(start, str) or not isinstance(end, str):
return None
@@ -560,25 +638,8 @@ def _tribhagi_periods(packet: dict, language: str) -> str:
def _navamsha(packet: dict, language: str) -> str:
module = _first(packet, ("timing_and_predictive_systems", "navamsha_dasha"))
periods = []
if isinstance(module, dict):
majors = module.get("periods") or module.get("native_result", {}).get("periods") if isinstance(module.get("native_result"), dict) else module.get("periods")
if isinstance(module.get("native_result"), dict):
majors = module["native_result"].get("periods") or majors
for major in majors or []:
if not isinstance(major, dict):
continue
for child in major.get("antardasha") or []:
if not isinstance(child, dict):
continue
copied = dict(child)
copied["months"] = child.get("duration_months") or _months(child.get("start"), child.get("end"))
periods.append(copied)
if not periods:
note = "公开报告包没有带上 Navamsha 小运行。" if language == "zh" else "The public packet does not carry the Navamsha sub-period rows."
return note + "\n\n" + _blank(language, ["sign", "start", "end", "months"])
return _rows_from(periods, language, [("sign", "sign"), ("start", "text"), ("end", "text"), ("months", "text")])
del packet
return _missing_module(language, ["sign", "start", "end", "months"])
def _nakshatra_context(packet: dict, language: str) -> str:
@@ -675,31 +736,8 @@ def _sandhi(packet: dict, language: str) -> str:
def _convergence(packet: dict, language: str) -> str:
timing = _first(packet, ("ai_and_audit", "ai_prompt_pack", "evidence_snapshot", "timing")) or {}
pairs = timing.get("convergence_top_domains") if isinstance(timing, dict) else None
module = _first(packet, ("timing_and_predictive_systems", "dasa_convergence"))
activations = module.get("domain_activations") if isinstance(module, dict) else None
rows = []
if isinstance(activations, dict) and activations:
for domain, info in activations.items():
if not isinstance(info, dict):
continue
rows.append({
"domain": domain,
"house": info.get("house"),
"level": info.get("convergence_level"),
"counted": info.get("system_count"),
})
elif isinstance(pairs, list):
for item in pairs:
if isinstance(item, (list, tuple)) and len(item) >= 2:
rows.append({"domain": item[0], "level": item[1]})
elif isinstance(item, dict):
rows.append({"domain": item.get("domain") or item.get("name"), "level": item.get("level") or item.get("convergence_level")})
if not rows:
note = "公开报告包没有带上逐领域激活行。" if language == "zh" else "The public packet does not carry the per-domain activation rows."
return note + "\n\n" + _blank(language, ["domain", "level"])
return _rows_from(rows, language, [("domain", "text"), ("house", "text"), ("level", "text"), ("counted", "text")])
del packet
return _missing_module(language, ["domain", "level"])
def _chara(packet: dict, language: str) -> str:
@@ -890,12 +928,30 @@ def _bhava(packet: dict, language: str) -> str:
def _vimsopaka(packet: dict, language: str) -> str:
module = _first(packet, ("strengths_and_scores", "vimsopaka")) or {}
nested = module.get("planets") if isinstance(module, dict) else None
planets = nested if isinstance(nested, dict) else module
bands = {
"High": ("High", "高"),
"Moderate": ("Moderate", "中"),
"Limited": ("Limited", "有限"),
"Low": ("Low", "低"),
}
rows = []
planets = module.get("planets") if isinstance(module, dict) else module
if isinstance(planets, dict):
for planet, item in planets.items():
if isinstance(item, dict) and ("total_score" in item or "category" in item):
rows.append({"planet": planet, "score": item.get("total_score"), "category": item.get("category")})
if not isinstance(item, dict) or ("total_score" not in item and "category" not in item):
continue
if planet not in _PLANETS and item.get("planet") not in _PLANETS:
continue
category = item.get("category")
pair = bands.get(str(category))
if pair:
category = pair[1] if language == "zh" else pair[0]
rows.append({
"planet": planet if planet in _PLANETS else item.get("planet"),
"score": item.get("total_score"),
"category": category,
})
elif isinstance(planets, list):
rows = [item for item in planets if isinstance(item, dict)]
return _rows_from(rows, language, [("planet", "planet"), ("score", "text"), ("category", "text")])
@@ -958,33 +1014,61 @@ def _prastara(packet: dict, language: str) -> str:
return _rows_from(rows, language, [("planet", "planet"), ("total", "text")])
def _avastha_state(item: dict) -> Any:
states = item.get("avasthas") if isinstance(item.get("avasthas"), dict) else {}
for key in ("Jagrat", "Deeptadi"):
entry = states.get(key)
if isinstance(entry, dict) and entry.get("state"):
return entry.get("state")
return item.get("jagradadi") or item.get("state") or item.get("avastha")
def _avastha(packet: dict, language: str) -> str:
module = _first(packet, ("advanced_systems", "avasthas")) or {}
nested = module.get("planets") if isinstance(module, dict) else None
planets = nested if isinstance(nested, dict) else module if isinstance(module, dict) else {}
rows = []
planets = module.get("planets") if isinstance(module, dict) else module
if isinstance(planets, dict):
for planet, item in planets.items():
if isinstance(item, dict):
rows.append({"planet": planet, "status": item.get("jagradadi") or item.get("state") or item.get("avastha"), "degree": item.get("degree") or item.get("degree_in_sign")})
elif isinstance(planets, list):
rows = [item for item in planets if isinstance(item, dict)]
return _rows_from(rows, language, [("planet", "planet"), ("status", "text"), ("degree", "text")])
for planet, item in planets.items():
if planet not in _PLANETS or not isinstance(item, dict):
continue
degree = item.get("degree")
if degree is None:
degree = item.get("degree_in_sign")
rows.append({
"planet": planet,
"sign": item.get("sign"),
"status": _avastha_state(item),
"degree": degree,
})
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("status", "text"), ("degree", "text")])
def _d30(packet: dict, language: str, source: bool) -> str:
module = _first(packet, ("advanced_systems", "trimshamsa_d30")) or _first(packet, ("divisional_and_special_charts", "varga_full", "D30"))
rows = []
planets = module.get("planets") if isinstance(module, dict) else None
if isinstance(planets, dict):
for planet, item in planets.items():
if not isinstance(item, dict):
if source:
rows = []
for planet, item in _shadbala_planets(packet).items():
if planet not in _PLANETS or not isinstance(item, dict):
continue
if source:
rows.append({"planet": planet, "degree": item.get("degree_in_sign") or item.get("degree"), "sign": item.get("sign")})
else:
rows.append({"planet": planet, "sign": item.get("sign"), "status": item.get("dignity") or item.get("status")})
columns = [("planet", "planet"), ("degree", "text"), ("sign", "sign")] if source else [("planet", "planet"), ("sign", "sign"), ("status", "text")]
return _rows_from(rows, language, columns)
sthana = item.get("sthana_bala") if isinstance(item.get("sthana_bala"), dict) else {}
score = sthana.get("sapta_d30")
if isinstance(score, bool) or not isinstance(score, (int, float)):
continue
rows.append({"planet": planet, "score": score})
return _rows_from(rows, language, [("planet", "planet"), ("score", "text")])
chart = _varga_chart(packet, "D30_Trimsamsa", "D30", "Trimsamsa")
if not chart:
legacy = _first(packet, ("advanced_systems", "trimshamsa_d30"))
chart = legacy if isinstance(legacy, dict) else {}
dignity = chart.get("_dignity") if isinstance(chart.get("_dignity"), dict) else {}
rows = []
for planet, item in _named_planets(chart):
status = dignity.get(planet) or item.get("dignity") or item.get("status")
rows.append({
"planet": planet,
"sign": item.get("sign"),
"status": _dignity_token(status, language),
})
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("status", "text")])
def _pushkara(packet: dict, language: str) -> str:
@@ -1112,8 +1196,26 @@ def _chain_rows(module: Any, terminal: bool) -> list[dict]:
def _dispositor(packet: dict, language: str, terminal: bool) -> str:
module = _first(packet, ("advanced_systems", "final_dispositors" if terminal else "dispositor_chains"))
return _rows_from(_chain_rows(module, terminal), language, [("planet", "planet"), ("sign", "sign"), ("lord", "planet")])
if not terminal:
module = _first(packet, ("advanced_systems", "dispositor_chains"))
return _rows_from(_chain_rows(module, False), language, [("planet", "planet"), ("sign", "sign"), ("lord", "planet")])
module = _first(packet, ("advanced_systems", "final_dispositors")) or {}
rows = []
if isinstance(module, dict):
for planet, value in module.items():
if planet not in _PLANETS:
continue
if _is_planet(value):
rows.append({"planet": planet, "lord": value})
elif isinstance(value, str) and value.startswith("Cycle:"):
label = _cycle_label(value, language)
if label:
rows.append({"planet": planet, "value": label})
elif isinstance(value, (dict, list)):
rows.extend(_chain_rows({planet: value}, True))
if not rows:
rows = _chain_rows(module, True)
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("lord", "planet"), ("value", "text")])
def _aspects(packet: dict, language: str) -> str:
@@ -1290,14 +1392,18 @@ def _saham_basis(packet: dict, language: str) -> str:
for name, item in module.items():
if not isinstance(item, dict):
continue
if "formula_status" not in item and "formula_variant" not in item and "formula_operands" not in item:
if item.get("sign") in (None, "") and not isinstance(item.get("longitude"), (int, float)):
continue
label = item.get("name")
if not isinstance(label, str) or "_" in label or not label.strip():
label = " ".join(str(name).replace("_", " ").split())
rows.append({
"name": name,
"status": item.get("formula_status") or item.get("formula_variant"),
"value": item.get("base_longitude_before_exception") or item.get("longitude"),
"name": label,
"sign": item.get("sign"),
"degree": item.get("degree_in_sign"),
"longitude": item.get("longitude"),
})
return _rows_from(rows, language, [("name", "text"), ("status", "text"), ("value", "text")])
return _rows_from(rows, language, [("name", "text"), ("sign", "sign"), ("degree", "text"), ("longitude", "text")])
def _special_points(packet: dict, language: str) -> str:
@@ -1361,31 +1467,30 @@ def _one_point(packet: dict, language: str, key: str) -> str:
item = jaimini.get(key) if isinstance(jaimini, dict) else None
if isinstance(item, str):
return _rows_from([{"planet": item}], language, [("planet", "planet")])
if isinstance(item, dict):
return _rows_from([item], language, [("planet", "planet"), ("sign", "sign"), ("house", "text")])
return _blank(language, ["planet", "sign"])
if not isinstance(item, dict):
return _blank(language, ["planet", "sign"])
if key == "karakamsha":
return _rows_from([{
"sign": item.get("karakamsha_sign") or item.get("sign"),
"degree": item.get("karakamsha_degree") if "karakamsha_degree" in item else item.get("degree"),
"lord": item.get("karakamsha_lord") or item.get("lord") or item.get("planet"),
"house": item.get("house"),
}], language, [("sign", "sign"), ("degree", "text"), ("lord", "planet"), ("house", "text")])
if key == "darakaraka":
return _rows_from([{
"planet": item.get("dk_planet") or item.get("planet"),
"sign": item.get("dk_sign") or item.get("sign"),
"house": item.get("dk_house") if item.get("dk_house") is not None else item.get("house"),
"degree": item.get("dk_degree") if item.get("dk_degree") is not None else item.get("degree"),
"value": item.get("d9_sign"),
"dignity": _dignity_token(item.get("d9_dignity") or item.get("dignity"), language),
}], language, [("planet", "planet"), ("sign", "sign"), ("house", "text"), ("degree", "text"), ("value", "sign"), ("dignity", "text")])
return _rows_from([item], language, [("planet", "planet"), ("sign", "sign"), ("house", "text")])
def _rashi_tulya(packet: dict, language: str) -> str:
module = _first(packet, ("advanced_systems", "rashi_tulya_navamsa")) or {}
analysis = module.get("analysis") if isinstance(module, dict) else module
if not isinstance(analysis, dict):
return _blank(language, ["name", "value"])
labels = {
"lagna_sign": "上升星座" if language == "zh" else "Ascendant sign",
"strength_score": "力量分" if language == "zh" else "Strength score",
"weakness_score": "薄弱分" if language == "zh" else "Weakness score",
}
rows = []
for key, label in labels.items():
if key in analysis:
rows.append({"name": label, "value": analysis.get(key)})
planets = analysis.get("planets")
if isinstance(planets, dict):
for planet, item in planets.items():
sign = item.get("sign") if isinstance(item, dict) else item
rows.append({"name": planet, "value": sign})
return _rows_from(rows, language, [("name", "text"), ("value", "text")])
del packet
return _missing_module(language, ["name", "value"])
def _moon(packet: dict, language: str) -> str:
@@ -1403,7 +1508,10 @@ def _moon(packet: dict, language: str) -> str:
def _sudarshana(packet: dict, language: str) -> str:
module = _first(packet, ("divisional_and_special_charts", "sudarshana")) or {}
points = module.get("reference_points") if isinstance(module, dict) else {}
raw = module.get("raw") if isinstance(module, dict) and isinstance(module.get("raw"), dict) else {}
points = raw.get("reference_points") if isinstance(raw.get("reference_points"), dict) else None
if not isinstance(points, dict) and isinstance(module, dict):
points = module.get("reference_points")
rows = []
labels = {
"ascendant_lagna": ("Ascendant reference", "出生上升参考点"),
@@ -1419,16 +1527,37 @@ def _sudarshana(packet: dict, language: str) -> str:
def _transit(packet: dict, language: str) -> str:
module = _first(packet, ("timing_and_predictive_systems", "transit_positions")) or _first(packet, ("advanced_systems", "transit_positions"))
if not isinstance(module, dict):
module = {}
planets = module.get("planets") or module.get("positions") or module
multi = _first(packet, ("timing_and_predictive_systems", "transit_multi_reference")) or {}
analysis = multi.get("transit_analysis") if isinstance(multi, dict) else {}
when = multi.get("target_date") if isinstance(multi, dict) else None
overlay_root = _first(packet, ("advanced_systems", "nakshatra_dasha")) or {}
overlay = overlay_root.get("transit_overlay") if isinstance(overlay_root, dict) else {}
if not isinstance(analysis, dict):
analysis = {}
if not isinstance(overlay, dict):
overlay = {}
order = [planet for planet in (*analysis.keys(), *overlay.keys()) if planet in _PLANETS]
seen = []
for planet in order:
if planet not in seen:
seen.append(planet)
rows = []
if isinstance(planets, dict):
for planet, item in planets.items():
if isinstance(item, dict):
rows.append({"planet": planet, "sign": item.get("sign"), "date": item.get("date") or module.get("date")})
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("date", "text")])
for planet in seen:
item = analysis.get(planet) if isinstance(analysis.get(planet), dict) else {}
over = overlay.get(planet) if isinstance(overlay.get(planet), dict) else {}
nakshatra = over.get("transit_nakshatra")
if isinstance(nakshatra, str) and "_" in nakshatra:
nakshatra = nakshatra.replace("_", " ")
if not isinstance(nakshatra, str):
nakshatra = None
rows.append({
"planet": planet,
"sign": item.get("sign"),
"degree": item.get("degree_in_sign"),
"date": when if isinstance(when, (str, int, float)) else item.get("date"),
"nakshatra": nakshatra,
})
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("degree", "text"), ("date", "text"), ("nakshatra", "text")])
def _multi_transit(packet: dict, language: str) -> str:
@@ -1465,19 +1594,15 @@ def _kp(packet: dict, language: str) -> str:
def _d9(packet: dict, language: str) -> str:
varga = _first(packet, ("divisional_and_special_charts", "varga_full")) or {}
chart = None
if isinstance(varga, dict):
for key in ("D9", "d9", "Navamsa", "navamsa"):
if isinstance(varga.get(key), dict):
chart = varga[key]
break
planets = chart.get("planets") if isinstance(chart, dict) else None
chart = _varga_chart(packet, "D9_Navamsa", "D9", "d9", "Navamsa", "navamsa")
dignity = chart.get("_dignity") if isinstance(chart.get("_dignity"), dict) else {}
rows = []
if isinstance(planets, dict):
for planet, item in planets.items():
if isinstance(item, dict):
rows.append({"planet": planet, "sign": item.get("sign"), "dignity": item.get("dignity") or item.get("status")})
for planet, item in _named_planets(chart):
rows.append({
"planet": planet,
"sign": item.get("sign"),
"dignity": _dignity_token(dignity.get(planet) or item.get("dignity") or item.get("status"), language),
})
return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("dignity", "text")])
@@ -1486,13 +1611,19 @@ def _mapping(packet: dict, language: str) -> str:
rows = []
if isinstance(varga, dict):
for name, chart in varga.items():
if not isinstance(chart, dict):
continue
if any(token in str(name).upper() for token in ("D81", "D108", "D144")):
continue
sign = None
if isinstance(chart, dict):
asc = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {}
sign = asc.get("sign") or chart.get("asc_sign") or chart.get("lagna_sign")
rows.append({"name": name, "sign": sign})
asc = chart.get("Ascendant") if isinstance(chart.get("Ascendant"), dict) else {}
if not asc and isinstance(chart.get("ascendant"), dict):
asc = chart.get("ascendant") or {}
sign = asc.get("sign") or chart.get("asc_sign") or chart.get("lagna_sign")
if sign in (None, "") and isinstance(asc.get("sign_idx"), int):
sign = asc.get("sign_idx")
if sign in (None, ""):
continue
rows.append({"name": _varga_label(str(name), chart), "sign": sign})
return _rows_from(rows, language, [("name", "text"), ("sign", "sign")])
@@ -1515,7 +1646,9 @@ def _chalit_planets(packet: dict, language: str) -> str:
def _chalit_centres(packet: dict, language: str) -> str:
raw = _bhava_raw(packet)
boundaries = raw.get("boundaries") if isinstance(raw.get("boundaries"), dict) else {}
details = boundaries.get("houses_detail") if isinstance(boundaries, dict) else None
details = boundaries.get("houses_detail") if isinstance(boundaries.get("houses_detail"), list) else None
if not details:
details = boundaries.get("houses") if isinstance(boundaries.get("houses"), list) else []
rows = []
for item in details or []:
if isinstance(item, dict):
@@ -1625,7 +1758,7 @@ def _render_named(packet: dict, language: str, title: str) -> str:
"Tara-Chandra Matrix": lambda: _tara(packet, language),
"Natal Tithi Lord": lambda: _tithi(packet, language),
"Birth Calendar": lambda: _panchanga(packet, language),
"Pancha Pakshi": lambda: _simple_module(packet, ("advanced_systems", "pancha_pakshi"), language, [("name", "text"), ("value", "text"), ("status", "text")]),
"Pancha Pakshi": lambda: _missing_module(language, ["name", "value", "status"]),
"Natal Vivah Saham": lambda: _vivah(packet, language),
"Natal Saham Formula Basis": lambda: _saham_basis(packet, language),
"Arudha / UL / A10": lambda: _special_points(packet, language),
+4
View File
@@ -278,6 +278,9 @@ def render_english(index: int) -> dict:
edition = _english_edition(packet, True)
chinese = render_full_data_markdown(packet)
gates = chinese_prose_gates(chinese)
from scripts.report_render.appendix_language import snapshot_appendix_drop_log
drops = snapshot_appendix_drop_log()
markdown = edition.get("markdown_en") or ""
flags = _flags_from_packet(packet)
applicability = edition.get("reader_dasha_applicability_en") or ""
@@ -298,6 +301,7 @@ def render_english(index: int) -> dict:
"missing": rendered.count("[translation missing:"),
"zh_english_signs": gates["english_signs"],
"zh_han_function_glue": gates["han_function_glue"],
"appendix_drops": drops,
**_guard_snapshot(),
**flags,
}
+103
View File
@@ -6,6 +6,7 @@ are separate headings. Fictional charts only.
from __future__ import annotations
import copy
import re
import pytest
@@ -142,6 +143,108 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict
assert "blocked" not in zh.lower()
_UNDETERMINED = {"zh": "未判定", "en": "Not determined"}
_GAP_LINE = {
"zh": "公开报告资料包未带此模块",
"en": "This module is not in the public report packet",
}
# These four modules are absent from the public worksheet. The reason is why
# the section may stay undetermined. Anything else must show a real cell.
_PACKET_GAPS = {
"Navamsha AD Duration": "navamsha_dasha is computed on the reading but is not copied into the public worksheet",
"Pancha Pakshi": "pancha_pakshi is not copied into the public worksheet",
"Rashi Tulya Navamsa": "rashi_tulya_navamsa is not copied into the public worksheet",
"Current Dasha Convergence": "dasa_convergence per-domain rows are not copied into the public worksheet",
}
def _table_blocks(body: str) -> list[list[list[str]]]:
blocks = []
current: list[str] = []
for line in body.splitlines():
if line.startswith("|"):
current.append(line)
continue
if current:
blocks.append(current)
current = []
if current:
blocks.append(current)
parsed = []
for block in blocks:
rows = []
for index, line in enumerate(block):
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if cells and all(set(cell) <= set("-: ") and cell for cell in cells):
continue
if index == 0:
continue
rows.append(cells)
parsed.append(rows)
return parsed
def _section_has_real_cell(body: str, undetermined: str) -> bool:
blocks = _table_blocks(body)
if blocks:
return any(
cell and cell != undetermined
for rows in blocks
for row in rows
for cell in row
)
prose = [line.strip() for line in body.splitlines() if line.strip()]
return any(line and line != undetermined for line in prose)
def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_reports: dict[int, tuple[str, str]]) -> None:
titles = dict(SECTIONS)
for reason in _PACKET_GAPS.values():
assert reason
for index, (chinese, english) in fictional_reports.items():
for language, markdown in (("zh", chinese), ("en", english)):
chapter = _chapter_text(markdown, language)
for title_en, title_zh in SECTIONS:
heading = title_zh if language == "zh" else title_en
body = _section_body(chapter, heading)
if title_en in _PACKET_GAPS:
first = next(line.strip() for line in body.splitlines() if line.strip())
assert first == _GAP_LINE[language], f"{index} {heading}"
continue
assert _section_has_real_cell(body, _UNDETERMINED[language]), f"{index} {heading}"
def test_parity_chapters_go_through_local_conventions() -> None:
from scripts.pl9_full_data_export import _apply_local_report_conventions
packet = {
"report_language": "zh",
"worksheets": {
"strengths_and_scores": {
"vimsopaka": {
"Sun": {"planet": "Sun", "total_score": 12.5, "category": "必然"},
}
}
},
}
chinese = render_full_data_markdown(packet)
zh_body = _section_body(chinese, "Vimsopaka 分盘贡献")
zh_replaced = _apply_local_report_conventions("必然", language="zh")
assert zh_replaced == "可能"
assert zh_replaced in zh_body
assert "必然" not in zh_body
english_packet = copy.deepcopy(packet)
english_packet["report_language"] = "en"
english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "divorce"
english = render_full_data_markdown(english_packet)
en_body = _section_body(english, "Vimsopaka Contribution")
en_replaced = _apply_local_report_conventions("divorce", language="en")
assert en_replaced == "relationship strain"
assert en_replaced in en_body
assert "divorce" not in en_body.lower()
def test_chart_nine_partial_kaal_and_friendship(fictional_reports: dict[int, tuple[str, str]]) -> None:
chinese, english = fictional_reports[9]
assert _KAAL_ZH.search(chinese)
+86 -1
View File
@@ -3,24 +3,109 @@
This file is not on the quick gate. Windows timing is recorded in
docs/tasks/PROGRESS-report-english-han-leak-20261021.md. Set
REPORT_ENGLISH_SWEEP_DUMP to a directory to write the English markdown for
the acceptance pass. Workers stay at 4.
the acceptance pass. Workers stay at 4. Set J4_DROP_STATS to a json path to
write the Chinese-appendix drop counts from the same run.
"""
from __future__ import annotations
import json
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from tests.report_english_fictional import CHART_COUNT, init_english_worker, render_english, row_problem
def _j4_summary(rows: list[dict]) -> dict:
annual_names: dict[str, int] = {}
annual_charts = []
annual_tables = 0
effect_entries = 0
effect_calls = 0
effect_charts = []
effect_keys: dict[str, int] = {}
combination_entries = 0
combination_calls = 0
combination_charts = []
combination_keys: dict[str, int] = {}
replaced_entries = 0
replaced_charts = []
replaced_keys: dict[str, int] = {}
for row in rows:
drops = row.get("appendix_drops") if isinstance(row.get("appendix_drops"), dict) else {}
annual = drops.get("annual_columns") if isinstance(drops.get("annual_columns"), dict) else {}
tables = drops.get("annual_tables") if isinstance(drops.get("annual_tables"), dict) else {}
effects = drops.get("effects") if isinstance(drops.get("effects"), dict) else {}
combinations = drops.get("combinations_undetermined") if isinstance(drops.get("combinations_undetermined"), dict) else {}
replaced = drops.get("combinations_replaced") if isinstance(drops.get("combinations_replaced"), dict) else {}
if annual:
annual_charts.append(row.get("i"))
for name, count in annual.items():
annual_names[name] = annual_names.get(name, 0) + int(count)
annual_tables += int(tables.get("seen") or 0)
if effects:
effect_charts.append(row.get("i"))
effect_entries += len(effects)
effect_calls += sum(int(count) for count in effects.values())
for key, count in effects.items():
effect_keys[key] = effect_keys.get(key, 0) + int(count)
if combinations:
combination_charts.append(row.get("i"))
combination_entries += len(combinations)
combination_calls += sum(int(count) for count in combinations.values())
for key, count in combinations.items():
combination_keys[key] = combination_keys.get(key, 0) + int(count)
if replaced:
replaced_charts.append(row.get("i"))
replaced_entries += len(replaced)
for key, count in replaced.items():
replaced_keys[key] = replaced_keys.get(key, 0) + int(count)
return {
"charts": len(rows),
"annual_columns": annual_names,
"annual_chart_count": len(annual_charts),
"annual_tables_seen": annual_tables,
"effect_entries": effect_entries,
"effect_calls": effect_calls,
"effect_chart_count": len(effect_charts),
"effect_keys": effect_keys,
"combination_undetermined_entries": combination_entries,
"combination_undetermined_calls": combination_calls,
"combination_undetermined_chart_count": len(combination_charts),
"combination_undetermined_keys": combination_keys,
"combination_replaced_entries": replaced_entries,
"combination_replaced_chart_count": len(replaced_charts),
"combination_replaced_keys": replaced_keys,
}
def test_two_hundred_fictional_charts_have_clean_english() -> None:
workers = min(4, os.cpu_count() or 1, CHART_COUNT)
failures = []
rows = []
with ProcessPoolExecutor(max_workers=workers, initializer=init_english_worker) as pool:
futures = [pool.submit(render_english, index) for index in range(CHART_COUNT)]
for future in as_completed(futures):
row = future.result()
rows.append(row)
problem = row_problem(row)
if problem:
failures.append(problem)
summary = _j4_summary(rows)
print("J4_APPENDIX_DROPS " + json.dumps({
"annual_columns": summary["annual_columns"],
"annual_chart_count": summary["annual_chart_count"],
"annual_tables_seen": summary["annual_tables_seen"],
"effect_entries": summary["effect_entries"],
"effect_calls": summary["effect_calls"],
"effect_chart_count": summary["effect_chart_count"],
"combination_undetermined_entries": summary["combination_undetermined_entries"],
"combination_undetermined_calls": summary["combination_undetermined_calls"],
"combination_undetermined_chart_count": summary["combination_undetermined_chart_count"],
"combination_replaced_entries": summary["combination_replaced_entries"],
"combination_replaced_chart_count": summary["combination_replaced_chart_count"],
}, ensure_ascii=False))
stats_path = os.environ.get("J4_DROP_STATS")
if stats_path:
Path(stats_path).write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
assert failures == []