diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3a24eef..2c96a289 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
+## 2026-10-18 — 完整解读补上西占对照和五处缺失模块
+
+- 个人报告里的西占对照层不再因为缺文件而整段空白。它仍只作对照,不拿来改印度占星的结论。
+- 完整解读补上上升是否在九分盘同星座、Pushkara 行星简表、Punarphoo 观察,以及 Navamsha / Niryaana Shoola 两套大运。后两套用本站自己的算法,不换回上游那份已停用的算法。
+- 页面没有单独的新入口。Skill 版本不变(BUG-1375、BUG-1376)。
+
## 2026-10-18 — 星盘档案最多保存 20 个人
- 星盘档案从最多 5 人(含本人)改为最多 20 人。第 19 个其他人可以保存,第 20 个其他人会被拒绝,提示「最多保存 20 个人的星盘」。建档仍不扣点。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index c0e40027..82f03d15 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -36488,3 +36488,33 @@
- 相关记录:BUG-1361、BUG-1362。
- 复发自:无。
- 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。
+
+## BUG-1375 | 完整解读的西占对照层永远空白
+
+- 状态:代码已交,待验收
+- 首次发现 / 最近更新:2026-10-18 / 2026-10-18
+- 来源:issue #81。任务书 `docs/tasks/TASK-full-reading-module-parity-20261018.md`。
+- 现象:完整解读里的西占对照一直是空白。原因写着找不到 `unified_western_reading`。
+- 触发条件:任何一次 full-reading,只要给了参考日期。
+- 根因:引擎里已经有调用,对应文件和它依赖的 `classical_astrology.py` 没有搬进本仓库。
+- 修复:按上游 `bde5d615` 的副本搬入这两个文件。西占排盘只补了本仓没有的龙首盘和迁居盘函数,没有整份覆盖现有西占文件。特异性计分函数上游副本里没有,导入处返回「未移植、blocked」,不编造分数。
+- 验证:两位公开名人的该模块状态不再是空白。把联网和外部程序打桩成抛错后,西占包本身的调用次数为 0,完整解读仍成功。
+- 防复发:`tests/test_full_reading_module_parity.py` 检查模块能导入,并检查打桩后西占包不发起联网或外部程序。
+- 相关记录:BUG-1376。
+- 复发自:无。
+- 修复版本:分支 `codex/full-reading-module-parity-20261018`。
+
+## BUG-1376 | 完整解读比上游少五个模块,合盘的次要大运互证整项空白
+
+- 状态:代码已交,待验收
+- 首次发现 / 最近更新:2026-10-18 / 2026-10-18
+- 来源:issue #81。任务书 `docs/tasks/TASK-full-reading-module-parity-20261018.md`。
+- 现象:完整解读没有上升同星座、Pushkara 简表、Punarphoo、Navamsha 大运、Niryaana Shoola 大运这五个键。上游合盘校验因此把「次要大运互证」整项标成空白。
+- 触发条件:对比上游 full-reading 的模块键;或用上游合盘校验器读两份完整解读。
+- 根因:这几处是上游 2026 年 7 到 9 月加进 full-reading 的,同步时漏了。Punarphoo 文件其实已经在,只是没接上。
+- 修复:前三项按上游函数接上。后两项用本仓 `rashi_dashas_native` 的 `goel_method_1_v1` 和 `goel_nirayana_shoola_v1` 填键,不调用已停用的 `niryaana_shoola_dasha.py`。外层状态给校验器读,期间表仍是本仓的。
+- 验证:模块键覆盖上游那 86 个名字。两位公开名人的 Navamsha 首段星座和年数与上游 Goel Method 1 的算法一致。上游校验器里 Navamsha、Niryaana Shoola 两行不是空白,「次要大运互证」也不再是空白。屏蔽 PyJHora 后相关测试仍通过。
+- 防复发:`tests/test_full_reading_module_parity.py` 固定 86 个键,并跑上游校验器。`tests/test_rashi_dashas_native.py` 仍禁止那三份文件导入已停用的大运模块。
+- 相关记录:BUG-1283、BUG-1375。编号 1370~1374 由 issue #79 预留,本单从 1375 起。
+- 复发自:无。
+- 修复版本:分支 `codex/full-reading-module-parity-20261018`。
diff --git a/docs/tasks/PROGRESS-full-reading-module-parity-20261018.md b/docs/tasks/PROGRESS-full-reading-module-parity-20261018.md
new file mode 100644
index 00000000..f27b152d
--- /dev/null
+++ b/docs/tasks/PROGRESS-full-reading-module-parity-20261018.md
@@ -0,0 +1,46 @@
+# 进度:full-reading 补齐上游 5 个模块(2026-10-18)
+
+基线:`origin/staging` = `b3ef2d815`(任务书写作时写的 `47b4a2317` 已过时,开工以 fetch 后的最新 staging 为准)。分支 `codex/full-reading-module-parity-20261018`。
+
+## 做了什么
+
+- T3:`navamsha_dasha`、`niryaana_shoola_dasha` 用 `rashi_dashas_native.calculate_rashi_dashas` 的结果填键。profile id 仍是 `goel_method_1_v1` 和 `goel_nirayana_shoola_v1`,期间表原样放在 `periods`。外层 `status` 为 `parameter_sensitive`,给上游合盘校验器读。没有导入 `niryaana_shoola_dasha.py`。
+- T2:搬入 `_calc_lagna_vargottamamsa`、`_summarize_pushkara_planets`,并在 full-reading 写入 `lagna_vargottamamsa`、`pushkara_planets`、`punarphoo`(调用已有的 `scripts/punarphoo.py`)。
+- T1:按上游副本搬入 `scripts/unified_western_reading.py` 和 `scripts/classical_astrology.py`。西占排盘补了本仓没有的 `_birth_times`、`build_draconic_chart`、`build_relocation_chart`。加了 `scripts/_script_root.py`,因为搬来的文件会可选导入它,本仓原来没有这个文件。
+- T4:`tests/test_full_reading_module_parity.py` 固定 86 个模块键。
+- 两位公开名人用的是仓库里已有的样例(`tests/indastro_cases.json` 的 `brad_pitt`,`tests/celebrity_cases.json` 的 `jolie`),没有新抄出生时间。
+
+## 耗时
+
+同一进程里先跑一次预热,再各跑 3 次,取中位数。参考日 `2026-10-18`,ayanamsa `lahiri`,node `mean`。
+
+| 样例 | 改前 3 次(秒) | 改前中位数 | 改后 3 次(秒) | 改后中位数 | 变化 |
+| --- | --- | --- | --- | --- | --- |
+| brad_pitt | 4.943 / 5.000 / 5.043 | 5.000 | 4.964 / 5.152 / 5.192 | 5.152 | +3.0% |
+| jolie | 5.022 / 5.047 / 5.094 | 5.047 | 5.132 / 5.139 / 5.174 | 5.139 | +1.8% |
+
+没有超过 20%。多出来的时间主要是西占各层和一次本仓七套 rashi 大运。
+
+## Navamsha 首段
+
+两位公开名人的首段星座序号和年数,与上游 `goel_method_1_v1` 用「D9 上升 + 宫主星在 D9 的位置」算出的首段一致,也与本仓用黄经直接取九分盘的算法一致。没有需要仲裁的差异。
+
+## 没整份同步的西占差异
+
+- `western_timing_engine.py` 里西占层要的函数,签名已经和上游副本一致,没有改这个文件。
+- `build_tropical_natal_chart` 签名已一致。本仓这份还多了接口请求包装和岁差展示,没有用上游文件覆盖。
+- 上游 `classical_astrology.py` 在导入时需要 `western_real_case_calibration._build_calibration_summary`。这个函数不在本单的上游副本里,本仓原来也没有。补了一个只返回 `blocked` 的垫片,键是调用方实际读取的那些,不编造特异性分数。完整解读里的 classical 层仍然是 `executed`。
+- full-reading 不传迁居坐标,所以西占包里的 `relocation` 层是 `blocked`,整包状态是 `partial`。这和上游调用方式一样。挂到 full-reading 上的模块状态是 `parameter_sensitive`,不再是 `blocked`。
+- 现有 VedAstro 适配器仍会尝试本地外部程序,失败被它自己接住。打桩测试确认这次调用栈不经过 `unified_western_reading.py` 或 `classical_astrology.py`;单独跑西占包时,`urlopen` 和 `subprocess.run` 的调用次数为 0。
+
+## 检查
+
+| 检查 | 命令 | 结果 |
+| --- | --- | --- |
+| 新测试 + 大运红线 + Punarphoo | `pytest tests/test_full_reading_module_parity.py tests/test_rashi_dashas_native.py tests/test_punarphoo_observation.py -q` | 27 passed |
+| 屏蔽 PyJHora 后再跑新测试和大运红线 | 拦截 `jhora` 导入后跑上述前两个文件 | 19 passed |
+| 改过的西占文件 | `pytest tests/test_western_chart_engine.py tests/test_western_real_case_calibration.py -q` | 8 passed |
+| 全量 pytest | 未跑 | Windows 执行侧按循环约定不跑,留给验收 |
+| 快速门 | 未跑 | 同上 |
+
+解释器是本机 Anaconda,不是仓库 `.venv`(工作树里没有 `.venv`)。
diff --git a/docs/tasks/README.md b/docs/tasks/README.md
index 85727a94..c2ec9b83 100644
--- a/docs/tasks/README.md
+++ b/docs/tasks/README.md
@@ -502,6 +502,6 @@
| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` |
| `TASK-people-archive-limit-20-20261017.md` | `PROGRESS-people-archive-limit-20-20261017.md` | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待验收 | `codex/people-archive-limit-20-20261017` |
| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待执行(不再等 #78) | `codex/marriage-match-p1-20261017`;动表 |
-| `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` |
+| `TASK-full-reading-module-parity-20261018.md` | `PROGRESS-full-reading-module-parity-20261018.md` | #81 full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375、BUG-1376) | 待验收 | `codex/full-reading-module-parity-20261018` |
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 |
diff --git a/scripts/_script_root.py b/scripts/_script_root.py
new file mode 100644
index 00000000..1d20c005
--- /dev/null
+++ b/scripts/_script_root.py
@@ -0,0 +1,14 @@
+"""Put the repository root on sys.path for scripts copied from upstream.
+
+Those modules optionally import ``_script_root`` before their other imports.
+This file only adjusts the import path. It does not calculate charts.
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+root_text = str(ROOT)
+if root_text not in sys.path:
+ sys.path.insert(0, root_text)
diff --git a/scripts/classical_astrology.py b/scripts/classical_astrology.py
new file mode 100644
index 00000000..7cb446b6
--- /dev/null
+++ b/scripts/classical_astrology.py
@@ -0,0 +1,13065 @@
+#!/usr/bin/env python3
+"""Minimum auditable classical astrology calculation layer.
+
+This module preserves a narrow boundary: it calculates traditional tropical
+chart factors and audit data, but it does not make predictive judgments.
+"""
+
+from __future__ import annotations
+
+try: # reached as a script: this directory is on the path, the root is not
+ import _script_root # noqa: F401
+except ModuleNotFoundError: # pragma: no cover - reached as scripts.x instead
+ try:
+ from scripts import _script_root # noqa: F401
+ except ModuleNotFoundError:
+ pass
+
+
+import argparse
+from copy import deepcopy
+from functools import lru_cache
+import csv
+import hashlib
+import io
+import json
+import re
+import shutil
+import subprocess
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timedelta, timezone as fixed_timezone
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+try:
+ from scripts.western_chart_engine import _RULERS, _SIGNS, _longitude, _point, build_tropical_natal_chart
+ from scripts.western_timing_engine import calculate_annual_profection, calculate_zodiacal_release
+ from scripts.western_real_case_calibration import _build_calibration_summary
+except ModuleNotFoundError: # pragma: no cover - direct script execution
+ from western_chart_engine import _RULERS, _SIGNS, _longitude, _point, build_tropical_natal_chart
+ from western_timing_engine import calculate_annual_profection, calculate_zodiacal_release
+ from western_real_case_calibration import _build_calibration_summary
+
+
+TRADITIONAL_PLANETS = ("sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn")
+CLASSICAL_CASE_VALIDATION_FAMILIES = ("horary", "electional", "zr")
+DEFAULT_REAL_CASE_MANIFEST = Path("references/real_case_calibration/replay_manifest.json")
+DEFAULT_NEGATIVE_HOLDOUT_AUDIT = Path("references/real_case_calibration/timing_negative_holdout_source_audit_2026_07_17.json")
+DEFAULT_POSITIVE_HOLDOUT_MANIFEST = Path("references/real_case_calibration/replay_manifest_holdout_v2.json")
+DEFAULT_CANDIDATE_NEGATIVE_MANIFEST = Path("references/real_case_calibration/timing_rectification_candidate_holdout_labels_2026_07_23.json")
+DEFAULT_CLASSICAL_BLIND_VALIDATION_MANIFEST = Path("references/real_case_calibration/classical_blind_case_validation_manifest_2026_08_25.json")
+DEFAULT_CLASSICAL_REVIEWER_UNSEAL_MANIFEST = Path("references/real_case_calibration/classical_reviewer_unseal_manifest_2026_08_25.json")
+DEFAULT_CLASSICAL_PUBLIC_REVIEWER_UNSEAL_MANIFEST = Path("references/real_case_calibration/classical_public_reviewer_unseal_seed_2026_08_27.json")
+DEFAULT_CLASSICAL_DOCTRINE_LOCI_MANIFEST = Path("references/real_case_calibration/classical_doctrine_loci_manifest_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_SAMPLE_PACKET = Path("references/real_case_calibration/classical_horary_blind_sample_packet_seed_2026_08_27.json")
+DEFAULT_CLASSICAL_ELECTIONAL_SAMPLE_PACKET = Path("references/real_case_calibration/classical_electional_blind_sample_packet_seed_2026_08_27.json")
+DEFAULT_CLASSICAL_ZR_SAMPLE_PACKET = Path("references/real_case_calibration/classical_public_blind_sample_packet_seed_2026_08_27.json")
+DEFAULT_CLASSICAL_HORARY_ACQUISITION_QUEUE = Path("references/real_case_calibration/classical_horary_case_acquisition_queue_2026_08_29.json")
+DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_QUEUE = Path("references/real_case_calibration/classical_electional_case_acquisition_queue_2026_08_29.json")
+DEFAULT_CLASSICAL_ZR_ACQUISITION_QUEUE = Path("references/real_case_calibration/classical_zr_case_acquisition_queue_2026_08_30.json")
+DEFAULT_CLASSICAL_HORARY_SOURCE_DISCOVERY = Path("references/real_case_calibration/classical_horary_source_candidates.json")
+DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_DISCOVERY = Path("references/real_case_calibration/classical_electional_source_candidates.json")
+DEFAULT_CLASSICAL_ZR_SOURCE_DISCOVERY = Path("references/real_case_calibration/classical_zr_source_candidates.json")
+DEFAULT_CLASSICAL_HORARY_SOURCE_FREEZE_CONVERSION = Path("references/real_case_calibration/classical_horary_source_freeze_conversion_2026_08_30.json")
+DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_FREEZE_CONVERSION = Path("references/real_case_calibration/classical_electional_source_freeze_conversion_2026_08_30.json")
+DEFAULT_CLASSICAL_ZR_SOURCE_FREEZE_CONVERSION = Path("references/real_case_calibration/classical_zr_source_freeze_conversion_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_CASE_FIELD_EXTRACTION = Path("references/real_case_calibration/classical_horary_case_level_field_extraction_2026_08_30.json")
+DEFAULT_CLASSICAL_ELECTIONAL_CASE_FIELD_EXTRACTION = Path("references/real_case_calibration/classical_electional_case_level_field_extraction_2026_08_30.json")
+DEFAULT_CLASSICAL_ZR_CASE_FIELD_EXTRACTION = Path("references/real_case_calibration/classical_zr_case_level_field_extraction_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_CHART_OCR_CACHE = Path("references/real_case_calibration/classical_horary_chart_ocr_cache_2026_08_31.json")
+DEFAULT_CLASSICAL_ELECTIONAL_CHART_OCR_CACHE = Path("references/real_case_calibration/classical_electional_chart_ocr_cache_2026_08_31.json")
+DEFAULT_CLASSICAL_ZR_CHART_OCR_CACHE = Path("references/real_case_calibration/classical_zr_chart_ocr_cache_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_OCR_FIELD_BACKFILL = Path("references/real_case_calibration/classical_horary_ocr_field_backfill_2026_08_31.json")
+DEFAULT_CLASSICAL_ELECTIONAL_OCR_FIELD_BACKFILL = Path("references/real_case_calibration/classical_electional_ocr_field_backfill_2026_08_31.json")
+DEFAULT_CLASSICAL_ZR_OCR_FIELD_BACKFILL = Path("references/real_case_calibration/classical_zr_ocr_field_backfill_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_horary_freeze_admission_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_electional_freeze_admission_2026_08_30.json")
+DEFAULT_CLASSICAL_ZR_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_zr_freeze_admission_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_PACKET = Path("references/real_case_calibration/classical_horary_reviewer_freeze_packet_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_PACKET = Path("references/real_case_calibration/classical_electional_reviewer_freeze_packet_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_PACKET = Path("references/real_case_calibration/classical_zr_reviewer_freeze_packet_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_horary_reviewer_freeze_admission_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_electional_reviewer_freeze_admission_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_ADMISSION = Path("references/real_case_calibration/classical_zr_reviewer_freeze_admission_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_UNSEAL_MANIFEST = Path("references/real_case_calibration/classical_horary_reviewer_unseal_manifest_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_UNSEAL_MANIFEST = Path("references/real_case_calibration/classical_electional_reviewer_unseal_manifest_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_UNSEAL_MANIFEST = Path("references/real_case_calibration/classical_zr_reviewer_unseal_manifest_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_PIPELINE = Path("references/real_case_calibration/classical_horary_reviewer_evidence_pipeline_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_PIPELINE = Path("references/real_case_calibration/classical_electional_reviewer_evidence_pipeline_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_PIPELINE = Path("references/real_case_calibration/classical_zr_reviewer_evidence_pipeline_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_KIT = Path("references/real_case_calibration/classical_horary_reviewer_evidence_kit_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_KIT = Path("references/real_case_calibration/classical_electional_reviewer_evidence_kit_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_KIT = Path("references/real_case_calibration/classical_zr_reviewer_evidence_kit_2026_08_31.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_ASSISTANT = Path("references/real_case_calibration/classical_horary_reviewer_evidence_assistant_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_ASSISTANT = Path("references/real_case_calibration/classical_electional_reviewer_evidence_assistant_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_ASSISTANT = Path("references/real_case_calibration/classical_zr_reviewer_evidence_assistant_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_REVIEWER_ANSWER_APPLY = Path("references/real_case_calibration/classical_horary_reviewer_answer_apply_2026_09_02.json")
+DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_ANSWER_APPLY = Path("references/real_case_calibration/classical_electional_reviewer_answer_apply_2026_09_02.json")
+DEFAULT_CLASSICAL_ZR_REVIEWER_ANSWER_APPLY = Path("references/real_case_calibration/classical_zr_reviewer_answer_apply_2026_09_02.json")
+DEFAULT_CLASSICAL_HORARY_ACQUISITION_RUNNER_PACKET = Path("references/real_case_calibration/classical_horary_case_acquisition_runner_packet_2026_08_29.json")
+DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_RUNNER_PACKET = Path("references/real_case_calibration/classical_electional_case_acquisition_runner_packet_2026_08_29.json")
+DEFAULT_CLASSICAL_ZR_ACQUISITION_RUNNER_PACKET = Path("references/real_case_calibration/classical_zr_case_acquisition_runner_packet_2026_08_30.json")
+DEFAULT_CLASSICAL_HORARY_FREEZE_CHECKLIST = Path("references/real_case_calibration/classical_horary_freeze_checklist_2026_08_29.json")
+DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_CHECKLIST = Path("references/real_case_calibration/classical_electional_freeze_checklist_2026_08_29.json")
+DEFAULT_CLASSICAL_ZR_FREEZE_CHECKLIST = Path("references/real_case_calibration/classical_zr_freeze_checklist_2026_08_30.json")
+DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE = Path("references/real_case_calibration/classical_horary_domain_validation_bundle_2026_08_29.json")
+DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE = Path("references/real_case_calibration/classical_electional_domain_validation_bundle_2026_08_29.json")
+DEFAULT_CLASSICAL_ZR_DOMAIN_VALIDATION_BUNDLE = Path("references/real_case_calibration/classical_zr_domain_validation_bundle_2026_08_30.json")
+DEFAULT_CLASSICAL_HORARY_ELECTIONAL_REMAINING_TASK_REGISTER = Path("references/real_case_calibration/classical_horary_electional_remaining_task_register_2026_08_29.json")
+DEFAULT_CLASSICAL_ARTIFACT_REGISTRY = Path("references/real_case_calibration/classical_artifact_registry_2026_08_29.json")
+DEFAULT_CLASSICAL_PRODUCTIZATION_SHELL = Path("references/real_case_calibration/classical_productization_shell_2026_08_29.json")
+DEFAULT_CLASSICAL_PROMOTION_THRESHOLD_CALIBRATION = Path("references/real_case_calibration/classical_promotion_threshold_calibration_2026_09_02.json")
+DEFAULT_ASTROLOG_SOURCE_URL = "https://www.astrolog.org/ftp/ast80src.zip"
+DEFAULT_ASTROLOG_LICENSE_URL = "https://www.astrolog.org/ftp/license.htm"
+DEFAULT_ASTROLOG_SOURCE_DIR = Path("references/open_source_sources/astrolog")
+DEFAULT_ASTROLOG_BINARY = Path("references/open_source_sources/astrolog/src/astrolog")
+DEFAULT_ASTROLOG_PARITY_DIR = Path("references/real_case_calibration/astrolog_parity")
+CLASSICAL_FIXED_STAR_PRIORITY = (
+ "Sirius", "Canopus", "Arcturus", "Vega", "Capella", "Rigel", "Procyon",
+ "Betelgeuse", "Achernar", "Agena", "Altair", "Acrux", "Aldebaran",
+ "Antares", "Spica", "Pollux", "Fomalhaut", "Deneb", "Regulus",
+ "Castor", "Shaula", "Bellatrix", "Alnilam", "Alkaid", "Algol",
+ "Alcyone", "Alphard",
+)
+_CHALDEAN_ORDER = ("saturn", "jupiter", "mars", "sun", "venus", "mercury", "moon")
+_WEEKDAY_DAY_RULERS = ("moon", "mars", "mercury", "jupiter", "venus", "saturn", "sun")
+_CANDIDATE_PLACE_COORDS = {
+ "san francisco, california, usa": {"lat": 37.7749, "lon": -122.4194, "tz": -8.0},
+ "honolulu, hawaii, usa": {"lat": 21.3069, "lon": -157.8583, "tz": -10.0},
+ "sandringham, norfolk, england": {"lat": 52.8299, "lon": 0.5145, "tz": 1.0},
+}
+
+_EXALTATIONS = {
+ "sun": "Aries",
+ "moon": "Taurus",
+ "jupiter": "Cancer",
+ "mercury": "Virgo",
+ "saturn": "Libra",
+ "mars": "Capricorn",
+ "venus": "Pisces",
+}
+_FALLS = {planet: _SIGNS[(_SIGNS.index(sign) + 6) % 12] for planet, sign in _EXALTATIONS.items()}
+_DETRIMENTS = {planet: _SIGNS[(_SIGNS.index(sign) + 6) % 12] for sign, planet in _RULERS.items()}
+_TRIPLICITIES = {
+ "fire": {"day": "sun", "night": "jupiter", "participating": "saturn"},
+ "earth": {"day": "venus", "night": "moon", "participating": "mars"},
+ "air": {"day": "saturn", "night": "mercury", "participating": "jupiter"},
+ "water": {"day": "venus", "night": "mars", "participating": "moon"},
+}
+_ELEMENTS = {
+ "Aries": "fire", "Leo": "fire", "Sagittarius": "fire",
+ "Taurus": "earth", "Virgo": "earth", "Capricorn": "earth",
+ "Gemini": "air", "Libra": "air", "Aquarius": "air",
+ "Cancer": "water", "Scorpio": "water", "Pisces": "water",
+}
+
+# Egyptian bounds by sign: upper degree, ruler.
+_EGYPTIAN_BOUNDS = {
+ "Aries": [(6, "jupiter"), (14, "venus"), (21, "mercury"), (26, "mars"), (30, "saturn")],
+ "Taurus": [(8, "venus"), (14, "mercury"), (22, "jupiter"), (27, "saturn"), (30, "mars")],
+ "Gemini": [(6, "mercury"), (12, "jupiter"), (17, "venus"), (24, "mars"), (30, "saturn")],
+ "Cancer": [(7, "mars"), (13, "venus"), (19, "mercury"), (26, "jupiter"), (30, "saturn")],
+ "Leo": [(6, "jupiter"), (11, "venus"), (18, "saturn"), (24, "mercury"), (30, "mars")],
+ "Virgo": [(7, "mercury"), (13, "venus"), (17, "jupiter"), (21, "mars"), (30, "saturn")],
+ "Libra": [(6, "saturn"), (14, "mercury"), (21, "jupiter"), (28, "venus"), (30, "mars")],
+ "Scorpio": [(7, "mars"), (11, "venus"), (19, "mercury"), (24, "jupiter"), (30, "saturn")],
+ "Sagittarius": [(12, "jupiter"), (17, "venus"), (21, "mercury"), (26, "saturn"), (30, "mars")],
+ "Capricorn": [(7, "mercury"), (14, "jupiter"), (22, "venus"), (26, "saturn"), (30, "mars")],
+ "Aquarius": [(7, "mercury"), (13, "venus"), (20, "jupiter"), (25, "mars"), (30, "saturn")],
+ "Pisces": [(12, "venus"), (16, "jupiter"), (19, "mercury"), (28, "mars"), (30, "saturn")],
+}
+_FACE_RULERS = ("mars", "sun", "venus", "mercury", "moon", "saturn", "jupiter")
+
+
+_DOCTRINE_SOURCE_LOCI = {
+ "zr": [
+ {
+ "source_id": "local_valens_riley_anthology_full",
+ "author": "Vettius Valens",
+ "title": "Anthology",
+ "locator_hint": "Zodiacal Releasing / Lots sections; use Book IV scan for primary locus review",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_valens_book_iv_scan",
+ "author": "Vettius Valens",
+ "title": "Book IV",
+ "locator_hint": "Book IV ZR/releasing/Loosing source locus; OCR required before final wording freeze",
+ "source_status": "ocr_required",
+ },
+ {
+ "source_id": "local_bonatti_arabic_parts_zoller",
+ "author": "Guido Bonatti",
+ "title": "On the Arabic Parts",
+ "locator_hint": "Lots/Arabic Parts topic arbitration used as a cross-lot support source, not as an independent ZR prediction validator",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_abumashar_persian_nativities_iii_solar_revolutions",
+ "author": "Abu Ma'shar al-Balkhi",
+ "title": "Persian Nativities III: On Solar Revolutions",
+ "locator_hint": "annual/profection-return arbitration source for keeping release-period evidence subordinate to validated yearly/event evidence",
+ "source_status": "extractable",
+ },
+ ],
+ "horary": [
+ {
+ "source_id": "local_lilly_christian_astrology_vol_1_3_scan",
+ "author": "William Lilly",
+ "title": "Christian Astrology (Vol. 1-3)",
+ "locator_hint": "Book II question judgment, prohibition/impediment, perfection, and timing sections; OCR cache supplies page anchors",
+ "source_status": "ocr_required",
+ },
+ {
+ "source_id": "local_lilly_houlding_christian_astrology_books_i_ii",
+ "author": "William Lilly",
+ "title": "Christian Astrology Books I & II",
+ "locator_hint": "Book II, questions/perfection/timing sections",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_sahl_mashaallah_works_dykes",
+ "author": "Sahl ibn Bishr; Masha'allah",
+ "title": "Works of Sahl & Masha'allah",
+ "locator_hint": "questions, significators, reception, perfection, and judgment order",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_dorotheus_pingree_carmen_astrologicum",
+ "author": "Dorotheus of Sidon",
+ "title": "Carmen Astrologicum: with charts, tables and index",
+ "locator_hint": "interrogations and question doctrine cross-check before modern horary yes/no promotion",
+ "source_status": "extractable",
+ },
+ ],
+ "electional": [
+ {
+ "source_id": "local_dorotheus_book_v_excerpt",
+ "author": "Dorotheus of Sidon",
+ "title": "Carmen Astrologicum--Book V",
+ "locator_hint": "Book V, commencements/interrogations and Moon-condition rules",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_bonatti_elections_treatise_7_dykes",
+ "author": "Guido Bonatti",
+ "title": "Bonatti on Elections: Treatise 7",
+ "locator_hint": "Treatise 7 electional goal rules, cautions, and Moon condition",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_dorotheus_dykes_carmen_astrologicum",
+ "author": "Dorotheus of Sidon; 'Umar al-Tabari",
+ "title": "Carmen Astrologicum: The 'Umar al-Tabari Translation",
+ "locator_hint": "Book V and electional commencements cross-translation source for Moon condition and goal-specific cautions",
+ "source_status": "extractable",
+ },
+ ],
+ "lots": [
+ {
+ "source_id": "local_bonatti_arabic_parts_zoller",
+ "author": "Guido Bonatti",
+ "title": "On the Arabic Parts",
+ "locator_hint": "Arabic Parts/Lots formulas and topic arbitration",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_paulus_introductory_matters_schmidt_hand",
+ "author": "Paulus Alexandrinus",
+ "title": "Introductory Matters",
+ "locator_hint": "definitions and Lots terminology",
+ "source_status": "extractable",
+ },
+ ],
+ "aspect": [
+ {
+ "source_id": "local_bonatti_basic_astrology_treatises_1_3_dykes",
+ "author": "Guido Bonatti",
+ "title": "Bonatti on Basic Astrology: Treatises 1-3",
+ "locator_hint": "configurations/aspects, houses, and planets",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_paulus_introductory_matters_schmidt_hand",
+ "author": "Paulus Alexandrinus",
+ "title": "Introductory Matters",
+ "locator_hint": "aspect/configuration definitions",
+ "source_status": "extractable",
+ },
+ ],
+ "dignity": [
+ {
+ "source_id": "local_abumashar_great_introduction_dykes",
+ "author": "Abu Ma'shar",
+ "title": "The Great Introduction to the Science of the Judgments of the Stars",
+ "locator_hint": "sect, dignity foundations, signs, Lots, and planetary condition",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_bonatti_basic_astrology_treatises_1_3_dykes",
+ "author": "Guido Bonatti",
+ "title": "Bonatti on Basic Astrology: Treatises 1-3",
+ "locator_hint": "essential/accidental dignity and house-condition foundations",
+ "source_status": "extractable",
+ },
+ ],
+ "reception": [
+ {
+ "source_id": "local_sahl_mashaallah_works_dykes",
+ "author": "Sahl ibn Bishr; Masha'allah",
+ "title": "Works of Sahl & Masha'allah",
+ "locator_hint": "reception, perfection, and questions sections",
+ "source_status": "extractable",
+ },
+ {
+ "source_id": "local_lilly_houlding_christian_astrology_books_i_ii",
+ "author": "William Lilly",
+ "title": "Christian Astrology Books I & II",
+ "locator_hint": "Book II reception and question judgment sections",
+ "source_status": "extractable",
+ },
+ ],
+}
+
+
+def _source_loci(*families: str, rule_family: str | None = None) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ page_manifest = _load_doctrine_loci_manifest(_shared=True)
+ for family in families:
+ for row in _DOCTRINE_SOURCE_LOCI.get(family, []):
+ source_id = str(row["source_id"])
+ if source_id in seen:
+ continue
+ seen.add(source_id)
+ page_anchors = _page_anchors_for_source_family(page_manifest, source_id, family, rule_family=rule_family)
+ rows.append({
+ **row,
+ "doctrine_family": family,
+ "page_anchors": deepcopy(page_anchors),
+ "page_anchor_count": len(page_anchors),
+ })
+ return rows
+
+
+def _page_anchors_for_source_family(
+ manifest: dict[str, Any],
+ source_id: str,
+ family: str,
+ *,
+ rule_family: str | None = None,
+) -> list[dict[str, Any]]:
+ anchors: list[dict[str, Any]] = []
+ for source in manifest.get("source_loci", []) if isinstance(manifest.get("source_loci"), list) else []:
+ if not isinstance(source, dict) or source.get("source_id") != source_id:
+ continue
+ family_rows = source.get("family_loci") if isinstance(source.get("family_loci"), list) else []
+ for family_row in family_rows:
+ if not isinstance(family_row, dict) or family_row.get("doctrine_family") != family:
+ continue
+ for anchor in family_row.get("anchors") or []:
+ if isinstance(anchor, dict):
+ rule_locus_families = (
+ anchor.get("rule_locus_families")
+ if isinstance(anchor.get("rule_locus_families"), list)
+ else []
+ )
+ if rule_family and rule_locus_families and rule_family not in rule_locus_families:
+ continue
+ anchors.append({
+ "rule_locus_id": anchor.get("rule_locus_id"),
+ "rule_locus_catalog_ids": (
+ anchor.get("rule_locus_catalog_ids")
+ if isinstance(anchor.get("rule_locus_catalog_ids"), list)
+ else []
+ ),
+ "rule_locus_labels": (
+ anchor.get("rule_locus_labels")
+ if isinstance(anchor.get("rule_locus_labels"), list)
+ else []
+ ),
+ "page": anchor.get("page"),
+ "anchor": anchor.get("anchor"),
+ "chapter_anchor": anchor.get("chapter_anchor"),
+ "keyword": anchor.get("keyword"),
+ "rule_locus_families": rule_locus_families,
+ "status": anchor.get("status"),
+ })
+ return anchors
+
+
+def _source_loci_manifest() -> dict[str, Any]:
+ page_anchor_manifest = _load_doctrine_loci_manifest()
+ families = {
+ family: {
+ "source_count": len(rows),
+ "source_ids": [row["source_id"] for row in rows],
+ "extractable_count": sum(1 for row in rows if row["source_status"] == "extractable"),
+ "ocr_required_count": sum(1 for row in rows if row["source_status"] == "ocr_required"),
+ "page_anchor_summary": page_anchor_manifest.get("family_summary", {}).get(family),
+ }
+ for family, rows in _DOCTRINE_SOURCE_LOCI.items()
+ }
+ return {
+ "schema": "classical.doctrine_source_loci_manifest.v1",
+ "status": "partial",
+ "families": families,
+ "family_count": len(families),
+ "page_anchor_manifest": page_anchor_manifest,
+ "promotion_allowed": False,
+ "boundary": "Source loci are citation pointers for doctrine review only; they are not case validation or prediction promotion.",
+ }
+
+
+@lru_cache(maxsize=4)
+def _read_doctrine_loci_json(path: Path, revision: tuple[int, ...]) -> dict[str, Any]:
+ """Reuse parsing only; file identity/timestamps are checked on every lookup."""
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ raise ValueError("doctrine loci manifest must be an object")
+ return data
+
+
+def _load_doctrine_loci_manifest(path: Path = DEFAULT_CLASSICAL_DOCTRINE_LOCI_MANIFEST, *, _shared: bool = False) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "reason": "page_anchor_manifest_not_generated",
+ "anchor_count": 0,
+ "family_summary": {},
+ }
+ try:
+ resolved = path.resolve()
+ stat = resolved.stat()
+ revision = (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
+ data = _read_doctrine_loci_json(resolved, revision)
+ # Only the internal anchor selector borrows this read-only value.
+ # Returned reports own independent mutable data.
+ if not _shared:
+ data = deepcopy(data)
+ except Exception as exc: # pragma: no cover - malformed local artifact
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "reason": f"page_anchor_manifest_unreadable:{type(exc).__name__}",
+ "anchor_count": 0,
+ "family_summary": {},
+ }
+ return {
+ "status": data.get("status", "partial"),
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "anchor_count": data.get("anchor_count", 0),
+ "sources_with_hits": data.get("sources_with_hits", 0),
+ "family_summary": data.get("family_summary") if isinstance(data.get("family_summary"), dict) else {},
+ "source_loci": data.get("source_loci") if isinstance(data.get("source_loci"), list) else [],
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Page anchors are partial doctrine loci only."),
+ }
+
+
+def _bound_lord(sign: str, degree: float) -> str:
+ for upper, ruler in _EGYPTIAN_BOUNDS[sign]:
+ if degree < upper:
+ return ruler
+ return _EGYPTIAN_BOUNDS[sign][-1][1]
+
+
+def _face_lord(longitude: float) -> str:
+ return _FACE_RULERS[int(_longitude(longitude) // 10) % len(_FACE_RULERS)]
+
+
+def _is_day_chart(chart: dict[str, Any]) -> bool:
+ sun_house = int(chart["natal"]["planets"]["sun"]["house"])
+ return 7 <= sun_house <= 12
+
+
+def _sect(chart: dict[str, Any]) -> dict[str, Any]:
+ is_day = _is_day_chart(chart)
+ return {
+ "status": "executed",
+ "chart_sect": "day" if is_day else "night",
+ "luminary_of_sect": "sun" if is_day else "moon",
+ "benefic_of_sect": "jupiter" if is_day else "venus",
+ "malefic_contrary_to_sect": "mars" if is_day else "saturn",
+ "basis": "Sun house above/below horizon in local tropical chart",
+ }
+
+
+def _dignities(chart: dict[str, Any], sect: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ chart_sect = sect["chart_sect"]
+ rows: dict[str, dict[str, Any]] = {}
+ for planet in TRADITIONAL_PLANETS:
+ point = chart["natal"]["planets"][planet]
+ sign = point["sign"]
+ degree = float(point["degree_in_sign"])
+ ruler = _RULERS[sign]
+ element = _ELEMENTS[sign]
+ triplicity = _TRIPLICITIES[element]
+ bound = _bound_lord(sign, degree)
+ face = _face_lord(point["longitude"])
+ dignity = {
+ "rulership": ruler == planet,
+ "detriment": _DETRIMENTS.get(planet) == sign,
+ "exaltation": _EXALTATIONS.get(planet) == sign,
+ "fall": _FALLS.get(planet) == sign,
+ "triplicity": (
+ "ruler" if triplicity[chart_sect] == planet
+ else "participating" if triplicity["participating"] == planet
+ else "none"
+ ),
+ "bound": bound == planet,
+ "face": face == planet,
+ }
+ score = (
+ (5 if dignity["rulership"] else 0)
+ + (4 if dignity["exaltation"] else 0)
+ + (3 if dignity["triplicity"] == "ruler" else 1 if dignity["triplicity"] == "participating" else 0)
+ + (2 if dignity["bound"] else 0)
+ + (1 if dignity["face"] else 0)
+ - (5 if dignity["detriment"] else 0)
+ - (4 if dignity["fall"] else 0)
+ )
+ rows[planet] = {
+ "longitude": point["longitude"],
+ "sign": sign,
+ "degree_in_sign": degree,
+ "house": point["house"],
+ "essential_dignities": dignity,
+ "bound_lord": bound,
+ "face_lord": face,
+ "score": int(score),
+ }
+ return rows
+
+
+def _lot_row(longitude: float, formula: str) -> dict[str, Any]:
+ row = _point(longitude)
+ row["formula"] = formula
+ row["ruler"] = _RULERS[row["sign"]]
+ row["source_loci"] = _source_loci("lots")
+ return row
+
+
+def _lots(chart: dict[str, Any], sect: dict[str, Any]) -> dict[str, Any]:
+ asc = chart["natal"]["angles"]["ascendant"]["longitude"]
+ sun = chart["natal"]["planets"]["sun"]["longitude"]
+ moon = chart["natal"]["planets"]["moon"]["longitude"]
+ venus = chart["natal"]["planets"]["venus"]["longitude"]
+ jupiter = chart["natal"]["planets"]["jupiter"]["longitude"]
+ mercury = chart["natal"]["planets"]["mercury"]["longitude"]
+ is_day = sect["chart_sect"] == "day"
+ fortune = asc + moon - sun if is_day else asc + sun - moon
+ spirit = asc + sun - moon if is_day else asc + moon - sun
+ eros = asc + venus - spirit if is_day else asc + spirit - venus
+ necessity = asc + mercury - fortune if is_day else asc + fortune - mercury
+ basis = asc + fortune - spirit if is_day else asc + spirit - fortune
+ exaltation = asc + jupiter - sun if is_day else asc + sun - jupiter
+ return {
+ "fortune": _lot_row(fortune, "ascendant + moon - sun" if is_day else "ascendant + sun - moon"),
+ "spirit": _lot_row(spirit, "ascendant + sun - moon" if is_day else "ascendant + moon - sun"),
+ "eros": _lot_row(eros, "ascendant + venus - spirit" if is_day else "ascendant + spirit - venus"),
+ "necessity": _lot_row(necessity, "ascendant + mercury - fortune" if is_day else "ascendant + fortune - mercury"),
+ "basis": _lot_row(basis, "ascendant + fortune - spirit" if is_day else "ascendant + spirit - fortune"),
+ "exaltation": _lot_row(exaltation, "ascendant + jupiter - sun" if is_day else "ascendant + sun - jupiter"),
+ "status": "executed",
+ "boundary": "Expanded lot formulas are preserved as auditable points; topic interpretation is not inferred.",
+ }
+
+
+def _house_rulers(chart: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ rows: dict[str, dict[str, Any]] = {}
+ for row in chart["natal"]["houses"]:
+ house = str(row["house"])
+ cusp = row["cusp"]
+ sign = cusp["sign"]
+ ruler = _RULERS[sign]
+ rows[house] = {
+ "house": int(house),
+ "cusp_longitude": cusp["longitude"],
+ "cusp_sign": sign,
+ "ruler": ruler,
+ "ruler_house": chart["natal"]["planets"][ruler]["house"],
+ "ruler_sign": chart["natal"]["planets"][ruler]["sign"],
+ }
+ return rows
+
+
+def _house_motion(house: int) -> str:
+ if house in {1, 4, 7, 10}:
+ return "angular"
+ if house in {2, 5, 8, 11}:
+ return "succedent"
+ return "cadent"
+
+
+def _planetary_condition(chart: dict[str, Any], sect: dict[str, Any], dignities: dict[str, dict[str, Any]]) -> dict[str, Any]:
+ benefic_of_sect = sect["benefic_of_sect"]
+ malefic_contrary = sect["malefic_contrary_to_sect"]
+ luminary = sect["luminary_of_sect"]
+ rows: dict[str, Any] = {}
+ for planet in TRADITIONAL_PLANETS:
+ point = chart["natal"]["planets"][planet]
+ sect_status = "neutral"
+ if planet in {benefic_of_sect, luminary}:
+ sect_status = "of_sect"
+ elif planet == malefic_contrary:
+ sect_status = "contrary_to_sect"
+ rows[planet] = {
+ "house_motion": _house_motion(int(point["house"])),
+ "speed_status": "retrograde" if point.get("retrograde") else "direct",
+ "sect_status": sect_status,
+ "dignity_score": dignities[planet]["score"],
+ "condition_boundary": "Compact condition flags only; no benefic/malefic outcome is inferred.",
+ }
+ return rows
+
+
+def _planetary_doctrine_condition_verdicts(
+ condition: dict[str, Any],
+ dignities: dict[str, dict[str, Any]],
+ sect: dict[str, Any],
+) -> dict[str, Any]:
+ rows: list[dict[str, Any]] = []
+ for planet in TRADITIONAL_PLANETS:
+ dignity = dignities[planet]
+ flags = dignity["essential_dignities"]
+ planet_condition = condition[planet]
+ support_factors: list[str] = []
+ caution_factors: list[str] = []
+ if int(dignity["score"]) > 0:
+ support_factors.append("positive_essential_dignity_score")
+ if flags.get("rulership"):
+ support_factors.append("domicile_rulership")
+ if flags.get("exaltation"):
+ support_factors.append("exaltation")
+ if flags.get("triplicity") == "ruler":
+ support_factors.append("sect_triplicity_ruler")
+ if flags.get("bound"):
+ support_factors.append("own_bound")
+ if flags.get("face"):
+ support_factors.append("own_face")
+ if planet_condition.get("house_motion") == "angular":
+ support_factors.append("angular_house")
+ if planet_condition.get("sect_status") == "of_sect":
+ support_factors.append("of_sect")
+ if int(dignity["score"]) < 0:
+ caution_factors.append("negative_essential_dignity_score")
+ if flags.get("detriment"):
+ caution_factors.append("detriment")
+ if flags.get("fall"):
+ caution_factors.append("fall")
+ if planet_condition.get("house_motion") == "cadent":
+ caution_factors.append("cadent_house")
+ if planet_condition.get("speed_status") == "retrograde":
+ caution_factors.append("retrograde")
+ if planet_condition.get("sect_status") == "contrary_to_sect":
+ caution_factors.append("contrary_to_sect")
+ if caution_factors and not support_factors:
+ verdict = "review_caution"
+ elif support_factors and not caution_factors:
+ verdict = "review_support"
+ elif support_factors and caution_factors:
+ verdict = "mixed_review"
+ else:
+ verdict = "neutral_review"
+ rows.append(
+ {
+ "planet": planet,
+ "status": "executed",
+ "verdict": verdict,
+ "sect": sect["chart_sect"],
+ "sect_status": planet_condition.get("sect_status"),
+ "house_motion": planet_condition.get("house_motion"),
+ "speed_status": planet_condition.get("speed_status"),
+ "dignity_score": dignity["score"],
+ "essential_dignities": flags,
+ "support_factors": support_factors,
+ "caution_factors": caution_factors,
+ "cannot_promote_reason": (
+ "Planet condition explains doctrine review weight only; it needs topic doctrine, timing arbitration, "
+ "and blind case calibration before prediction-ready use."
+ ),
+ "source_loci": _source_loci("dignity"),
+ "promotion_allowed": False,
+ }
+ )
+ return {
+ "schema": "classical.planetary_doctrine_condition_verdicts.v1",
+ "status": "executed",
+ "chart_sect": sect["chart_sect"],
+ "row_count": len(rows),
+ "rows": rows,
+ "summary": {
+ "review_support": sum(1 for row in rows if row["verdict"] == "review_support"),
+ "mixed_review": sum(1 for row in rows if row["verdict"] == "mixed_review"),
+ "review_caution": sum(1 for row in rows if row["verdict"] == "review_caution"),
+ "neutral_review": sum(1 for row in rows if row["verdict"] == "neutral_review"),
+ },
+ "promotion_allowed": False,
+ "boundary": (
+ "Planetary condition verdicts expose doctrine review weights only; they do not assert outcomes, "
+ "events, or final predictive capability."
+ ),
+ }
+
+
+def _planetary_condition_natural_language_summary(
+ verdicts: dict[str, Any],
+ benefic_malefic_verdicts: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ rows = verdicts.get("rows") if isinstance(verdicts.get("rows"), list) else []
+ support = [row for row in rows if row.get("verdict") == "review_support"]
+ mixed = [row for row in rows if row.get("verdict") == "mixed_review"]
+ caution = [row for row in rows if row.get("verdict") == "review_caution"]
+ neutral = [row for row in rows if row.get("verdict") == "neutral_review"]
+ testimony_summary = (
+ benefic_malefic_verdicts.get("summary")
+ if isinstance(benefic_malefic_verdicts, dict) and isinstance(benefic_malefic_verdicts.get("summary"), dict)
+ else {}
+ )
+ return {
+ "schema": "classical.planetary_condition_natural_language_summary.v1",
+ "status": verdicts.get("status", "blocked"),
+ "zh": (
+ f"行星条件层已逐星审查:{len(support)} 颗偏支持、{len(mixed)} 颗混合、"
+ f"{len(caution)} 颗偏警戒、{len(neutral)} 颗中性。它解释 dignity、sect、宫位动能与速度状态,"
+ f"并附带 bonification/maltreatment 近相位证词:支持 {testimony_summary.get('bonification_review_support', 0)}、"
+ f"混合 {testimony_summary.get('mixed_bonification_maltreatment_review', 0)}、警戒 {testimony_summary.get('maltreatment_review_caution', 0)}。"
+ "这些只用于 doctrine review,不产生具体事件或最终预测。"
+ ),
+ "en": (
+ f"Planetary condition is reviewed planet by planet: {len(support)} support, {len(mixed)} mixed, "
+ f"{len(caution)} caution, {len(neutral)} neutral. It explains dignity, sect, house motion, and speed, "
+ f"with close bonification/maltreatment testimony: {testimony_summary.get('bonification_review_support', 0)} support, "
+ f"{testimony_summary.get('mixed_bonification_maltreatment_review', 0)} mixed, "
+ f"{testimony_summary.get('maltreatment_review_caution', 0)} caution. It remains doctrine-review evidence "
+ "rather than an event or final prediction claim."
+ ),
+ "support_planets": [row.get("planet") for row in support],
+ "mixed_planets": [row.get("planet") for row in mixed],
+ "caution_planets": [row.get("planet") for row in caution],
+ "neutral_planets": [row.get("planet") for row in neutral],
+ "bonification_maltreatment_summary": testimony_summary,
+ "promotion_allowed": False,
+ "reader_boundary": (
+ "This natural-language summary explains condition verdicts only; it must not be rendered as final "
+ "prediction-ready capability."
+ ),
+ }
+
+
+def _score_point_for_almutens(longitude: float, sect: dict[str, Any]) -> dict[str, int]:
+ point = _point(longitude)
+ sign = point["sign"]
+ element = _ELEMENTS[sign]
+ degree = float(point["degree_in_sign"])
+ triplicity = _TRIPLICITIES[element]
+ scores = {planet: 0 for planet in TRADITIONAL_PLANETS}
+ scores[_RULERS[sign]] += 5
+ exaltation_lord = next((planet for planet, exaltation_sign in _EXALTATIONS.items() if exaltation_sign == sign), None)
+ if exaltation_lord:
+ scores[exaltation_lord] += 4
+ scores[triplicity[sect["chart_sect"]]] += 3
+ scores[triplicity["participating"]] += 1
+ scores[_bound_lord(sign, degree)] += 2
+ scores[_face_lord(longitude)] += 1
+ return scores
+
+
+def _almutens(chart: dict[str, Any], lots: dict[str, Any], sect: dict[str, Any]) -> dict[str, Any]:
+ raw_points = {
+ "ascendant": chart["natal"]["angles"]["ascendant"],
+ "mc": chart["natal"]["angles"]["mc"],
+ "sun": chart["natal"]["planets"]["sun"],
+ "moon": chart["natal"]["planets"]["moon"],
+ "fortune": lots["fortune"],
+ "spirit": lots["spirit"],
+ }
+ points: dict[str, Any] = {}
+ for name, point in raw_points.items():
+ scores = _score_point_for_almutens(point["longitude"], sect)
+ winner = sorted(scores, key=lambda planet: (-scores[planet], TRADITIONAL_PLANETS.index(planet)))[0]
+ points[name] = {
+ "longitude": point["longitude"],
+ "sign": point["sign"],
+ "winner": winner,
+ "scores": scores,
+ }
+ return {
+ "status": "executed",
+ "method": "ruler/exaltation/triplicity/bound/face point scoring",
+ "points": points,
+ "boundary": "Key-point almuten scoring only; full compound medieval almuten calculation remains a later adjudication layer.",
+ }
+
+
+def _release_adjudication(
+ *,
+ birth: dict[str, Any],
+ reference_date: str | None,
+ chart: dict[str, Any],
+ lots: dict[str, Any],
+ condition: dict[str, Any],
+) -> dict[str, Any]:
+ if not reference_date:
+ return {"status": "blocked", "reason": "reference_date is required"}
+ raw = calculate_zodiacal_release(target_date=reference_date, lot="spirit", levels=2, **birth)
+ fortune_raw = calculate_zodiacal_release(target_date=reference_date, lot="fortune", levels=2, **birth)
+ active = raw.get("active_period") if isinstance(raw.get("active_period"), dict) else None
+ if not active:
+ return {
+ "status": "blocked",
+ "reason": "no active L1 period was found for reference_date",
+ "raw_result": raw,
+ }
+ active_l2 = next(
+ (
+ subperiod
+ for subperiod in active.get("subperiods", [])
+ if subperiod["start_date"] <= reference_date <= subperiod["end_date"]
+ ),
+ None,
+ )
+ lord = _RULERS[active["sign"]]
+ l2_lord = _RULERS[active_l2["sign"]] if active_l2 else None
+ opposite_sign = _SIGNS[(_SIGNS.index(active["sign"]) + 6) % 12]
+ l2_opposition_detected = bool(active_l2 and active_l2.get("sign") == opposite_sign)
+
+ def _release_snapshot(lot_name: str, release_raw: dict[str, Any]) -> dict[str, Any]:
+ release_active = release_raw.get("active_period") if isinstance(release_raw.get("active_period"), dict) else None
+ if not release_active:
+ return {
+ "status": "blocked",
+ "lot": lot_name,
+ "reason": f"no active {lot_name} L1 period was found for reference_date",
+ }
+ release_l2 = next(
+ (
+ subperiod
+ for subperiod in release_active.get("subperiods", [])
+ if subperiod["start_date"] <= reference_date <= subperiod["end_date"]
+ ),
+ None,
+ )
+ release_l1_lord = _RULERS[release_active["sign"]]
+ release_l2_lord = _RULERS[release_l2["sign"]] if release_l2 else None
+ release_opposite_sign = _SIGNS[(_SIGNS.index(release_active["sign"]) + 6) % 12]
+ lot_ruler = lots[lot_name]["ruler"]
+ return {
+ "status": "partial",
+ "lot": lot_name,
+ "topic_family": (
+ "action, intention, agency, career/reputation review"
+ if lot_name == "spirit"
+ else "body, circumstance, material condition review"
+ ),
+ "active_l1_sign": release_active["sign"],
+ "active_l1_lord": release_l1_lord,
+ "active_l1_start_date": release_active.get("start_date"),
+ "active_l1_end_date": release_active.get("end_date"),
+ "active_l2_sign": release_l2.get("sign") if release_l2 else None,
+ "active_l2_lord": release_l2_lord,
+ "active_l2_start_date": release_l2.get("start_date") if release_l2 else None,
+ "active_l2_end_date": release_l2.get("end_date") if release_l2 else None,
+ "active_l2_opposes_active_l1": bool(release_l2 and release_l2.get("sign") == release_opposite_sign),
+ "lot_ruler": lot_ruler,
+ "lot_ruler_condition": condition[lot_ruler],
+ "boundary": "Side-by-side release snapshot only; this is not a calibrated event-prediction rule.",
+ }
+
+ spirit_release_snapshot = _release_snapshot("spirit", raw)
+ fortune_release_snapshot = _release_snapshot("fortune", fortune_raw)
+ cross_lot_alignment = {
+ "status": "partial",
+ "same_l1_sign": (
+ spirit_release_snapshot.get("active_l1_sign") == fortune_release_snapshot.get("active_l1_sign")
+ if fortune_release_snapshot["status"] == "partial"
+ else None
+ ),
+ "same_l1_lord": (
+ spirit_release_snapshot.get("active_l1_lord") == fortune_release_snapshot.get("active_l1_lord")
+ if fortune_release_snapshot["status"] == "partial"
+ else None
+ ),
+ "same_l2_sign": (
+ spirit_release_snapshot.get("active_l2_sign") == fortune_release_snapshot.get("active_l2_sign")
+ if fortune_release_snapshot["status"] == "partial"
+ else None
+ ),
+ "same_l2_lord": (
+ spirit_release_snapshot.get("active_l2_lord") == fortune_release_snapshot.get("active_l2_lord")
+ if fortune_release_snapshot["status"] == "partial"
+ else None
+ ),
+ "spirit_topic": spirit_release_snapshot.get("topic_family"),
+ "fortune_topic": fortune_release_snapshot.get("topic_family"),
+ "boundary": "Alignment strengthens review prominence only after frozen-case calibration; it cannot approve prediction-ready claims.",
+ }
+ cross_lot_release_review = {
+ "schema": "classical.zr_cross_lot_release_review.v1",
+ "status": "partial",
+ "release_snapshots": {
+ "spirit": spirit_release_snapshot,
+ "fortune": fortune_release_snapshot,
+ },
+ "cross_lot_alignment": cross_lot_alignment,
+ "cross_release_rows": [
+ {
+ "family": "spirit_release",
+ "status": spirit_release_snapshot["status"],
+ "selected_lot": "spirit",
+ "active_l1_sign": spirit_release_snapshot.get("active_l1_sign"),
+ "active_l1_lord": spirit_release_snapshot.get("active_l1_lord"),
+ "active_l2_sign": spirit_release_snapshot.get("active_l2_sign"),
+ "active_l2_lord": spirit_release_snapshot.get("active_l2_lord"),
+ "loosing_candidate": spirit_release_snapshot.get("active_l2_opposes_active_l1"),
+ "topic_family": spirit_release_snapshot.get("topic_family"),
+ },
+ {
+ "family": "fortune_release",
+ "status": fortune_release_snapshot["status"],
+ "selected_lot": "fortune",
+ "active_l1_sign": fortune_release_snapshot.get("active_l1_sign"),
+ "active_l1_lord": fortune_release_snapshot.get("active_l1_lord"),
+ "active_l2_sign": fortune_release_snapshot.get("active_l2_sign"),
+ "active_l2_lord": fortune_release_snapshot.get("active_l2_lord"),
+ "loosing_candidate": fortune_release_snapshot.get("active_l2_opposes_active_l1"),
+ "topic_family": fortune_release_snapshot.get("topic_family"),
+ },
+ ],
+ "calibration_gate": {
+ "status": "blocked",
+ "required_to_promote": [
+ "frozen positive and negative event cases with Spirit/Fortune release snapshots",
+ "independent reviewer evidence that labels were not visible to the scoring runner",
+ "domain-specific minimum sample counts for career, relationship, health, and material-condition topics",
+ "false-positive and accuracy thresholds met after unseal",
+ ],
+ },
+ "promotion_allowed": False,
+ "boundary": "Fortune/Spirit releases are now executed side by side for review; cross-lot evidence remains uncalibrated and cannot become final prediction authority.",
+ }
+ loosing_variants = {
+ "active_l2_opposes_active_l1": {
+ "status": "partial",
+ "detected": l2_opposition_detected,
+ "source": "available L1/L2 sign scaffold",
+ },
+ "lot_specific_fortune_spirit_variants": {
+ "status": "partial",
+ "detected": bool(
+ spirit_release_snapshot.get("active_l2_opposes_active_l1")
+ or fortune_release_snapshot.get("active_l2_opposes_active_l1")
+ ),
+ "source": "side-by-side Spirit/Fortune L1/L2 release snapshots",
+ "boundary": "Lot-specific loosing evidence is exposed for doctrine review only; historical variant adjudication remains unclosed.",
+ },
+ "historical_rule_variant_matrix": {
+ "status": "partial",
+ "variants": [
+ "opposition-to-period-sign",
+ "level-specific loosing marker",
+ "peak/angular support marker",
+ ],
+ "boundary": "Variant names are exposed for audit; only active L2 opposition is calculated in this layer.",
+ },
+ }
+ loosing_of_bond = {
+ "status": "partial",
+ "method": "detect active L2 sign opposite the active L1 sign in the available L1/L2 scaffold",
+ "active_l1_sign": active["sign"],
+ "opposite_sign": opposite_sign,
+ "active_l2_sign": active_l2.get("sign") if active_l2 else None,
+ "detected": l2_opposition_detected,
+ "rule_variants": loosing_variants,
+ "boundary": "Sign-level detection only; historical lots, angular variants, and full Hellenistic loosing rules remain guarded.",
+ }
+ angular_signs = {
+ row["cusp"]["sign"]
+ for row in chart["natal"]["houses"]
+ if int(row["house"]) in {1, 4, 7, 10}
+ }
+ peak_candidates = []
+ for period in raw.get("periods", []):
+ sign = period.get("sign")
+ if sign in angular_signs:
+ period_lord = _RULERS[sign]
+ lord_condition = condition[period_lord]
+ peak_weight = 1
+ if lord_condition["house_motion"] == "angular":
+ peak_weight += 1
+ if int(lord_condition["dignity_score"]) > 0:
+ peak_weight += 1
+ if lord_condition["sect_status"] == "of_sect":
+ peak_weight += 1
+ peak_candidates.append({
+ "level": period.get("level"),
+ "sign": sign,
+ "lord": period_lord,
+ "start_date": period.get("start_date"),
+ "end_date": period.get("end_date"),
+ "peak_weight": peak_weight,
+ "lord_condition_snapshot": {
+ "house_motion": lord_condition["house_motion"],
+ "sect_status": lord_condition["sect_status"],
+ "dignity_score": lord_condition["dignity_score"],
+ },
+ "basis": "period sign is on a natal angular house cusp",
+ })
+ peak_candidates = sorted(peak_candidates, key=lambda row: (-row["peak_weight"], str(row["start_date"])))
+ peak_rule_matrix = {
+ "angular_cusp_sign_overlap": {
+ "status": "partial",
+ "candidate_count": len(peak_candidates),
+ },
+ "lord_condition_weighting": {
+ "status": "partial",
+ "weights": ["angular_lord", "positive_dignity_score", "of_sect"],
+ },
+ "event_prominence_adjudication": {
+ "status": "blocked",
+ "reason": "requires doctrine-specific peak rules and replay calibration before interpretive promotion",
+ },
+ }
+ top_peak_candidate = peak_candidates[0] if peak_candidates else None
+ sect_light = "sun" if _is_day_chart(chart) else "moon"
+ fortune_ruler = lots["fortune"]["ruler"]
+ spirit_ruler = lots["spirit"]["ruler"]
+ release_lord_topic_arbitration = {
+ "schema": "classical.zr_release_lord_topic_arbitration.v1",
+ "status": "partial",
+ "release_lords": {
+ "l1": lord,
+ "l2": l2_lord,
+ "lot_selected": "spirit",
+ "lot_ruler": spirit_ruler,
+ },
+ "sect_light": {
+ "planet": sect_light,
+ "condition": condition[sect_light],
+ "supports_spirit_topic": sect_light == "sun",
+ "supports_fortune_topic": sect_light == "moon",
+ },
+ "fortune_spirit_topic_arbitration": {
+ "primary_topic": "spirit",
+ "spirit_topic": "action, intention, career/reputation review",
+ "fortune_topic": "body, circumstance, material condition review",
+ "spirit_ruler": spirit_ruler,
+ "fortune_ruler": fortune_ruler,
+ "same_ruler": spirit_ruler == fortune_ruler,
+ "release_alignment": cross_lot_alignment,
+ "boundary": "Topic arbitration identifies which Lot family is being reviewed; it does not convert ZR into an event prediction.",
+ },
+ "loosing_rule_family": {
+ "status": "partial",
+ "active_l2_opposes_l1": l2_opposition_detected,
+ "detected_variant": "active_l2_opposes_active_l1" if l2_opposition_detected else None,
+ "boundary": "Only the available L1/L2 opposition variant is calculated.",
+ },
+ "peak_lord_profection_arbitration": {
+ "status": "partial" if top_peak_candidate else "blocked",
+ "active_lord": lord,
+ "l2_lord": l2_lord,
+ "top_peak_lord": top_peak_candidate.get("lord") if top_peak_candidate else None,
+ "release_lord_matches_top_peak_lord": lord == top_peak_candidate.get("lord") if top_peak_candidate else None,
+ "l2_lord_matches_top_peak_lord": l2_lord == top_peak_candidate.get("lord") if top_peak_candidate and l2_lord else None,
+ "boundary": "Peak-lord arbitration is rank-order evidence only; annual profection cross-check is surfaced in replay gates when reference data is present.",
+ },
+ "verdict": (
+ "zr_topic_peak_loosing_review"
+ if l2_opposition_detected or bool(top_peak_candidate and top_peak_candidate.get("peak_weight", 0) >= 3)
+ else "zr_topic_background_review"
+ ),
+ "promotion_allowed": False,
+ "source_loci": _source_loci("zr", "lots"),
+ "cannot_promote_reason": (
+ "ZR release-lord and topic arbitration are doctrine-review evidence only. Full Fortune/Spirit cross-release, level-specific loosing variants, "
+ "annual profection arbitration, and blind case calibration are still required before event-prominence promotion."
+ ),
+ }
+ zr_matrix_rows = [
+ {
+ "factor": "active_l2_loosing_opposition",
+ "status": "partial",
+ "evidence": l2_opposition_detected,
+ "supports_prominence": l2_opposition_detected,
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "active_l1_peak",
+ "status": "partial",
+ "evidence": active["sign"] in angular_signs,
+ "supports_prominence": active["sign"] in angular_signs,
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "active_l2_peak",
+ "status": "partial" if active_l2 else "blocked",
+ "evidence": bool(active_l2 and active_l2.get("sign") in angular_signs),
+ "supports_prominence": bool(active_l2 and active_l2.get("sign") in angular_signs),
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "active_lord_angularity",
+ "status": "partial",
+ "evidence": condition[lord]["house_motion"],
+ "supports_prominence": condition[lord]["house_motion"] == "angular",
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "active_lord_dignity",
+ "status": "partial",
+ "evidence": condition[lord]["dignity_score"],
+ "supports_prominence": int(condition[lord]["dignity_score"]) > 0,
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "lot_ruler_condition",
+ "status": "partial",
+ "evidence": condition[lots["spirit"]["ruler"]]["house_motion"],
+ "supports_prominence": condition[lots["spirit"]["ruler"]]["house_motion"] == "angular",
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "fortune_spirit_cross_lot_alignment",
+ "status": "partial" if fortune_release_snapshot["status"] == "partial" else "blocked",
+ "evidence": cross_lot_alignment,
+ "supports_prominence": bool(
+ cross_lot_alignment.get("same_l1_lord") or cross_lot_alignment.get("same_l2_lord")
+ ),
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "top_peak_candidate_weight",
+ "status": "partial" if top_peak_candidate else "blocked",
+ "evidence": top_peak_candidate.get("peak_weight") if top_peak_candidate else None,
+ "supports_prominence": bool(top_peak_candidate and top_peak_candidate.get("peak_weight", 0) >= 3),
+ "blocks_promotion": False,
+ },
+ {
+ "factor": "event_prominence_gate",
+ "status": "blocked",
+ "evidence": "requires replay calibration",
+ "supports_prominence": False,
+ "blocks_promotion": True,
+ },
+ ]
+ active_period = {
+ **active,
+ "lord": lord,
+ "active_l2_period": ({**active_l2, "lord": l2_lord} if active_l2 else None),
+ "lot_ruler": lots["spirit"]["ruler"],
+ }
+ return {
+ "status": "guarded",
+ "raw_result": raw,
+ "cross_lot_raw_results": {
+ "spirit": raw,
+ "fortune": fortune_raw,
+ },
+ "active_period": active_period,
+ "period_condition": {
+ "lord": lord,
+ "lord_condition": condition[lord],
+ "l2_lord": l2_lord,
+ "l2_lord_condition": condition[l2_lord] if l2_lord else None,
+ "lot_ruler_condition": condition[lots["spirit"]["ruler"]],
+ },
+ "loosing_of_bond": loosing_of_bond,
+ "release_lord_topic_arbitration": release_lord_topic_arbitration,
+ "cross_lot_release_review": cross_lot_release_review,
+ "peak_periods": {
+ "status": "partial",
+ "method": "natal angular cusp sign overlap with conservative lord-condition weighting",
+ "angular_signs": sorted(angular_signs, key=_SIGNS.index),
+ "active_l1_is_peak": active["sign"] in angular_signs,
+ "active_l2_is_peak": bool(active_l2 and active_l2.get("sign") in angular_signs),
+ "candidates": peak_candidates,
+ "top_candidate_review": (
+ {
+ "status": "partial",
+ "sign": top_peak_candidate["sign"],
+ "lord": top_peak_candidate["lord"],
+ "peak_weight": top_peak_candidate["peak_weight"],
+ "basis": top_peak_candidate["basis"],
+ "boundary": "Top peak candidate is rank-order evidence only; it is not an event-prominence claim.",
+ }
+ if top_peak_candidate
+ else {"status": "blocked", "reason": "no angular-sign peak candidates were available"}
+ ),
+ "rule_matrix": peak_rule_matrix,
+ "boundary": "Peak marking and peak_weight are evidence only; they do not infer event prominence by themselves.",
+ },
+ "adjudication_matrix": {
+ "status": "partial",
+ "rows": zr_matrix_rows,
+ "support_count": sum(1 for row in zr_matrix_rows if row["supports_prominence"] is True),
+ "blocked_count": sum(1 for row in zr_matrix_rows if row["blocks_promotion"] is True),
+ "promotion_status": "blocked",
+ "boundary": "ZR matrix organizes loosing/peak/lord-condition evidence only; it does not promote event claims.",
+ },
+ "audit": {
+ "l1_l2_scaffold": {"status": "executed", "source": "scripts/western_timing_engine.py"},
+ "active_l1_period": {"status": "executed"},
+ "active_l2_period": {"status": "executed" if active_l2 else "blocked"},
+ "loosing_of_bond": {"status": "partial", "source": "active L1/L2 sign opposition detector"},
+ "loosing_rule_variants": {"status": "partial", "source": "guarded variant matrix"},
+ "peak_periods": {"status": "partial", "source": "natal angular cusp sign overlap plus lord-condition weighting"},
+ "release_lord_topic_arbitration": {"status": "partial", "source": "release lords + sect light + Fortune/Spirit topic arbitration"},
+ "peak_rule_matrix": {"status": "partial", "source": "guarded peak weighting matrix"},
+ "cross_lot_release_review": {"status": "partial", "source": "side-by-side Spirit/Fortune release snapshots"},
+ "release_adjudication_matrix": {"status": "partial", "source": "loosing/peak/lord-condition support matrix"},
+ },
+ "boundary": "Guarded release support only; active-period condition does not infer events or certainty.",
+ }
+
+
+_THEME_HOUSES = {
+ "career": (10, 1),
+ "career_status": (10, 1),
+ "relationship": (7, 1),
+ "marriage": (7, 1),
+ "legal_marriage": (7, 1),
+ "finance": (2, 11),
+ "wealth": (2, 11),
+}
+_HORARY_HOUSE_KEYWORDS = (
+ (("career", "job", "work", "project", "ship", "launch", "status"), 10),
+ (("marry", "marriage", "relationship", "partner", "love"), 7),
+ (("money", "wealth", "income", "payment", "finance"), 2),
+ (("home", "house", "property", "family"), 4),
+)
+_ELECTIONAL_GOAL_WEIGHTS = {
+ 10: {
+ "required_hits": ("goal_lord_angular", "goal_lord_direct"),
+ "preferred_hits": ("moon_next_aspect_to_benefic", "moon_next_aspect_not_to_malefic"),
+ "topic": "career_launch_status",
+ },
+ 7: {
+ "required_hits": ("goal_lord_direct", "moon_next_aspect_to_benefic"),
+ "preferred_hits": ("goal_lord_angular", "moon_next_aspect_not_to_malefic"),
+ "topic": "relationship_contract",
+ },
+ 2: {
+ "required_hits": ("goal_lord_direct", "moon_next_aspect_not_to_malefic"),
+ "preferred_hits": ("goal_lord_angular", "moon_next_aspect_to_benefic"),
+ "topic": "finance_payment",
+ },
+ 4: {
+ "required_hits": ("goal_lord_angular", "moon_next_aspect_not_to_malefic"),
+ "preferred_hits": ("goal_lord_direct", "moon_next_aspect_to_benefic"),
+ "topic": "home_property",
+ },
+}
+_ELECTIONAL_CAUTION_WEIGHTS = {
+ 10: {
+ "topic": "career_launch_status",
+ "weights": {
+ "moon_next_aspect_to_malefic": 2,
+ "goal_lord_contrary_to_sect": 2,
+ "goal_lord_combust_within_8_degrees": 2,
+ "moon_void_like_no_major_application_within_sign": 1,
+ "angular_malefics": 1,
+ },
+ },
+ 7: {
+ "topic": "relationship_contract",
+ "weights": {
+ "moon_next_aspect_to_malefic": 2,
+ "goal_lord_contrary_to_sect": 1,
+ "goal_lord_combust_within_8_degrees": 2,
+ "moon_void_like_no_major_application_within_sign": 2,
+ "angular_malefics": 1,
+ },
+ },
+ 2: {
+ "topic": "finance_payment",
+ "weights": {
+ "moon_next_aspect_to_malefic": 2,
+ "goal_lord_contrary_to_sect": 1,
+ "goal_lord_combust_within_8_degrees": 2,
+ "moon_void_like_no_major_application_within_sign": 1,
+ "angular_malefics": 2,
+ },
+ },
+ 4: {
+ "topic": "home_property",
+ "weights": {
+ "moon_next_aspect_to_malefic": 1,
+ "goal_lord_contrary_to_sect": 1,
+ "goal_lord_combust_within_8_degrees": 2,
+ "moon_void_like_no_major_application_within_sign": 2,
+ "angular_malefics": 2,
+ },
+ },
+}
+
+
+_ZR_EVENT_PROMINENCE_RULE_TABLE = {
+ "status": "partial",
+ "table_id": "classical_zr_event_prominence_rule_table.v1",
+ "promotion_status": "blocked",
+ "thresholds": {
+ "minimum_support_count_for_review": 3,
+ "minimum_top_peak_weight_for_review": 3,
+ "hard_blockers": ("event_prominence_gate_blocked", "missing_blind_negative_control"),
+ },
+ "rules": [
+ {
+ "id": "loosing_of_bond_active_l2",
+ "weight": 2,
+ "supports_review_when": "active L2 sign opposes active L1 sign",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "active_period_peak",
+ "weight": 1,
+ "supports_review_when": "active L1 or active L2 sign is on a natal angular house cusp",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "period_lord_condition",
+ "weight": 1,
+ "supports_review_when": "period lord is angular, of sect, or dignity-positive",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "lot_ruler_condition",
+ "weight": 1,
+ "supports_review_when": "Lot of Spirit ruler is angular",
+ "doctrine_status": "partial",
+ },
+ ],
+ "boundary": "Rule table ranks event-prominence evidence for review only; it cannot promote prediction without blind validation.",
+}
+
+
+_HORARY_PERFECTION_TIMING_THRESHOLDS = {
+ "status": "partial",
+ "table_id": "classical_horary_perfection_timing_thresholds.v1",
+ "promotion_status": "blocked",
+ "thresholds": {
+ "close_perfection_degrees": 3,
+ "reviewable_perfection_degrees": 6,
+ "wide_perfection_degrees": 12,
+ "hard_blockers": ("no_significator_table", "prohibition_detected", "moon_void_like"),
+ },
+ "rules": [
+ {
+ "id": "direct_applying_aspect",
+ "supports_review_when": "querent significator applies to quesited significator within reviewable orb",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "prohibition_screen",
+ "blocks_review_when": "querent reaches another planet before quesited",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "translation_collection_screen",
+ "supports_review_when": "a mediator links both significators by applying aspects",
+ "doctrine_status": "partial",
+ },
+ {
+ "id": "moon_bridge_screen",
+ "supports_review_when": "Moon applies to a significator before sign exit",
+ "doctrine_status": "partial",
+ },
+ ],
+ "boundary": "Timing thresholds are degree heuristics for audit review only; no yes/no or time-unit conversion is promoted.",
+}
+
+
+_ELECTIONAL_GOAL_RULE_TABLES = {
+ "status": "partial",
+ "table_id": "classical_electional_goal_rule_tables.v1",
+ "promotion_status": "blocked",
+ "minimum_candidate_review": {
+ "minimum_weighted_score_ratio": 0.6,
+ "maximum_weighted_caution_score": 1,
+ "hard_blockers": ("moon_void_like", "goal_lord_retrograde", "goal_lord_combust_within_8_degrees"),
+ },
+ "goals": _ELECTIONAL_GOAL_WEIGHTS,
+ "caution_weights": _ELECTIONAL_CAUTION_WEIGHTS,
+ "boundary": "Goal tables rank candidates for review only; they cannot approve an election or name a best time.",
+}
+_PTOLEMAIC_ASPECTS = {
+ "conjunction": 0,
+ "sextile": 60,
+ "square": 90,
+ "trine": 120,
+ "opposition": 180,
+}
+_PLANETARY_JOYS = {
+ "mercury": 1,
+ "moon": 3,
+ "venus": 5,
+ "mars": 6,
+ "sun": 9,
+ "jupiter": 11,
+ "saturn": 12,
+}
+_SIGN_MODALITIES = {
+ "Aries": "cardinal",
+ "Taurus": "fixed",
+ "Gemini": "mutable",
+ "Cancer": "cardinal",
+ "Leo": "fixed",
+ "Virgo": "mutable",
+ "Libra": "cardinal",
+ "Scorpio": "fixed",
+ "Sagittarius": "mutable",
+ "Capricorn": "cardinal",
+ "Aquarius": "fixed",
+ "Pisces": "mutable",
+}
+
+
+def _house_for_topic(text: str | None, default: int = 7) -> int:
+ normalized = str(text or "").lower()
+ for keywords, house in _HORARY_HOUSE_KEYWORDS:
+ if any(keyword in normalized for keyword in keywords):
+ return house
+ return default
+
+
+def _angular_distance(first: float, second: float) -> float:
+ distance = abs(_longitude(first) - _longitude(second)) % 360
+ return min(distance, 360 - distance)
+
+
+def _closest_ptolemaic_aspect(first: float, second: float) -> dict[str, Any]:
+ distance = _angular_distance(first, second)
+ name, degree = min(_PTOLEMAIC_ASPECTS.items(), key=lambda item: abs(distance - item[1]))
+ orb = round(abs(distance - degree), 6)
+ return {
+ "aspect": name,
+ "exact_degrees": degree,
+ "actual_distance": round(distance, 6),
+ "orb": orb,
+ "within_six_degree_orb": orb <= 6,
+ }
+
+
+def _orb_band(orb: float) -> str:
+ if orb <= 1:
+ return "exact"
+ if orb <= 3:
+ return "close"
+ if orb <= 6:
+ return "doctrine_reviewable"
+ if orb <= 12:
+ return "wide_background"
+ return "out_of_orb"
+
+
+def _whole_sign_aspect(first_sign: str, second_sign: str) -> dict[str, Any]:
+ first_index = _SIGNS.index(first_sign)
+ second_index = _SIGNS.index(second_sign)
+ zodiac_distance = (second_index - first_index) % 12
+ aspect_by_distance = {
+ 0: "conjunction",
+ 2: "sextile",
+ 3: "square",
+ 4: "trine",
+ 6: "opposition",
+ 8: "trine",
+ 9: "square",
+ 10: "sextile",
+ }
+ return {
+ "aspect": aspect_by_distance.get(zodiac_distance),
+ "zodiac_sign_distance": zodiac_distance,
+ "configured_model": "sign-to-sign",
+ "status": "executed" if zodiac_distance in aspect_by_distance else "not_configured_aspect",
+ }
+
+
+def _dexter_sinister(first_sign: str, second_sign: str) -> str:
+ distance = (_SIGNS.index(second_sign) - _SIGNS.index(first_sign)) % 12
+ if distance in {0, 6}:
+ return "conjunction_or_opposition"
+ if distance in {2, 3, 4}:
+ return "sinister"
+ if distance in {8, 9, 10}:
+ return "dexter"
+ return "no_ptolemaic_whole_sign_aspect"
+
+
+def _signed_aspect_error(first: float, second: float, aspect_degree: float) -> float:
+ distance = (_longitude(second) - _longitude(first)) % 360
+ candidates = [0.0] if aspect_degree == 0 else [aspect_degree, 360 - aspect_degree]
+ return min(
+ (((distance - candidate + 180) % 360) - 180 for candidate in candidates),
+ key=abs,
+ )
+
+
+def _aspect_motion_by_relative_speed(
+ first: dict[str, Any],
+ second: dict[str, Any],
+ aspect_name: str,
+ orb: float,
+) -> str:
+ if orb > 12:
+ return "out_of_orb"
+ aspect_degree = float(_PTOLEMAIC_ASPECTS.get(aspect_name, 0))
+ error = _signed_aspect_error(float(first["longitude"]), float(second["longitude"]), aspect_degree)
+ relative_speed = float(second.get("speed_longitude", 0)) - float(first.get("speed_longitude", 0))
+ if abs(error) <= 0.0001:
+ return "partile"
+ if abs(relative_speed) <= 0.0001:
+ return "stationary_or_static_unresolved"
+ return "applying" if error * relative_speed < 0 else "separating"
+
+
+def _aspect_doctrine_verdicts(chart: dict[str, Any]) -> dict[str, Any]:
+ rows: list[dict[str, Any]] = []
+ for first_planet in TRADITIONAL_PLANETS:
+ first = chart["natal"]["planets"][first_planet]
+ for second_planet in TRADITIONAL_PLANETS:
+ if first_planet == second_planet:
+ continue
+ second = chart["natal"]["planets"][second_planet]
+ degree_aspect = _closest_ptolemaic_aspect(first["longitude"], second["longitude"])
+ degree_aspect["orb_band"] = _orb_band(degree_aspect["orb"])
+ whole_sign = _whole_sign_aspect(first["sign"], second["sign"])
+ motion = _aspect_motion_by_relative_speed(
+ first,
+ second,
+ str(degree_aspect["aspect"]),
+ float(degree_aspect["orb"]),
+ )
+ if degree_aspect["within_six_degree_orb"] and whole_sign["aspect"] == degree_aspect["aspect"]:
+ verdict = "degree_and_whole_sign_agree_review"
+ elif degree_aspect["within_six_degree_orb"]:
+ verdict = "degree_only_review"
+ elif whole_sign["aspect"]:
+ verdict = "whole_sign_background_review"
+ else:
+ verdict = "no_configured_ptolemaic_testimony"
+ rows.append(
+ {
+ "from_planet": first_planet,
+ "to_planet": second_planet,
+ "status": "partial",
+ "degree_aspect": degree_aspect,
+ "whole_sign_aspect": whole_sign,
+ "applying_separating": motion,
+ "dexter_sinister": _dexter_sinister(first["sign"], second["sign"]),
+ "verdict": verdict,
+ "promotion_allowed": False,
+ "cannot_promote_reason": (
+ "Aspect doctrine is split into degree, whole-sign, dexter/sinister, and approximate applying testimony, "
+ "but historical speed, latitude, moiety orbs, and full topic arbitration remain uncalibrated."
+ ),
+ "source_loci": _source_loci("aspect"),
+ }
+ )
+ summary = {
+ "degree_and_whole_sign_agree_review": sum(1 for row in rows if row["verdict"] == "degree_and_whole_sign_agree_review"),
+ "degree_only_review": sum(1 for row in rows if row["verdict"] == "degree_only_review"),
+ "whole_sign_background_review": sum(1 for row in rows if row["verdict"] == "whole_sign_background_review"),
+ "no_configured_ptolemaic_testimony": sum(1 for row in rows if row["verdict"] == "no_configured_ptolemaic_testimony"),
+ "applying": sum(1 for row in rows if row["applying_separating"] == "applying"),
+ "separating": sum(1 for row in rows if row["applying_separating"] == "separating"),
+ "partile": sum(1 for row in rows if row["applying_separating"] == "partile"),
+ "stationary_or_static_unresolved": sum(1 for row in rows if row["applying_separating"] == "stationary_or_static_unresolved"),
+ }
+ return {
+ "schema": "classical.aspect_doctrine_verdicts.v1",
+ "status": "partial",
+ "row_count": len(rows),
+ "orb_hierarchy": {
+ "exact": "<= 1 degree",
+ "close": "<= 3 degrees",
+ "doctrine_reviewable": "<= 6 degrees",
+ "wide_background": "<= 12 degrees",
+ "out_of_orb": "> 12 degrees",
+ },
+ "rows": rows,
+ "summary": summary,
+ "promotion_allowed": False,
+ "boundary": (
+ "Aspect verdicts are review-only doctrine evidence. They refine Ptolemaic, whole-sign, applying, and dexter/sinister "
+ "testimony without claiming final prediction-ready judgment."
+ ),
+ }
+
+
+def _aspect_doctrine_natural_language_summary(verdicts: dict[str, Any]) -> dict[str, Any]:
+ summary = verdicts.get("summary", {})
+ row_count = int(verdicts.get("row_count") or 0)
+ close_count = int(summary.get("degree_and_whole_sign_agree_review") or 0) + int(summary.get("degree_only_review") or 0)
+ whole_sign_count = int(summary.get("whole_sign_background_review") or 0)
+ applying_count = int(summary.get("applying") or 0)
+ return {
+ "schema": "classical.aspect_doctrine_natural_language_summary.v1",
+ "status": "partial",
+ "zh": (
+ f"相位 doctrine 已拆成度数相位、整宫相位、顺逆手相位和 orb 层级;本盘共有 {row_count} 条方向性相位证据,"
+ f"其中 {close_count} 条进入近距离审查,{whole_sign_count} 条仅作为整宫背景,{applying_count} 条被近似标记为 applying。"
+ "由于 applying/separating 仍是静态经度近似,且未完成历史速度、纬度、moiety orb 与议题仲裁校准,只能作为 review-only 证据。"
+ ),
+ "en": (
+ f"Aspect doctrine is now split into degree aspects, whole-sign aspects, dexter/sinister direction, and an orb hierarchy. "
+ f"This chart exposes {row_count} directional aspect rows: {close_count} close review rows, {whole_sign_count} whole-sign background rows, "
+ f"and {applying_count} approximate applying rows. Because applying/separating remains longitude-only and speed, latitude, moiety orbs, "
+ "topic arbitration, and blind calibration are not closed, this stays review-only evidence."
+ ),
+ "promotion_allowed": False,
+ "boundary": "Natural-language aspect summary must not be upgraded into final predictive capability.",
+ }
+
+
+def _solar_visibility_status(planet: str, longitude: float, sun_longitude: float) -> dict[str, Any]:
+ if planet == "sun":
+ return {
+ "status": "solar_body",
+ "distance_from_sun": 0.0,
+ "solar_phase": "solar_body",
+ "caution_weight": 0,
+ }
+ distance = _angular_distance(longitude, sun_longitude)
+ zodiac_delta = (_longitude(longitude) - _longitude(sun_longitude)) % 360
+ if distance <= 17 / 60:
+ status = "cazimi_review_support"
+ caution_weight = 0
+ elif distance <= 8:
+ status = "combust_review_caution"
+ caution_weight = 3
+ elif distance <= 15:
+ status = "under_beams_review_caution"
+ caution_weight = 2
+ else:
+ status = "visible_background"
+ caution_weight = 0
+ return {
+ "status": status,
+ "distance_from_sun": round(distance, 6),
+ "solar_phase": "oriental" if zodiac_delta < 180 else "occidental",
+ "caution_weight": caution_weight,
+ }
+
+
+def _accidental_dignity_verdicts(chart: dict[str, Any], sect: dict[str, Any]) -> dict[str, Any]:
+ sun_longitude = chart["natal"]["planets"]["sun"]["longitude"]
+ rows: list[dict[str, Any]] = []
+ for planet in TRADITIONAL_PLANETS:
+ point = chart["natal"]["planets"][planet]
+ house = int(point["house"])
+ house_motion = _house_motion(house)
+ joy_house = _PLANETARY_JOYS[planet]
+ visibility = _solar_visibility_status(planet, point["longitude"], sun_longitude)
+ support_factors = []
+ caution_factors = []
+ if house_motion == "angular":
+ support_factors.append("angularity")
+ elif house_motion == "cadent":
+ caution_factors.append("cadency")
+ if house == joy_house:
+ support_factors.append("planetary_joy")
+ if visibility["status"] == "cazimi_review_support":
+ support_factors.append("cazimi")
+ elif visibility["status"] in {"combust_review_caution", "under_beams_review_caution"}:
+ caution_factors.append(visibility["status"])
+ if point.get("retrograde"):
+ caution_factors.append("retrograde")
+ if support_factors and caution_factors:
+ verdict = "mixed_accidental_review"
+ elif support_factors:
+ verdict = "accidental_support_review"
+ elif caution_factors:
+ verdict = "accidental_caution_review"
+ else:
+ verdict = "neutral_accidental_review"
+ rows.append(
+ {
+ "planet": planet,
+ "status": "partial",
+ "house": house,
+ "house_motion": house_motion,
+ "joy_house": joy_house,
+ "in_joy": house == joy_house,
+ "visibility": visibility,
+ "support_factors": support_factors,
+ "caution_factors": caution_factors,
+ "verdict": verdict,
+ "promotion_allowed": False,
+ "cannot_promote_reason": (
+ "Accidental dignity verdicts include angularity, planetary joy, visibility, solar phase, combustion, under-beams, "
+ "and cazimi checks, but do not yet close full house strength, hayz, station phase, latitude, or topic arbitration."
+ ),
+ "source_loci": _source_loci("dignity"),
+ }
+ )
+ return {
+ "schema": "classical.accidental_dignity_verdicts.v1",
+ "status": "partial",
+ "chart_sect": sect["chart_sect"],
+ "row_count": len(rows),
+ "rows": rows,
+ "summary": {
+ "accidental_support_review": sum(1 for row in rows if row["verdict"] == "accidental_support_review"),
+ "mixed_accidental_review": sum(1 for row in rows if row["verdict"] == "mixed_accidental_review"),
+ "accidental_caution_review": sum(1 for row in rows if row["verdict"] == "accidental_caution_review"),
+ "neutral_accidental_review": sum(1 for row in rows if row["verdict"] == "neutral_accidental_review"),
+ },
+ "promotion_allowed": False,
+ "boundary": "Accidental dignity is partial review evidence only; it cannot approve outcomes or prediction-ready status.",
+ }
+
+
+def _accidental_dignity_natural_language_summary(verdicts: dict[str, Any]) -> dict[str, Any]:
+ summary = verdicts.get("summary", {})
+ support = int(summary.get("accidental_support_review") or 0)
+ mixed = int(summary.get("mixed_accidental_review") or 0)
+ caution = int(summary.get("accidental_caution_review") or 0)
+ return {
+ "schema": "classical.accidental_dignity_natural_language_summary.v1",
+ "status": "partial",
+ "zh": (
+ f"偶然尊贵层已从单一 angularity 扩展到宫位强弱、planetary joy、可见性、太阳相位、under beams、combust 与 cazimi。"
+ f"本盘有 {support} 颗行星偏支持、{mixed} 颗混合、{caution} 颗偏警示;这些只能说明条件强弱,不能直接推出事件会发生。"
+ ),
+ "en": (
+ "Accidental dignity now covers house motion, planetary joy, visibility, solar phase, under beams, combustion, and cazimi. "
+ f"This chart has {support} support rows, {mixed} mixed rows, and {caution} caution rows. These indicate condition strength only, "
+ "not final event approval or prediction-ready capability."
+ ),
+ "promotion_allowed": False,
+ "boundary": "Natural-language accidental dignity summary remains review-only.",
+ }
+
+
+def _core_doctrine_explanation_panel(
+ *,
+ planetary_verdicts: dict[str, Any],
+ aspect_verdicts: dict[str, Any],
+ accidental_verdicts: dict[str, Any],
+ bonification_verdicts: dict[str, Any],
+ zr: dict[str, Any],
+) -> dict[str, Any]:
+ aspect_summary = aspect_verdicts.get("summary") if isinstance(aspect_verdicts.get("summary"), dict) else {}
+ accidental_summary = accidental_verdicts.get("summary") if isinstance(accidental_verdicts.get("summary"), dict) else {}
+ bonification_summary = (
+ bonification_verdicts.get("summary")
+ if isinstance(bonification_verdicts.get("summary"), dict)
+ else {}
+ )
+ zr_matrix = zr.get("adjudication_matrix") if isinstance(zr.get("adjudication_matrix"), dict) else {}
+ zr_arbitration = (
+ zr.get("release_lord_topic_arbitration")
+ if isinstance(zr.get("release_lord_topic_arbitration"), dict)
+ else {}
+ )
+ zr_peak = zr.get("peak_periods") if isinstance(zr.get("peak_periods"), dict) else {}
+ aspect_loci = _source_loci("aspect")
+ dignity_loci = _source_loci("dignity")
+ zr_loci = _source_loci("zr", "lots", "dignity")
+ rule_locus_highlights = {
+ "zodiacal_release": _trace_source_loci_summary(_source_loci("zr", rule_family="loosing_cross_lot")),
+ "horary_prohibition": _trace_source_loci_summary(_source_loci("horary", rule_family="horary_prohibition")),
+ "electional_cautions": _trace_source_loci_summary(_source_loci("electional", rule_family="electional_goal_specific_cautions")),
+ }
+ zr_component_explanations = [
+ {
+ "id": "release_lord",
+ "zh": "释放主星说明当前时期由哪颗星承载议题,但它只是时期主轴,不能单独推出具体事件。",
+ "en": "The release lord identifies the period carrier, but it is a period axis, not a standalone event prediction.",
+ "review_weight_only_reason": "Needs peak, lot topic, profection, and blind event calibration before event claims.",
+ },
+ {
+ "id": "peak_lord",
+ "zh": "峰值主星提高显著性审查优先级,但峰值不等于事件必然发生。",
+ "en": "Peak lord testimony raises prominence review priority, but a peak period is not an event guarantee.",
+ "review_weight_only_reason": "Needs negative controls distinguishing prominent non-events from true events.",
+ },
+ {
+ "id": "profection",
+ "zh": "年小限主星与释放/峰值主星相合时可加权,但只能作为仲裁支持。",
+ "en": "Annual profection agreement can add event-arbitration weight, but remains supporting testimony only, not a prediction claim.",
+ "review_weight_only_reason": "Needs domain-specific replay before it can change prediction status.",
+ },
+ {
+ "id": "lot_ruler_condition",
+ "zh": "福点/精神点主星状态帮助判断议题承载力,但仍需要跨 Lot 校准。",
+ "en": "Fortune/Spirit lot-ruler condition helps judge event-topic carrying power, but requires cross-lot calibration before prediction use.",
+ "review_weight_only_reason": "Needs Spirit/Fortune topic arbitration and real-case calibration.",
+ },
+ {
+ "id": "loosing_cross_lot",
+ "zh": "Loosing 与跨 Lot 一致性是高显著性提示,但不能跳过样本验证直接断事件。",
+ "en": "Loosing and cross-lot agreement are high-salience event flags, not permission to skip sample validation or promote prediction readiness.",
+ "review_weight_only_reason": "Needs Book IV loci review, cross-release replay, and blind validation.",
+ },
+ ]
+ return {
+ "schema": "classical.core_doctrine_explanation_panel.v1",
+ "status": "review_only",
+ "prediction_ready_allowed": False,
+ "source_loci_manifest": _source_loci_manifest(),
+ "rule_locus_highlights": rule_locus_highlights,
+ "zr_component_explanations": zr_component_explanations,
+ "families": [
+ {
+ "id": "planetary_condition",
+ "status": planetary_verdicts.get("status", "blocked"),
+ "row_count": planetary_verdicts.get("row_count", 0),
+ "source_loci": dignity_loci,
+ "source_loci_count": len(dignity_loci),
+ "zh": "行星条件按 dignity、sect、宫位动能、速度和吉凶星证词逐星分层,只能说明证据强弱。",
+ "en": "Planetary condition is split by dignity, sect, house motion, speed, and benefic/malefic testimony; it explains evidence weight only.",
+ "blocked_from_promotion": "Needs topic-specific doctrine, timing arbitration, and blind calibration before prediction-ready use.",
+ },
+ {
+ "id": "aspect_doctrine",
+ "status": aspect_verdicts.get("status", "blocked"),
+ "row_count": aspect_verdicts.get("row_count", 0),
+ "source_loci": aspect_loci,
+ "source_loci_count": len(aspect_loci),
+ "key_counts": {
+ "close_degree_rows": int(aspect_summary.get("degree_and_whole_sign_agree_review") or 0)
+ + int(aspect_summary.get("degree_only_review") or 0),
+ "whole_sign_background_rows": aspect_summary.get("whole_sign_background_review", 0),
+ "applying_rows": aspect_summary.get("applying", 0),
+ "separating_rows": aspect_summary.get("separating", 0),
+ "partile_rows": aspect_summary.get("partile", 0),
+ "static_or_unresolved_rows": aspect_summary.get("stationary_or_static_unresolved", 0),
+ },
+ "zh": "相位层已区分度数相位、整宫相位、orb 层级、dexter/sinister,并用相对速度判断 applying/separating。",
+ "en": "Aspect doctrine separates degree aspect, whole-sign aspect, orb hierarchy, dexter/sinister direction, and relative-speed applying/separating testimony.",
+ "blocked_from_promotion": "Historical speed, latitude, moiety orbs, and topic arbitration remain partial, so aspect rows stay review-only.",
+ },
+ {
+ "id": "accidental_dignity",
+ "status": accidental_verdicts.get("status", "blocked"),
+ "row_count": accidental_verdicts.get("row_count", 0),
+ "source_loci": dignity_loci,
+ "source_loci_count": len(dignity_loci),
+ "key_counts": accidental_summary,
+ "zh": "偶然尊贵层已加入 angularity、joy、可见性、太阳距离、under beams、combust 和 cazimi。",
+ "en": "Accidental dignity includes angularity, joy, visibility, solar distance, under-beams, combustion, and cazimi checks.",
+ "blocked_from_promotion": "Full house strength, hayz, station phase, latitude, and topic weighting remain unclosed.",
+ },
+ {
+ "id": "bonification_maltreatment",
+ "status": bonification_verdicts.get("status", "blocked"),
+ "row_count": bonification_verdicts.get("row_count", 0),
+ "source_loci": _source_loci("aspect", "reception"),
+ "source_loci_count": len(_source_loci("aspect", "reception")),
+ "key_counts": bonification_summary,
+ "zh": "吉化/凶害层记录与金木、火土的近相位证词,并保留 sect 权重。",
+ "en": "Bonification/maltreatment records close testimony from Venus/Jupiter and Mars/Saturn with sect-aware weight.",
+ "blocked_from_promotion": "It still requires reception priority, topic relevance, and calibrated outcome review.",
+ },
+ {
+ "id": "zodiacal_release",
+ "status": zr.get("status", "blocked"),
+ "row_count": len(zr_matrix.get("rows") or []),
+ "source_loci": zr_loci,
+ "source_loci_count": len(zr_loci),
+ "key_counts": {
+ "support_count": zr_matrix.get("support_count", 0),
+ "blocked_count": zr_matrix.get("blocked_count", 0),
+ "active_l1_is_peak": zr_peak.get("active_l1_is_peak"),
+ "active_l2_is_peak": zr_peak.get("active_l2_is_peak"),
+ "topic_arbitration_verdict": zr_arbitration.get("verdict"),
+ },
+ "zh": "ZR 层展示释放主星、峰值主星、Lot of Spirit/Fortune 议题和 loosing 线索,但仍只是事件显著性审查。",
+ "en": "ZR exposes release lords, peak lords, Spirit/Fortune topic arbitration, and loosing testimony, but remains event-prominence review only.",
+ "blocked_from_promotion": "Full cross-lot releases, profection arbitration, negative controls, and blind case calibration are still required.",
+ },
+ ],
+ "reader_boundary": (
+ "This panel explains classical doctrine evidence families. It must not be rendered as final prediction-ready, "
+ "horary yes/no, or electional best-time authority."
+ ),
+ }
+
+
+def _benefic_malefic_condition_verdicts(chart: dict[str, Any], sect: dict[str, Any]) -> dict[str, Any]:
+ benefics = {"venus", "jupiter"}
+ malefics = {"mars", "saturn"}
+ benefic_of_sect = sect["benefic_of_sect"]
+ malefic_contrary = sect["malefic_contrary_to_sect"]
+ rows: list[dict[str, Any]] = []
+ for planet in TRADITIONAL_PLANETS:
+ point = chart["natal"]["planets"][planet]
+ benefic_contacts = []
+ malefic_contacts = []
+ for witness in sorted(benefics | malefics):
+ if witness == planet:
+ continue
+ witness_point = chart["natal"]["planets"][witness]
+ aspect = _closest_ptolemaic_aspect(point["longitude"], witness_point["longitude"])
+ if not aspect["within_six_degree_orb"]:
+ continue
+ contact = {
+ "witness": witness,
+ "aspect": aspect["aspect"],
+ "orb": aspect["orb"],
+ "sect_weight": (
+ "stronger_benefic_of_sect" if witness == benefic_of_sect
+ else "stronger_malefic_contrary_to_sect" if witness == malefic_contrary
+ else "ordinary"
+ ),
+ }
+ if witness in benefics:
+ benefic_contacts.append(contact)
+ else:
+ malefic_contacts.append(contact)
+ if benefic_contacts and malefic_contacts:
+ verdict = "mixed_bonification_maltreatment_review"
+ elif benefic_contacts:
+ verdict = "bonification_review_support"
+ elif malefic_contacts:
+ verdict = "maltreatment_review_caution"
+ else:
+ verdict = "no_close_benefic_malefic_testimony"
+ rows.append(
+ {
+ "planet": planet,
+ "status": "partial",
+ "verdict": verdict,
+ "benefic_contacts": benefic_contacts,
+ "malefic_contacts": malefic_contacts,
+ "close_contact_orb_degrees": 6,
+ "promotion_allowed": False,
+ "cannot_promote_reason": (
+ "Bonification/maltreatment scan is a close-aspect testimony layer only; it needs full aspect doctrine, "
+ "reception priority, topic relevance, and blind calibration before prediction-ready use."
+ ),
+ }
+ )
+ return {
+ "schema": "classical.bonification_maltreatment_verdicts.v1",
+ "status": "partial",
+ "chart_sect": sect["chart_sect"],
+ "benefic_of_sect": benefic_of_sect,
+ "malefic_contrary_to_sect": malefic_contrary,
+ "row_count": len(rows),
+ "rows": rows,
+ "summary": {
+ "bonification_review_support": sum(1 for row in rows if row["verdict"] == "bonification_review_support"),
+ "mixed_bonification_maltreatment_review": sum(1 for row in rows if row["verdict"] == "mixed_bonification_maltreatment_review"),
+ "maltreatment_review_caution": sum(1 for row in rows if row["verdict"] == "maltreatment_review_caution"),
+ "no_close_benefic_malefic_testimony": sum(1 for row in rows if row["verdict"] == "no_close_benefic_malefic_testimony"),
+ },
+ "promotion_allowed": False,
+ "boundary": (
+ "Bonification/maltreatment verdicts are partial close-aspect testimony only; they do not assert outcomes "
+ "or final predictive capability."
+ ),
+ }
+
+
+def _reception_between(first: dict[str, Any], second_planet: str) -> dict[str, Any]:
+ sign = first["sign"]
+ degree = float(first.get("degree_in_sign") or 0.0)
+ exaltation_lord = next((planet for planet, exaltation in _EXALTATIONS.items() if exaltation == sign), None)
+ bound_lord = _bound_lord(sign, degree)
+ face_lord = _face_lord(first["longitude"])
+ return {
+ "by_rulership": _RULERS[sign] == second_planet,
+ "by_exaltation": exaltation_lord == second_planet,
+ "by_bound": bound_lord == second_planet,
+ "by_face": face_lord == second_planet,
+ "sign": sign,
+ "degree_in_sign": degree,
+ "bound_lord": bound_lord,
+ "face_lord": face_lord,
+ }
+
+
+def _reception_doctrine_verdict(receptions: dict[str, Any], *, context: str) -> dict[str, Any]:
+ rows: list[dict[str, Any]] = []
+ support_count = 0
+ parsed_relations: dict[tuple[str, str], bool] = {}
+ for relation, reception in receptions.items():
+ if not isinstance(reception, dict):
+ continue
+ methods = [
+ name
+ for name, active in (
+ ("rulership", reception.get("by_rulership")),
+ ("exaltation", reception.get("by_exaltation")),
+ ("bound", reception.get("by_bound")),
+ ("face", reception.get("by_face")),
+ )
+ if active
+ ]
+ method_weights = {"rulership": 5, "exaltation": 4, "bound": 2, "face": 1}
+ reception_weight = sum(method_weights[method] for method in methods)
+ priority = (
+ "major_reception" if any(method in methods for method in ("rulership", "exaltation"))
+ else "minor_reception" if methods
+ else "none"
+ )
+ relation_parts = relation.split("_received_by_")
+ if len(relation_parts) == 2:
+ parsed_relations[(relation_parts[0], relation_parts[1])] = bool(methods)
+ support_count += 1 if methods else 0
+ rows.append(
+ {
+ "relation": relation,
+ "status": "executed",
+ "received": bool(methods),
+ "methods": methods,
+ "reception_weight": reception_weight,
+ "priority": priority,
+ "debility_mitigation": (
+ "major_reception_can_mitigate_but_not_cancel_debility"
+ if priority == "major_reception"
+ else "minor_reception_can_only_add_context"
+ if priority == "minor_reception"
+ else "no_reception_mitigation"
+ ),
+ "verdict": "supports_review" if methods else "no_reception_seen",
+ }
+ )
+ mutual_pairs = []
+ for (first, second), active in parsed_relations.items():
+ if not active or not parsed_relations.get((second, first)):
+ continue
+ pair = tuple(sorted((first, second)))
+ if pair not in mutual_pairs:
+ mutual_pairs.append(pair)
+ major_count = sum(1 for row in rows if row.get("priority") == "major_reception")
+ minor_count = sum(1 for row in rows if row.get("priority") == "minor_reception")
+ if support_count >= 2:
+ verdict = "mutual_or_multi_reception_review_support"
+ elif support_count == 1:
+ verdict = "single_reception_review_support"
+ elif rows:
+ verdict = "no_reception_review_caution"
+ else:
+ verdict = "blocked"
+ return {
+ "schema": "classical.reception_doctrine_verdict.v1",
+ "status": "partial" if rows else "blocked",
+ "context": context,
+ "verdict": verdict,
+ "support_count": support_count,
+ "major_reception_count": major_count,
+ "minor_reception_count": minor_count,
+ "mutual_reception_pairs": [
+ {"first": first, "second": second, "priority": "mutual_reception_review_support"}
+ for first, second in mutual_pairs
+ ],
+ "mutual_reception_count": len(mutual_pairs),
+ "reception_priority_order": ["mutual rulership/exaltation", "single rulership/exaltation", "bound", "face"],
+ "debility_mitigation_rule": (
+ "Reception can mitigate debility testimony in review, especially by rulership or exaltation, but it cannot erase debility "
+ "or promote an outcome without aspect, condition, topic, and blind-case calibration."
+ ),
+ "row_count": len(rows),
+ "rows": rows,
+ "promotion_allowed": False,
+ "boundary": (
+ "Reception verdict checks rulership, exaltation, bound, and face reception; it supports doctrine review but "
+ "does not complete horary, mediation, timing, or prediction judgment."
+ ),
+ }
+
+
+def _moon_next_aspect(chart: dict[str, Any]) -> dict[str, Any]:
+ moon = chart["natal"]["planets"]["moon"]
+ candidates = []
+ for planet in TRADITIONAL_PLANETS:
+ if planet == "moon":
+ continue
+ point = chart["natal"]["planets"][planet]
+ for aspect_name, aspect_degree in _PTOLEMAIC_ASPECTS.items():
+ targets = {0} if aspect_degree == 0 else {aspect_degree, 360 - aspect_degree}
+ for offset in targets:
+ travel = (_longitude(point["longitude"] + offset) - moon["longitude"]) % 360
+ if travel <= 0.0001:
+ continue
+ candidates.append({
+ "planet": planet,
+ "aspect": aspect_name,
+ "moon_travel_degrees": round(travel, 6),
+ "target_longitude": round(_longitude(point["longitude"] + offset), 6),
+ "planet_condition_boundary": "No latitude, prohibition, translation, collection, or speed perfection is adjudicated here.",
+ })
+ if not candidates:
+ return {"status": "blocked", "reason": "no future Moon aspect candidate found in simple longitude scan"}
+ nearest = min(candidates, key=lambda row: row["moon_travel_degrees"])
+ return {
+ "status": "partial",
+ **nearest,
+ "boundary": "Simple zodiacal longitude scan only; full applying aspect doctrine remains guarded.",
+ }
+
+
+def _moon_void_of_course(chart: dict[str, Any]) -> dict[str, Any]:
+ moon = chart["natal"]["planets"]["moon"]
+ remaining_in_sign = 30 - float(moon["degree_in_sign"])
+ future_aspects = [
+ row for row in _applying_aspect_candidates(chart, "moon")
+ if row["travel_degrees"] <= remaining_in_sign + 0.0001
+ ]
+ next_in_sign = future_aspects[0] if future_aspects else None
+ return {
+ "status": "partial",
+ "moon_sign": moon["sign"],
+ "moon_degree_in_sign": round(float(moon["degree_in_sign"]), 6),
+ "degrees_until_sign_exit": round(remaining_in_sign, 6),
+ "void_like_until_sign_exit": next_in_sign is None,
+ "next_aspect_before_sign_exit": next_in_sign,
+ "candidate_count_before_sign_exit": len(future_aspects),
+ "boundary": "Longitude-only Moon void-of-course approximation; speed, latitude, sign-bound doctrine, and historical variants remain guarded.",
+ }
+
+
+def _applying_aspect_candidates(chart: dict[str, Any], from_planet: str) -> list[dict[str, Any]]:
+ source = chart["natal"]["planets"][from_planet]
+ rows = []
+ for planet in TRADITIONAL_PLANETS:
+ if planet == from_planet:
+ continue
+ target = chart["natal"]["planets"][planet]
+ for aspect_name, aspect_degree in _PTOLEMAIC_ASPECTS.items():
+ targets = {0} if aspect_degree == 0 else {aspect_degree, 360 - aspect_degree}
+ for offset in targets:
+ travel = (_longitude(target["longitude"] + offset) - source["longitude"]) % 360
+ if travel <= 0.0001:
+ continue
+ rows.append({
+ "from": from_planet,
+ "to": planet,
+ "aspect": aspect_name,
+ "travel_degrees": round(travel, 6),
+ "target_longitude": round(_longitude(target["longitude"] + offset), 6),
+ })
+ return sorted(rows, key=lambda row: row["travel_degrees"])
+
+
+def _radicality_check(chart: dict[str, Any]) -> dict[str, Any]:
+ asc_degree = float(chart["natal"]["angles"]["ascendant"]["degree_in_sign"])
+ return {
+ "status": "partial",
+ "ascendant_degree_in_sign": asc_degree,
+ "early_ascendant": asc_degree < 3,
+ "late_ascendant": asc_degree > 27,
+ "readability_flag": "guarded" if asc_degree < 3 or asc_degree > 27 else "readable",
+ "boundary": "Ascendant-degree readability flag only; planetary-hour agreement and full radicality doctrine are not implemented.",
+ }
+
+
+def _event_local_datetime(event: dict[str, Any]) -> datetime:
+ timezone_value = event.get("timezone")
+ if timezone_value is None:
+ zone = ZoneInfo("UTC")
+ else:
+ try:
+ zone = fixed_timezone(timedelta(hours=float(timezone_value)))
+ except (TypeError, ValueError):
+ zone = ZoneInfo(str(timezone_value))
+ return datetime(
+ int(event["year"]),
+ int(event["month"]),
+ int(event["day"]),
+ int(event["hour"]),
+ int(event["minute"]),
+ int(event.get("second", 0)),
+ tzinfo=zone,
+ )
+
+
+def _planetary_hour_radicality(event: dict[str, Any], chart: dict[str, Any]) -> dict[str, Any]:
+ local = _event_local_datetime(event)
+ day_ruler = _WEEKDAY_DAY_RULERS[local.weekday()]
+ start_index = _CHALDEAN_ORDER.index(day_ruler)
+ hour_ruler = _CHALDEAN_ORDER[(start_index + local.hour) % len(_CHALDEAN_ORDER)]
+ asc_ruler = _RULERS[chart["natal"]["angles"]["ascendant"]["sign"]]
+ hour_condition = chart["natal"]["planets"][hour_ruler]
+ asc_element = _ELEMENTS[chart["natal"]["angles"]["ascendant"]["sign"]]
+ hour_element = _ELEMENTS[hour_condition["sign"]]
+ agreement = hour_ruler == asc_ruler or asc_element == hour_element
+ return {
+ "status": "partial",
+ "local_datetime": local.isoformat(),
+ "weekday_day_ruler": day_ruler,
+ "planetary_hour_ruler": hour_ruler,
+ "ascendant_ruler": asc_ruler,
+ "hour_ruler_sign": hour_condition["sign"],
+ "agreement": agreement,
+ "agreement_basis": "same ruler or same elemental triplicity between Ascendant sign and hour-ruler sign",
+ "boundary": "Clock-hour planetary ruler approximation only; true sunrise/sunset planetary hours are not implemented.",
+ }
+
+
+def _perfection_candidate(chart: dict[str, Any], querent: str, quesited: str) -> dict[str, Any]:
+ candidates = [
+ row for row in _applying_aspect_candidates(chart, querent)
+ if row["to"] == quesited
+ ]
+ if not candidates:
+ return {
+ "status": "blocked",
+ "reason": "no applying Ptolemaic aspect from querent significator to quesited significator was found",
+ }
+ nearest = candidates[0]
+ return {
+ "status": "partial",
+ **nearest,
+ "within_six_degree_orb": nearest["travel_degrees"] <= 6,
+ "boundary": "Direct applying longitude candidate only; no perfection timing, collection, translation, or dignity judgment is inferred.",
+ }
+
+
+def _application_separation_detail(chart: dict[str, Any], querent: str, quesited: str) -> dict[str, Any]:
+ querent_point = chart["natal"]["planets"][querent]
+ quesited_point = chart["natal"]["planets"][quesited]
+ closest = _closest_ptolemaic_aspect(querent_point["longitude"], quesited_point["longitude"])
+ applying = [
+ row for row in _applying_aspect_candidates(chart, querent)
+ if row["to"] == quesited
+ ]
+ separating = []
+ for aspect_name, aspect_degree in _PTOLEMAIC_ASPECTS.items():
+ targets = {0} if aspect_degree == 0 else {aspect_degree, 360 - aspect_degree}
+ for offset in targets:
+ past_travel = (querent_point["longitude"] - _longitude(quesited_point["longitude"] + offset)) % 360
+ if 0.0001 < past_travel <= 30:
+ separating.append({
+ "from": querent,
+ "to": quesited,
+ "aspect": aspect_name,
+ "past_degrees": round(past_travel, 6),
+ "exact_longitude": round(_longitude(quesited_point["longitude"] + offset), 6),
+ })
+ nearest_applying = applying[0] if applying else None
+ nearest_separating = sorted(separating, key=lambda row: row["past_degrees"])[0] if separating else None
+ return {
+ "status": "partial",
+ "closest_current_aspect": closest,
+ "nearest_applying": nearest_applying,
+ "nearest_separating": nearest_separating,
+ "motion_label": (
+ "applying" if nearest_applying and nearest_applying["travel_degrees"] <= 30
+ else "separating" if nearest_separating
+ else "wide_or_unresolved"
+ ),
+ "boundary": "Longitude-only applying/separating geometry; no speed, latitude, collection, or timing perfection doctrine is inferred.",
+ }
+
+
+def _prohibition_candidate(chart: dict[str, Any], querent: str, quesited: str, perfection: dict[str, Any]) -> dict[str, Any]:
+ if perfection.get("status") != "partial":
+ return {"status": "blocked", "reason": "no direct perfection candidate to compare"}
+ limit = float(perfection["travel_degrees"])
+ intervening = [
+ row for row in _applying_aspect_candidates(chart, querent)
+ if row["to"] != quesited and row["travel_degrees"] < limit
+ ]
+ return {
+ "status": "partial",
+ "detected": bool(intervening),
+ "candidate": intervening[0] if intervening else None,
+ "comparison_limit_degrees": limit,
+ "boundary": "Simple same-source intervening aspect scan only; no full prohibition doctrine is inferred.",
+ }
+
+
+def _translation_collection_candidate(chart: dict[str, Any], querent: str, quesited: str) -> dict[str, Any]:
+ rows = []
+ for mediator in TRADITIONAL_PLANETS:
+ if mediator in {querent, quesited}:
+ continue
+ to_querent = [
+ row for row in _applying_aspect_candidates(chart, mediator)
+ if row["to"] == querent
+ ]
+ to_quesited = [
+ row for row in _applying_aspect_candidates(chart, mediator)
+ if row["to"] == quesited
+ ]
+ if to_querent and to_quesited:
+ first = to_querent[0]
+ second = to_quesited[0]
+ span = round(max(first["travel_degrees"], second["travel_degrees"]), 6)
+ reception_snapshot = {
+ "status": "partial",
+ "mediator_received_by_querent": _reception_between(chart["natal"]["planets"][mediator], querent),
+ "mediator_received_by_quesited": _reception_between(chart["natal"]["planets"][mediator], quesited),
+ "querent_received_by_mediator": _reception_between(chart["natal"]["planets"][querent], mediator),
+ "quesited_received_by_mediator": _reception_between(chart["natal"]["planets"][quesited], mediator),
+ "boundary": "Rulership/exaltation reception snapshot only; no full collection or translation judgment is inferred.",
+ }
+ reception_snapshot["doctrine_verdict"] = _reception_doctrine_verdict(
+ {
+ key: value
+ for key, value in reception_snapshot.items()
+ if isinstance(value, dict)
+ },
+ context="translation_collection",
+ )
+ rows.append({
+ "mediator": mediator,
+ "pattern": "collection_or_translation_candidate",
+ "to_querent": first,
+ "to_quesited": second,
+ "span_degrees": span,
+ "strength_snapshot": {
+ "status": "partial",
+ "span_band": "tight" if span <= 6 else "moderate" if span <= 15 else "wide",
+ "mediator_house_motion": _house_motion(int(chart["natal"]["planets"][mediator]["house"])),
+ "mediator_speed_status": "retrograde" if chart["natal"]["planets"][mediator].get("retrograde") else "direct",
+ "mediator_sign": chart["natal"]["planets"][mediator]["sign"],
+ "boundary": "Strength snapshot uses span, mediator house motion, and direct/retrograde flag only; full speed, reception, and separation are not adjudicated.",
+ },
+ "reception_snapshot": reception_snapshot,
+ })
+ if not rows:
+ return {
+ "status": "blocked",
+ "reason": "no mediator applying to both significators was found in the simple longitude scan",
+ "boundary": "No denial is inferred; this only records absence in the current partial scan.",
+ }
+ nearest = sorted(rows, key=lambda row: row["span_degrees"])[0]
+ return {
+ "status": "partial",
+ **nearest,
+ "boundary": "Mediator candidate only; separating aspects, speed, reception, collection strength, and outcome doctrine remain guarded.",
+ }
+
+
+def _moon_significator_bridge(chart: dict[str, Any], querent: str, quesited: str) -> dict[str, Any]:
+ candidates = [
+ row for row in _applying_aspect_candidates(chart, "moon")
+ if row["to"] in {querent, quesited}
+ ]
+ if not candidates:
+ return {
+ "status": "blocked",
+ "reason": "Moon does not apply to either significator in the simple longitude scan",
+ "boundary": "No denial is inferred; this records only the current partial scan.",
+ }
+ nearest = candidates[0]
+ target = nearest["to"]
+ moon_point = chart["natal"]["planets"]["moon"]
+ reception_snapshot = {
+ "status": "partial",
+ "moon_received_by_target": _reception_between(moon_point, target),
+ "target_received_by_moon": _reception_between(chart["natal"]["planets"][target], "moon"),
+ "boundary": "Moon bridge reception snapshot only; no outcome or perfection judgment is inferred.",
+ }
+ reception_snapshot["doctrine_verdict"] = _reception_doctrine_verdict(
+ {
+ key: value
+ for key, value in reception_snapshot.items()
+ if isinstance(value, dict)
+ },
+ context="moon_significator_bridge",
+ )
+ return {
+ "status": "partial",
+ **nearest,
+ "supports": (
+ "querent" if nearest["to"] == querent
+ else "quesited" if nearest["to"] == quesited
+ else "unknown"
+ ),
+ "moon_condition_snapshot": {
+ "status": "partial",
+ "house_motion": _house_motion(int(moon_point["house"])),
+ "speed_status": "retrograde" if moon_point.get("retrograde") else "direct",
+ "sign": moon_point["sign"],
+ },
+ "reception_snapshot": reception_snapshot,
+ "boundary": "Moon-to-significator bridge only; separation, collection, translation, reception, and timing doctrine remain guarded.",
+ }
+
+
+def _horary_adjudication_matrix(
+ *,
+ radicality: dict[str, Any],
+ planetary_hour: dict[str, Any],
+ perfection: dict[str, Any],
+ application_detail: dict[str, Any],
+ prohibition: dict[str, Any],
+ mediation: dict[str, Any],
+ moon_bridge: dict[str, Any],
+ moon_void: dict[str, Any],
+) -> dict[str, Any]:
+ rows = [
+ {
+ "factor": "ascendant_readability",
+ "status": "partial",
+ "evidence": radicality.get("readability_flag"),
+ "supports_judgment": radicality.get("readability_flag") == "readable",
+ "blocks_judgment": radicality.get("readability_flag") == "guarded",
+ },
+ {
+ "factor": "planetary_hour_agreement",
+ "status": planetary_hour.get("status", "blocked"),
+ "evidence": planetary_hour.get("planetary_hour_ruler"),
+ "supports_judgment": planetary_hour.get("agreement") is True,
+ "blocks_judgment": False,
+ },
+ {
+ "factor": "direct_perfection_candidate",
+ "status": perfection.get("status", "blocked"),
+ "evidence": perfection.get("aspect"),
+ "supports_judgment": perfection.get("status") == "partial",
+ "blocks_judgment": perfection.get("status") == "blocked",
+ },
+ {
+ "factor": "application_separation",
+ "status": application_detail.get("status", "blocked"),
+ "evidence": application_detail.get("motion_label"),
+ "supports_judgment": application_detail.get("motion_label") == "applying",
+ "blocks_judgment": application_detail.get("motion_label") in {"separating", "wide_or_unresolved"},
+ },
+ {
+ "factor": "prohibition_screen",
+ "status": prohibition.get("status", "blocked"),
+ "evidence": prohibition.get("detected"),
+ "supports_judgment": prohibition.get("status") == "partial" and not prohibition.get("detected"),
+ "blocks_judgment": prohibition.get("detected") is True,
+ },
+ {
+ "factor": "translation_collection_candidate",
+ "status": mediation.get("status", "blocked"),
+ "evidence": mediation.get("mediator"),
+ "supports_judgment": mediation.get("status") == "partial",
+ "blocks_judgment": False,
+ },
+ {
+ "factor": "moon_significator_bridge",
+ "status": moon_bridge.get("status", "blocked"),
+ "evidence": moon_bridge.get("supports"),
+ "supports_judgment": moon_bridge.get("status") == "partial",
+ "blocks_judgment": False,
+ },
+ {
+ "factor": "moon_void_of_course",
+ "status": moon_void.get("status", "blocked"),
+ "evidence": moon_void.get("void_like_until_sign_exit"),
+ "supports_judgment": moon_void.get("void_like_until_sign_exit") is False,
+ "blocks_judgment": moon_void.get("void_like_until_sign_exit") is True,
+ },
+ ]
+ return {
+ "status": "partial",
+ "rows": rows,
+ "support_count": sum(1 for row in rows if row["supports_judgment"] is True),
+ "block_count": sum(1 for row in rows if row["blocks_judgment"] is True),
+ "judgment_status": "blocked",
+ "boundary": "Adjudication matrix organizes partial evidence only; it must not be rendered as a yes/no horary answer.",
+ }
+
+
+def _horary_guarded_yes_no_timing_verdict(
+ *,
+ chart: dict[str, Any],
+ querent: str,
+ quesited: str,
+ perfection: dict[str, Any],
+ application_detail: dict[str, Any],
+ prohibition: dict[str, Any],
+ mediation: dict[str, Any],
+ moon_bridge: dict[str, Any],
+ moon_void: dict[str, Any],
+ reception: dict[str, Any],
+ adjudication_matrix: dict[str, Any],
+) -> dict[str, Any]:
+ support = int(adjudication_matrix.get("support_count") or 0)
+ blocks = int(adjudication_matrix.get("block_count") or 0)
+ reception_support = int(reception.get("doctrine_verdict", {}).get("support_count") or 0)
+ perfection_degrees = perfection.get("travel_degrees")
+ querent_sign = chart["natal"]["planets"][querent]["sign"]
+ querent_house_motion = _house_motion(int(chart["natal"]["planets"][querent]["house"]))
+ modality = _SIGN_MODALITIES.get(querent_sign, "unknown")
+ prohibition_candidate = prohibition.get("candidate") if isinstance(prohibition.get("candidate"), dict) else {}
+ mediation_span = mediation.get("span_degrees")
+ moon_bridge_degrees = moon_bridge.get("travel_degrees")
+ timing_unit = (
+ "days_or_weeks_review" if modality == "cardinal" and querent_house_motion == "angular"
+ else "weeks_or_months_review" if modality in {"cardinal", "mutable"} or querent_house_motion == "succedent"
+ else "months_or_longer_review" if modality == "fixed"
+ else "blocked"
+ )
+ timing_doctrine_rows = [
+ {
+ "id": "direct_perfection_timing_unit",
+ "status": "partial" if isinstance(perfection_degrees, (int, float)) else "blocked",
+ "raw_input": {
+ "travel_degrees": perfection_degrees,
+ "querent_sign_modality": modality,
+ "querent_house_motion": querent_house_motion,
+ },
+ "candidate_unit": timing_unit,
+ "rule_locus_family": "horary_timing",
+ "blocks_final_judgment": True,
+ "reason": "Degree-to-time conversion is a review unit only until sign modality, house motion, and real horary outcomes are calibrated.",
+ },
+ {
+ "id": "prohibition_before_perfection",
+ "status": prohibition.get("status", "blocked"),
+ "raw_input": {
+ "detected": prohibition.get("detected"),
+ "direct_perfection_travel_degrees": perfection_degrees,
+ "intervening_candidate": prohibition_candidate,
+ },
+ "candidate_unit": "blocks_or_delays_review" if prohibition.get("detected") else "no_intervening_blocker_seen",
+ "rule_locus_family": "horary_prohibition",
+ "blocks_final_judgment": True,
+ "reason": "A prior intervening application must remain a hard review blocker until prohibition and frustration doctrine are calibrated.",
+ },
+ {
+ "id": "translation_collection_timing_span",
+ "status": mediation.get("status", "blocked"),
+ "raw_input": {
+ "mediator": mediation.get("mediator"),
+ "span_degrees": mediation_span,
+ "strength_snapshot": mediation.get("strength_snapshot"),
+ "reception_snapshot": mediation.get("reception_snapshot"),
+ },
+ "candidate_unit": (
+ "mediation_days_or_weeks_review" if isinstance(mediation_span, (int, float)) and mediation_span <= 6
+ else "mediation_weeks_or_months_review" if isinstance(mediation_span, (int, float)) and mediation_span <= 15
+ else "mediation_wide_or_blocked"
+ ),
+ "rule_locus_family": "horary_translation_collection",
+ "blocks_final_judgment": True,
+ "reason": "Mediation can describe a possible bridge, but cannot replace direct yes/no doctrine without calibrated collection/translation outcomes.",
+ },
+ {
+ "id": "moon_bridge_timing_span",
+ "status": moon_bridge.get("status", "blocked"),
+ "raw_input": {
+ "target": moon_bridge.get("to"),
+ "supports": moon_bridge.get("supports"),
+ "travel_degrees": moon_bridge_degrees,
+ "moon_condition_snapshot": moon_bridge.get("moon_condition_snapshot"),
+ },
+ "candidate_unit": (
+ "moon_bridge_short_review" if isinstance(moon_bridge_degrees, (int, float)) and moon_bridge_degrees <= 6
+ else "moon_bridge_medium_review" if isinstance(moon_bridge_degrees, (int, float)) and moon_bridge_degrees <= 15
+ else "moon_bridge_wide_or_blocked"
+ ),
+ "rule_locus_family": "horary_moon_condition",
+ "blocks_final_judgment": True,
+ "reason": "Moon translation evidence is timing context only; it does not authorize a final horary answer.",
+ },
+ {
+ "id": "final_yes_no_promotion_gate",
+ "status": "blocked",
+ "raw_input": {
+ "support_count": support,
+ "block_count": blocks,
+ "reception_support_count": reception_support,
+ },
+ "candidate_unit": "not_promoted",
+ "rule_locus_family": "horary_perfection",
+ "blocks_final_judgment": True,
+ "reason": "Independent horary cases, house-specific yes/no rules, and false-positive review are required before promotion.",
+ },
+ ]
+ for row in timing_doctrine_rows:
+ horary_loci = _source_loci(
+ "horary",
+ "reception",
+ rule_family=row.get("rule_locus_family"),
+ )
+ row["source_loci"] = horary_loci
+ row["source_loci_count"] = len(horary_loci)
+ if blocks:
+ answer_tendency = "review_no_or_blocked"
+ elif perfection.get("status") == "partial" and application_detail.get("motion_label") == "applying" and reception_support:
+ answer_tendency = "review_yes_candidate"
+ elif mediation.get("status") == "partial" or moon_bridge.get("status") == "partial":
+ answer_tendency = "review_possible_via_mediation"
+ else:
+ answer_tendency = "blocked_insufficient_yes_no_doctrine"
+ return {
+ "schema": "classical.horary_guarded_yes_no_timing_verdict.v1",
+ "status": "partial",
+ "answer_tendency": answer_tendency,
+ "final_yes_no_allowed": False,
+ "timing": {
+ "status": "partial" if isinstance(perfection_degrees, (int, float)) else "blocked",
+ "perfection_travel_degrees": perfection_degrees,
+ "candidate_unit": timing_unit,
+ "modality_basis": modality,
+ "house_motion_basis": querent_house_motion,
+ "boundary": "Timing unit is a doctrine-review candidate only; it is not a dated prediction.",
+ },
+ "timing_doctrine_review": {
+ "schema": "classical.horary_timing_doctrine_review.v1",
+ "status": "blocked",
+ "row_count": len(timing_doctrine_rows),
+ "rows": timing_doctrine_rows,
+ "final_yes_no_allowed": False,
+ "promotion_allowed": False,
+ "boundary": "Rows explain horary timing and yes/no blockers only; they must not be rendered as final outcome or dated prediction.",
+ },
+ "support_count": support,
+ "block_count": blocks,
+ "reception_support_count": reception_support,
+ "promotion_allowed": False,
+ "blocked_from_final_reason": (
+ "Horary has significator, perfection, prohibition, mediation, Moon, and reception evidence, but final yes/no and timing "
+ "require house-specific doctrine tables, calibrated historical cases, and reviewer-gated validation."
+ ),
+ }
+
+
+def _horary_partial_judgment(event_chart: dict[str, Any], question: str | None, event: dict[str, Any]) -> dict[str, Any]:
+ chart = event_chart["chart"]
+ quesited_house = _house_for_topic(question)
+ querent = event_chart["house_rulers"]["1"]["ruler"]
+ quesited = event_chart["house_rulers"][str(quesited_house)]["ruler"]
+ querent_point = chart["natal"]["planets"][querent]
+ quesited_point = chart["natal"]["planets"][quesited]
+ perfection = _perfection_candidate(chart, querent, quesited)
+ application_detail = _application_separation_detail(chart, querent, quesited)
+ prohibition = _prohibition_candidate(chart, querent, quesited, perfection)
+ mediation = _translation_collection_candidate(chart, querent, quesited)
+ moon_bridge = _moon_significator_bridge(chart, querent, quesited)
+ moon_void = _moon_void_of_course(chart)
+ planetary_hour = _planetary_hour_radicality(event, chart)
+ radicality = _radicality_check(chart)
+ reception = {
+ "querent_received_by_quesited": _reception_between(querent_point, quesited),
+ "quesited_received_by_querent": _reception_between(quesited_point, querent),
+ }
+ reception["doctrine_verdict"] = _reception_doctrine_verdict(reception, context="direct_significators")
+ adjudication_matrix = _horary_adjudication_matrix(
+ radicality=radicality,
+ planetary_hour=planetary_hour,
+ perfection=perfection,
+ application_detail=application_detail,
+ prohibition=prohibition,
+ mediation=mediation,
+ moon_bridge=moon_bridge,
+ moon_void=moon_void,
+ )
+ guarded_yes_no = _horary_guarded_yes_no_timing_verdict(
+ chart=chart,
+ querent=querent,
+ quesited=quesited,
+ perfection=perfection,
+ application_detail=application_detail,
+ prohibition=prohibition,
+ mediation=mediation,
+ moon_bridge=moon_bridge,
+ moon_void=moon_void,
+ reception=reception,
+ adjudication_matrix=adjudication_matrix,
+ )
+ evidence_score = sum([
+ 1 if not radicality["early_ascendant"] and not radicality["late_ascendant"] else 0,
+ 1 if planetary_hour["agreement"] else 0,
+ 1 if perfection.get("status") == "partial" and perfection.get("within_six_degree_orb") else 0,
+ 1 if not prohibition.get("detected") else 0,
+ 1 if mediation.get("status") == "partial" else 0,
+ 1 if moon_bridge.get("status") == "partial" else 0,
+ 1 if moon_void.get("void_like_until_sign_exit") is False else 0,
+ ])
+ return {
+ "status": "partial",
+ "question": question,
+ "querent": {"house": 1, "significator": querent, "condition": event_chart["planetary_condition"][querent]},
+ "quesited": {"house": quesited_house, "significator": quesited, "condition": event_chart["planetary_condition"][quesited]},
+ "radicality": radicality,
+ "planetary_hour_radicality": planetary_hour,
+ "significator_aspect": _closest_ptolemaic_aspect(querent_point["longitude"], quesited_point["longitude"]),
+ "reception": reception,
+ "perfection_candidate": perfection,
+ "application_separation_detail": application_detail,
+ "prohibition_candidate": prohibition,
+ "translation_collection_candidate": mediation,
+ "moon_significator_bridge": moon_bridge,
+ "moon_next_aspect": _moon_next_aspect(chart),
+ "moon_void_of_course": moon_void,
+ "adjudication_matrix": adjudication_matrix,
+ "guarded_yes_no_timing_verdict": guarded_yes_no,
+ "guarded_evidence_score": {
+ "status": "partial",
+ "score": evidence_score,
+ "max_score": 7,
+ "boundary": "Evidence score only; it must not be rendered as yes/no judgment.",
+ },
+ "judgment": {
+ "status": "blocked",
+ "reason": "Radicality/planetary-hour/perfection/application-separation/prohibition/mediation/Moon bridge/Moon void-of-course are partial evidence; house-specific doctrine and outcome rules are not complete.",
+ },
+ "boundary": "Horary significator evidence only; no yes/no answer is inferred.",
+ }
+
+
+def _score_electional_candidate(event_chart: dict[str, Any], election_goal: str | None) -> dict[str, Any]:
+ chart = event_chart["chart"]
+ goal_house = _house_for_topic(election_goal, default=10)
+ goal_lord = event_chart["house_rulers"][str(goal_house)]["ruler"]
+ moon_aspect = _moon_next_aspect(chart)
+ next_planet = moon_aspect.get("planet") if moon_aspect.get("status") == "partial" else None
+ goal_condition = event_chart["planetary_condition"][goal_lord]
+ sun_longitude = chart["natal"]["planets"]["sun"]["longitude"]
+ goal_lord_longitude = chart["natal"]["planets"][goal_lord]["longitude"]
+ moon_void_like = moon_aspect.get("status") != "partial" or float(moon_aspect.get("moon_travel_degrees", 999)) > 30
+ angular_malefics = [
+ planet for planet in ("mars", "saturn")
+ if event_chart["planetary_condition"][planet]["house_motion"] == "angular"
+ ]
+ goal_lord_combust = goal_lord not in {"sun"} and _angular_distance(goal_lord_longitude, sun_longitude) <= 8
+ score_hits = []
+ if goal_condition["house_motion"] == "angular":
+ score_hits.append("goal_lord_angular")
+ if goal_condition["speed_status"] == "direct":
+ score_hits.append("goal_lord_direct")
+ if next_planet in {"venus", "jupiter"}:
+ score_hits.append("moon_next_aspect_to_benefic")
+ if next_planet not in {"mars", "saturn"}:
+ score_hits.append("moon_next_aspect_not_to_malefic")
+ topic_weights = _ELECTIONAL_GOAL_WEIGHTS.get(goal_house, _ELECTIONAL_GOAL_WEIGHTS[10])
+ required_hits = list(topic_weights["required_hits"])
+ preferred_hits = list(topic_weights["preferred_hits"])
+ required_met = [hit for hit in required_hits if hit in score_hits]
+ preferred_met = [hit for hit in preferred_hits if hit in score_hits]
+ avoidance_flags = {
+ "moon_next_aspect_to_malefic": next_planet in {"mars", "saturn"},
+ "goal_lord_contrary_to_sect": goal_condition["sect_status"] == "contrary_to_sect",
+ "goal_lord_combust_within_8_degrees": goal_lord_combust,
+ "moon_void_like_no_major_application_within_sign": moon_void_like,
+ "angular_malefics": angular_malefics,
+ }
+ caution_count = sum([
+ 1 if avoidance_flags["moon_next_aspect_to_malefic"] else 0,
+ 1 if avoidance_flags["goal_lord_contrary_to_sect"] else 0,
+ 1 if avoidance_flags["goal_lord_combust_within_8_degrees"] else 0,
+ 1 if avoidance_flags["moon_void_like_no_major_application_within_sign"] else 0,
+ len(angular_malefics),
+ ])
+ caution_weights = _ELECTIONAL_CAUTION_WEIGHTS.get(goal_house, _ELECTIONAL_CAUTION_WEIGHTS[10])
+ weighted_cautions = {}
+ for flag, weight in caution_weights["weights"].items():
+ value = avoidance_flags.get(flag)
+ if isinstance(value, list):
+ weighted_cautions[flag] = len(value) * weight
+ else:
+ weighted_cautions[flag] = weight if value else 0
+ caution_weighted_score = sum(weighted_cautions.values())
+ weighted_score = len(required_met) * 2 + len(preferred_met)
+ max_weighted_score = len(required_hits) * 2 + len(preferred_hits)
+ score_ratio = round(weighted_score / max_weighted_score, 6) if max_weighted_score else 0
+ minimum_review = _ELECTIONAL_GOAL_RULE_TABLES["minimum_candidate_review"]
+ hard_blockers = [
+ blocker for blocker in minimum_review["hard_blockers"]
+ if (
+ blocker == "moon_void_like" and moon_void_like
+ ) or (
+ blocker == "goal_lord_retrograde" and goal_condition["speed_status"] == "retrograde"
+ ) or (
+ blocker == "goal_lord_combust_within_8_degrees" and goal_lord_combust
+ )
+ ]
+ moon_void = _moon_void_of_course(chart)
+ required_missing = [hit for hit in required_hits if hit not in required_met]
+ approval_doctrine_rows = [
+ {
+ "id": "goal_required_hits",
+ "status": "partial",
+ "raw_input": {
+ "topic": topic_weights["topic"],
+ "required_hits": required_hits,
+ "required_met": required_met,
+ "required_missing": required_missing,
+ },
+ "rule_locus_family": "electional_goal_specific",
+ "blocks_best_time_approval": True,
+ "reason": "Required goal hits are ranking evidence only; missing hits and topic fit need domain-calibrated approval rules.",
+ },
+ {
+ "id": "moon_condition_hierarchy",
+ "status": "partial",
+ "raw_input": {
+ "next_aspect_planet": next_planet,
+ "next_aspect_class": (
+ "benefic" if next_planet in {"venus", "jupiter"}
+ else "malefic" if next_planet in {"mars", "saturn"}
+ else "neutral_or_luminary" if next_planet
+ else "blocked"
+ ),
+ "moon_void_like": moon_void_like,
+ "moon_void_scan": moon_void,
+ },
+ "rule_locus_family": "electional_moon_condition",
+ "blocks_best_time_approval": True,
+ "reason": "Moon condition can caution or support a candidate, but the hierarchy is not calibrated enough to approve a best time.",
+ },
+ {
+ "id": "goal_specific_hard_prohibitions",
+ "status": "blocked" if hard_blockers else "partial",
+ "raw_input": {
+ "hard_blockers": hard_blockers,
+ "goal_lord_speed_status": goal_condition["speed_status"],
+ "goal_lord_combust_within_8_degrees": goal_lord_combust,
+ "angular_malefics": angular_malefics,
+ "weighted_cautions": weighted_cautions,
+ },
+ "rule_locus_family": "electional_hard_prohibition",
+ "blocks_best_time_approval": True,
+ "reason": "Hard prohibitions remain conservative blockers until goal-specific doctrine and false-positive review are closed.",
+ },
+ {
+ "id": "candidate_quality_thresholds",
+ "status": "partial",
+ "raw_input": {
+ "weighted_score_ratio": score_ratio,
+ "minimum_weighted_score_ratio": minimum_review["minimum_weighted_score_ratio"],
+ "weighted_caution_score": caution_weighted_score,
+ "maximum_weighted_caution_score": minimum_review["maximum_weighted_caution_score"],
+ },
+ "rule_locus_family": "electional_threshold",
+ "blocks_best_time_approval": True,
+ "reason": "Candidate quality thresholds can sort windows, but cannot certify suitability without validated accepted/rejected examples.",
+ },
+ {
+ "id": "window_comparison_coverage",
+ "status": "blocked",
+ "raw_input": {
+ "current_candidate_scope": "single chart or compact stepped window",
+ "required_scope": "larger searched window with accepted and rejected candidates",
+ },
+ "rule_locus_family": "electional_candidate",
+ "blocks_best_time_approval": True,
+ "reason": "Best-time approval requires comparison across a sufficiently broad window, not only the best candidate in the current limited search.",
+ },
+ ]
+ for row in approval_doctrine_rows:
+ electional_loci = _source_loci("electional", rule_family=row.get("rule_locus_family"))
+ row["source_loci"] = electional_loci
+ row["source_loci_count"] = len(electional_loci)
+ adjudication_rows = [
+ {
+ "factor": "goal_lord_angularity",
+ "status": "partial",
+ "evidence": goal_condition["house_motion"],
+ "supports_election": goal_condition["house_motion"] == "angular",
+ "cautions_election": goal_condition["house_motion"] == "cadent",
+ },
+ {
+ "factor": "goal_lord_speed",
+ "status": "partial",
+ "evidence": goal_condition["speed_status"],
+ "supports_election": goal_condition["speed_status"] == "direct",
+ "cautions_election": goal_condition["speed_status"] == "retrograde",
+ },
+ {
+ "factor": "moon_next_aspect_class",
+ "status": moon_aspect.get("status", "blocked"),
+ "evidence": next_planet,
+ "supports_election": next_planet in {"venus", "jupiter"},
+ "cautions_election": next_planet in {"mars", "saturn"},
+ },
+ {
+ "factor": "moon_void_of_course",
+ "status": moon_void.get("status", "blocked"),
+ "evidence": moon_void.get("void_like_until_sign_exit"),
+ "supports_election": moon_void.get("void_like_until_sign_exit") is False,
+ "cautions_election": moon_void.get("void_like_until_sign_exit") is True,
+ },
+ {
+ "factor": "goal_lord_combustion",
+ "status": "partial",
+ "evidence": goal_lord_combust,
+ "supports_election": goal_lord_combust is False,
+ "cautions_election": goal_lord_combust is True,
+ },
+ {
+ "factor": "angular_malefics",
+ "status": "partial",
+ "evidence": angular_malefics,
+ "supports_election": len(angular_malefics) == 0,
+ "cautions_election": len(angular_malefics) > 0,
+ },
+ {
+ "factor": "topic_required_hits",
+ "status": "partial",
+ "evidence": required_met,
+ "supports_election": len(required_met) == len(required_hits),
+ "cautions_election": len(required_met) < len(required_hits),
+ },
+ {
+ "factor": "topic_weighted_cautions",
+ "status": "partial",
+ "evidence": caution_weighted_score,
+ "supports_election": caution_weighted_score == 0,
+ "cautions_election": caution_weighted_score > 0,
+ },
+ ]
+ return {
+ "status": "partial",
+ "election_goal": election_goal,
+ "goal_house": goal_house,
+ "goal_lord": goal_lord,
+ "goal_lord_condition": goal_condition,
+ "moon_next_aspect": moon_aspect,
+ "moon_void_of_course": moon_void,
+ "next_aspect_class": (
+ "benefic" if next_planet in {"venus", "jupiter"}
+ else "malefic" if next_planet in {"mars", "saturn"}
+ else "neutral_or_luminary" if next_planet
+ else "blocked"
+ ),
+ "goal_weighting": {
+ "status": "partial",
+ "topic": topic_weights["topic"],
+ "required_hits": required_hits,
+ "preferred_hits": preferred_hits,
+ "required_met": required_met,
+ "preferred_met": preferred_met,
+ "weighted_score": weighted_score,
+ "max_weighted_score": max_weighted_score,
+ "weighted_score_ratio": score_ratio,
+ "boundary": "Topic weighting is a conservative evidence lens only; it does not approve or reject an election.",
+ },
+ "malefic_avoidance": {
+ "status": "partial",
+ **avoidance_flags,
+ "caution_count": caution_count,
+ "topic_caution_weighting": {
+ "status": "partial",
+ "topic": caution_weights["topic"],
+ "weights": caution_weights["weights"],
+ "weighted_flags": weighted_cautions,
+ "weighted_caution_score": caution_weighted_score,
+ "boundary": "Weighted cautions are topic-specific audit evidence only; they do not reject an election.",
+ },
+ "boundary": "Simple electional caution screen only; no full avoidance doctrine or final rejection is inferred.",
+ },
+ "approval_doctrine_gate": {
+ "schema": "classical.electional_approval_doctrine_gate.v1",
+ "status": "blocked",
+ "candidate_review_status": (
+ "reviewable_candidate"
+ if not hard_blockers
+ and score_ratio >= minimum_review["minimum_weighted_score_ratio"]
+ and caution_weighted_score <= minimum_review["maximum_weighted_caution_score"]
+ else "blocked_or_low_quality_candidate"
+ ),
+ "approval_allowed": False,
+ "best_time_allowed": False,
+ "hard_blockers": hard_blockers,
+ "thresholds": minimum_review,
+ "observed": {
+ "weighted_score_ratio": score_ratio,
+ "weighted_caution_score": caution_weighted_score,
+ "required_met": required_met,
+ "required_missing": required_missing,
+ "preferred_met": preferred_met,
+ },
+ "approval_doctrine_review": {
+ "schema": "classical.electional_approval_doctrine_review.v1",
+ "status": "blocked",
+ "row_count": len(approval_doctrine_rows),
+ "rows": approval_doctrine_rows,
+ "approval_allowed": False,
+ "best_time_allowed": False,
+ "boundary": "Rows explain electional approval blockers only; they must not be rendered as a best-time approval.",
+ },
+ "blocked_from_approval_reason": (
+ "Electional goal rules can rank or flag candidates, but approval of a best time requires fuller lunar condition doctrine, "
+ "house/topic-specific prohibitions, comparison across a larger searched window, and blind validation."
+ ),
+ },
+ "adjudication_matrix": {
+ "status": "partial",
+ "rows": adjudication_rows,
+ "support_count": sum(1 for row in adjudication_rows if row["supports_election"] is True),
+ "caution_count": sum(1 for row in adjudication_rows if row["cautions_election"] is True),
+ "judgment_status": "blocked",
+ "boundary": "Electional matrix organizes partial evidence only; it must not approve, reject, or name a best time.",
+ },
+ "guarded_evidence_score": {
+ "status": "partial",
+ "score": len(score_hits),
+ "max_score": 4,
+ "hits": score_hits,
+ "boundary": "Evidence score only; it must not be rendered as an election approval.",
+ },
+ "judgment": {
+ "status": "blocked",
+ "reason": "No election window search, goal-specific rule weighting, or malefic avoidance adjudicator is complete.",
+ },
+ "boundary": "Electional evidence only; no launch approval or best-time claim is inferred.",
+ }
+
+
+def _add_hours(event: dict[str, Any], hours: int) -> dict[str, Any]:
+ base = datetime(
+ int(event["year"]),
+ int(event["month"]),
+ int(event["day"]),
+ int(event["hour"]),
+ int(event["minute"]),
+ int(event.get("second", 0)),
+ ) + timedelta(hours=hours)
+ return {
+ **event,
+ "year": base.year,
+ "month": base.month,
+ "day": base.day,
+ "hour": base.hour,
+ "minute": base.minute,
+ "second": base.second,
+ }
+
+
+def _electional_window_search(
+ *,
+ base_event: dict[str, Any],
+ election_goal: str | None,
+ window_hours: int,
+ step_hours: int,
+) -> dict[str, Any]:
+ if window_hours <= 0:
+ return {"status": "not_applicable", "reason": "window_hours must be positive to scan"}
+ step = max(1, int(step_hours))
+ candidates = []
+ for offset in range(0, int(window_hours) + 1, step):
+ candidate_event = _add_hours(base_event, offset)
+ candidate_chart = build_classical_astrology_packet(
+ **candidate_event,
+ reference_date=None,
+ include_real_case_calibration=False,
+ )
+ evidence = _score_electional_candidate(candidate_chart, election_goal)
+ candidates.append({
+ "offset_hours": offset,
+ "datetime": (
+ f"{candidate_event['year']:04d}-{candidate_event['month']:02d}-{candidate_event['day']:02d}"
+ f"T{candidate_event['hour']:02d}:{candidate_event['minute']:02d}:{candidate_event.get('second', 0):02d}"
+ ),
+ "goal_lord": evidence["goal_lord"],
+ "goal_lord_house_motion": evidence["goal_lord_condition"]["house_motion"],
+ "next_aspect_class": evidence["next_aspect_class"],
+ "score": evidence["guarded_evidence_score"]["score"],
+ "weighted_score": evidence["goal_weighting"]["weighted_score"],
+ "max_weighted_score": evidence["goal_weighting"]["max_weighted_score"],
+ "weighted_caution_score": evidence["malefic_avoidance"]["topic_caution_weighting"]["weighted_caution_score"],
+ "adjudication_support_count": evidence["adjudication_matrix"]["support_count"],
+ "adjudication_caution_count": evidence["adjudication_matrix"]["caution_count"],
+ "hits": evidence["guarded_evidence_score"]["hits"],
+ "goal_weighting": evidence["goal_weighting"],
+ "malefic_avoidance": evidence["malefic_avoidance"],
+ "adjudication_matrix": evidence["adjudication_matrix"],
+ })
+ ranked = sorted(candidates, key=lambda row: (row["weighted_caution_score"], -row["weighted_score"], -row["score"], row["offset_hours"]))
+ return {
+ "status": "partial",
+ "window_hours": int(window_hours),
+ "step_hours": step,
+ "candidate_count": len(candidates),
+ "top_candidates": ranked[:5],
+ "top_candidate_review": (
+ {
+ "status": "partial",
+ "offset_hours": ranked[0]["offset_hours"],
+ "datetime": ranked[0]["datetime"],
+ "weighted_score": ranked[0]["weighted_score"],
+ "max_weighted_score": ranked[0]["max_weighted_score"],
+ "weighted_caution_score": ranked[0]["weighted_caution_score"],
+ "adjudication_support_count": ranked[0]["adjudication_support_count"],
+ "adjudication_caution_count": ranked[0]["adjudication_caution_count"],
+ "boundary": "Top candidate is rank-order evidence only; it is not a best-time claim or approval.",
+ }
+ if ranked else {"status": "blocked", "reason": "no candidates were generated"}
+ ),
+ "boundary": "Window scan ranks conservative evidence only; it does not declare a best time or approve an election.",
+ }
+
+
+def _electional_partial_judgment(
+ event_chart: dict[str, Any],
+ election_goal: str | None,
+ *,
+ base_event: dict[str, Any],
+ window_hours: int,
+ step_hours: int,
+) -> dict[str, Any]:
+ evidence = _score_electional_candidate(event_chart, election_goal)
+ return {
+ **evidence,
+ "window_search": _electional_window_search(
+ base_event=base_event,
+ election_goal=election_goal,
+ window_hours=window_hours,
+ step_hours=step_hours,
+ ),
+ "judgment": {
+ "status": "blocked",
+ "reason": "Window search and malefic screen are partial evidence; full goal-specific election doctrine is not complete.",
+ },
+ }
+
+
+def _case_birth(subject: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "year": subject["year"],
+ "month": subject["month"],
+ "day": subject["day"],
+ "hour": subject["hour"],
+ "minute": subject["minute"],
+ "second": subject.get("second", 0),
+ "latitude": subject["lat"],
+ "longitude": subject["lon"],
+ "timezone": subject["tz"],
+ }
+
+
+def _event_theme(event: dict[str, Any], fallback: str) -> str:
+ return str(event.get("expected_label") or event.get("domain") or event.get("event_type") or fallback).lower()
+
+
+def _theme_lords(packet: dict[str, Any], theme: str) -> dict[str, Any]:
+ houses = _THEME_HOUSES.get(theme) or _THEME_HOUSES.get(theme.replace("_status", "")) or (1, 10)
+ rows = []
+ for house in houses:
+ house_row = packet["house_rulers"][str(house)]
+ rows.append({
+ "house": house,
+ "ruler": house_row["ruler"],
+ "ruler_house": house_row["ruler_house"],
+ "ruler_sign": house_row["ruler_sign"],
+ })
+ return {"theme": theme, "houses": list(houses), "lords": rows}
+
+
+def _score_classical_event_replay(case: dict[str, Any], event: dict[str, Any], primary_theme: str) -> dict[str, Any]:
+ subject = case["subject"]
+ birth = _case_birth(subject)
+ event_date = str(event["event_date"])
+ packet = build_classical_astrology_packet(
+ **birth,
+ reference_date=event_date,
+ include_real_case_calibration=False,
+ )
+ theme = _event_theme(event, primary_theme)
+ theme_lords = _theme_lords(packet, theme)
+ profection = packet["annual_profection"]["raw_result"]
+ release = packet["zodiacal_release_adjudication"]
+ zr_lord = ((release.get("active_period") or {}).get("lord"))
+ loosing = release.get("loosing_of_bond") if isinstance(release.get("loosing_of_bond"), dict) else {}
+ peak_periods = release.get("peak_periods") if isinstance(release.get("peak_periods"), dict) else {}
+ release_matrix = release.get("adjudication_matrix") if isinstance(release.get("adjudication_matrix"), dict) else {}
+ peak_candidates = peak_periods.get("candidates") if isinstance(peak_periods.get("candidates"), list) else []
+ top_peak = peak_candidates[0] if peak_candidates else None
+ top_peak_lord = top_peak.get("lord") if isinstance(top_peak, dict) else None
+ year_lord = profection.get("year_lord")
+ theme_lord_names = {row["ruler"] for row in theme_lords["lords"]}
+ hits = []
+ if year_lord in theme_lord_names:
+ hits.append("annual_profection_lord_matches_theme_house_lord")
+ if zr_lord in theme_lord_names:
+ hits.append("zodiacal_release_lord_matches_theme_house_lord")
+ if year_lord == zr_lord and year_lord:
+ hits.append("annual_lord_matches_release_lord")
+ angular_theme_lords = [
+ row["ruler"]
+ for row in theme_lords["lords"]
+ if packet["planetary_condition"][row["ruler"]]["house_motion"] == "angular"
+ ]
+ if angular_theme_lords:
+ hits.append("theme_lord_angular")
+ score = round(len(hits) / 4.0, 3)
+ return {
+ "case_id": case["case_id"],
+ "subject": subject.get("name"),
+ "event": {
+ "date": event_date,
+ "type": event.get("event_type"),
+ "domain": event.get("domain"),
+ "expected_label": event.get("expected_label"),
+ "source": event.get("source"),
+ },
+ "birth_source": subject.get("birth_source"),
+ "theme_lords": theme_lords,
+ "annual_profection": {
+ "profected_house": profection.get("profected_house"),
+ "profected_sign": profection.get("profected_sign"),
+ "year_lord": year_lord,
+ },
+ "zodiacal_release": {
+ "status": release.get("status"),
+ "active_sign": (release.get("active_period") or {}).get("sign"),
+ "active_lord": zr_lord,
+ "lot_ruler": (release.get("active_period") or {}).get("lot_ruler"),
+ },
+ "zodiacal_release_replay_evidence": {
+ "status": "partial",
+ "loosing_detected": loosing.get("detected"),
+ "rule_variants": loosing.get("rule_variants"),
+ "active_l1_is_peak": peak_periods.get("active_l1_is_peak"),
+ "active_l2_is_peak": peak_periods.get("active_l2_is_peak"),
+ "theme_lord_cross": {
+ "status": "partial",
+ "active_lord_matches_theme_lord": zr_lord in theme_lord_names,
+ "lot_ruler_matches_theme_lord": ((release.get("active_period") or {}).get("lot_ruler")) in theme_lord_names,
+ "top_peak_lord_matches_theme_lord": top_peak_lord in theme_lord_names if top_peak_lord else None,
+ "annual_lord_matches_theme_lord": year_lord in theme_lord_names if year_lord else None,
+ "theme_lords": sorted(theme_lord_names),
+ "score_inclusion": "not_included",
+ "boundary": "Theme-lord cross evidence only; it does not adjudicate event relevance or change replay score.",
+ },
+ "annual_lord_cross": {
+ "status": "partial",
+ "annual_lord": year_lord,
+ "active_release_lord": zr_lord,
+ "top_peak_lord": top_peak_lord,
+ "annual_lord_matches_active_release_lord": year_lord == zr_lord if year_lord and zr_lord else None,
+ "annual_lord_matches_top_peak_lord": year_lord == top_peak_lord if year_lord and top_peak_lord else None,
+ "score_inclusion": "included_only_for_annual_lord_matches_release_lord",
+ "boundary": "Annual-lord cross is surfaced for replay review; top-peak overlap is not a prediction-confidence input.",
+ },
+ "top_peak_candidate": top_peak,
+ "top_peak_weight": top_peak.get("peak_weight") if isinstance(top_peak, dict) else None,
+ "event_prominence_gate": (
+ (peak_periods.get("rule_matrix") or {}).get("event_prominence_adjudication")
+ if isinstance(peak_periods.get("rule_matrix"), dict)
+ else {"status": "blocked", "reason": "peak rule matrix unavailable"}
+ ),
+ "adjudication_matrix_snapshot": {
+ "status": release_matrix.get("status", "blocked"),
+ "support_count": release_matrix.get("support_count"),
+ "blocked_count": release_matrix.get("blocked_count"),
+ "promotion_status": release_matrix.get("promotion_status", "blocked"),
+ "factors": [
+ row.get("factor")
+ for row in release_matrix.get("rows", [])
+ if isinstance(row, dict)
+ ],
+ "score_inclusion": "not_included",
+ "boundary": "Replay matrix snapshot mirrors calculation evidence only; it does not change replay score or prediction confidence.",
+ },
+ "score_inclusion": "not_included",
+ "boundary": "ZR loosing/peak replay evidence is surfaced for audit only; it is not included in replay score or prediction confidence.",
+ },
+ "hits": hits,
+ "score": score,
+ "score_boundary": "Replay evidence score only; it is not predictive accuracy, specificity, or a validation pass.",
+ }
+
+
+def _window_midpoint_date(start_date: str, end_date: str) -> str:
+ start = datetime.fromisoformat(start_date).date()
+ end = datetime.fromisoformat(end_date).date()
+ if end < start:
+ start, end = end, start
+ return (start + (end - start) / 2).isoformat()
+
+
+def _score_classical_negative_control_window(
+ case: dict[str, Any],
+ window: dict[str, Any],
+ primary_theme: str,
+) -> dict[str, Any]:
+ theme = str(window.get("domain") or case.get("theme") or primary_theme)
+ midpoint = _window_midpoint_date(str(window["start_date"]), str(window["end_date"]))
+ replay = _score_classical_event_replay(
+ case,
+ {
+ "event_type": window.get("event_type") or f"no_{theme}_event",
+ "event_date": midpoint,
+ "domain": theme,
+ "expected_label": window.get("expected_label") or f"no_{theme}_event",
+ "source": window.get("source"),
+ },
+ primary_theme,
+ )
+ return {
+ "case_id": replay["case_id"],
+ "subject": replay["subject"],
+ "window": {
+ "start_date": window.get("start_date"),
+ "end_date": window.get("end_date"),
+ "midpoint_date": midpoint,
+ "domain": theme,
+ "expected_label": window.get("expected_label") or f"no_{theme}_event",
+ "source": window.get("source"),
+ },
+ "score": replay["score"],
+ "hits": replay["hits"],
+ "false_positive_candidate": replay["score"] > 0,
+ "raw_replay": replay,
+ "boundary": "Negative-control diagnostic row only; it is not independent blind specificity evidence unless labels were frozen before scoring.",
+ }
+
+
+def _score_negative_control_cases(cases: list[dict[str, Any]], primary_theme: str) -> dict[str, Any]:
+ scored_rows = []
+ blocked_rows = []
+ for case in cases:
+ windows = case.get("non_event_windows") if isinstance(case.get("non_event_windows"), list) else []
+ if not windows:
+ continue
+ required = {"case_id", "subject", "source"}
+ missing = sorted(required - set(case))
+ for window in windows:
+ window_missing = [
+ field
+ for field in ("start_date", "end_date")
+ if not isinstance(window, dict) or not window.get(field)
+ ]
+ if missing or window_missing:
+ blocked_rows.append({
+ "case_id": case.get("case_id") or "unlabeled_case",
+ "status": "blocked",
+ "required_missing": missing + window_missing,
+ "window": window if isinstance(window, dict) else None,
+ })
+ continue
+ scored_rows.append(_score_classical_negative_control_window(case, window, primary_theme))
+ false_positive_rows = [row for row in scored_rows if row["false_positive_candidate"]]
+ return {
+ "status": "diagnostic" if scored_rows else "blocked",
+ "scored_window_count": len(scored_rows),
+ "blocked_window_count": len(blocked_rows),
+ "false_positive_candidate_count": len(false_positive_rows),
+ "false_positive_rate": (
+ round(len(false_positive_rows) / len(scored_rows), 3)
+ if scored_rows else None
+ ),
+ "scored_rows": scored_rows,
+ "blocked_rows": blocked_rows,
+ "score_inclusion": "not_included",
+ "promotion_eligible": False,
+ "blocked_reason": None if scored_rows else "no runnable non_event_windows were available",
+ "boundary": "Runnable negative-control scorer only. Promotion remains blocked without independent frozen non-event labels and blind scoring.",
+ }
+
+
+def _build_domain_specificity_table(
+ replay_results: list[dict[str, Any]],
+ negative_control_replay: dict[str, Any],
+) -> dict[str, Any]:
+ domains = {}
+ for row in replay_results:
+ event = row.get("event") if isinstance(row.get("event"), dict) else {}
+ domain = str(event.get("domain") or "general")
+ domains.setdefault(domain, {"positive_scores": [], "negative_rows": []})
+ domains[domain]["positive_scores"].append(row.get("score"))
+ for row in negative_control_replay.get("scored_rows", []):
+ window = row.get("window") if isinstance(row.get("window"), dict) else {}
+ domain = str(window.get("domain") or "general")
+ domains.setdefault(domain, {"positive_scores": [], "negative_rows": []})
+ domains[domain]["negative_rows"].append(row)
+ rows = []
+ for domain in sorted(domains):
+ positive_scores = [
+ score for score in domains[domain]["positive_scores"]
+ if isinstance(score, (int, float))
+ ]
+ negative_rows = domains[domain]["negative_rows"]
+ false_positive_count = sum(1 for row in negative_rows if row.get("false_positive_candidate") is True)
+ rows.append({
+ "domain": domain,
+ "positive_event_count": len(positive_scores),
+ "positive_average_score": (
+ round(sum(positive_scores) / len(positive_scores), 3)
+ if positive_scores else None
+ ),
+ "negative_window_count": len(negative_rows),
+ "false_positive_candidate_count": false_positive_count,
+ "false_positive_rate": (
+ round(false_positive_count / len(negative_rows), 3)
+ if negative_rows else None
+ ),
+ "status": "diagnostic" if negative_rows else "blocked",
+ "promotion_status": "blocked_for_promotion",
+ "score_inclusion": "not_included",
+ })
+ return {
+ "status": "diagnostic" if rows else "blocked",
+ "row_count": len(rows),
+ "rows": rows,
+ "promotion_status": "blocked_for_promotion",
+ "boundary": "Per-domain specificity table compares positive replay scores with diagnostic negative-window false positives; it is not promotion evidence.",
+ }
+
+
+def _parse_candidate_birth_data(candidate: dict[str, Any]) -> dict[str, Any] | None:
+ birth = candidate.get("birth_data") if isinstance(candidate.get("birth_data"), dict) else {}
+ date_text = birth.get("date")
+ time_text = birth.get("time")
+ place_key = str(birth.get("place") or "").strip().lower()
+ place = _CANDIDATE_PLACE_COORDS.get(place_key)
+ if not date_text or not time_text or not place:
+ return None
+ year, month, day = [int(part) for part in str(date_text).split("-")]
+ hour, minute = [int(part) for part in str(time_text).split(":")[:2]]
+ return {
+ "name": candidate.get("public_person"),
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": hour,
+ "minute": minute,
+ "second": 0,
+ "lat": place["lat"],
+ "lon": place["lon"],
+ "tz": place["tz"],
+ "birth_source": {
+ "source_grade": birth.get("source_quality"),
+ "url": birth.get("source_url"),
+ "place": birth.get("place"),
+ "coordinate_policy": "local_known_place_mapping",
+ },
+ }
+
+
+def _load_candidate_negative_control_cases(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "cases": [],
+ "blocked_rows": [],
+ "reason": "candidate negative-control manifest is missing",
+ }
+ data = json.loads(path.read_text(encoding="utf-8"))
+ cases = data.get("cases") if isinstance(data.get("cases"), list) else []
+ converted = []
+ blocked_rows = []
+ for candidate in cases:
+ if not isinstance(candidate, dict):
+ continue
+ subject = _parse_candidate_birth_data(candidate)
+ windows = candidate.get("negative_windows") if isinstance(candidate.get("negative_windows"), list) else []
+ if not subject or not windows:
+ blocked_rows.append({
+ "case_id": candidate.get("case_id") or "unlabeled_candidate",
+ "status": "blocked",
+ "reason": "birth_data place/time/date or negative_windows could not be converted",
+ })
+ continue
+ theme = str((candidate.get("positive_event") or {}).get("event_type") or "timing").lower()
+ converted.append({
+ "case_id": candidate.get("case_id"),
+ "theme": theme,
+ "subject": subject,
+ "source": {
+ "manifest": str(path),
+ "claim_status": data.get("claim_status"),
+ "production_tuning_allowed": data.get("production_tuning_allowed"),
+ "truth_matrix_allowed": data.get("truth_matrix_allowed"),
+ },
+ "non_event_windows": [
+ {
+ "start_date": window.get("start") or window.get("start_date"),
+ "end_date": window.get("end") or window.get("end_date"),
+ "domain": theme,
+ "expected_label": f"no_{theme}_event",
+ "source": {
+ "source_url": window.get("source_url"),
+ "review_note": window.get("review_note"),
+ "source_grade": "machine_prepared_candidate",
+ },
+ }
+ for window in windows
+ if isinstance(window, dict)
+ ],
+ })
+ return {
+ "status": "candidate_diagnostic" if converted else "blocked",
+ "path": str(path),
+ "claim_status": data.get("claim_status"),
+ "production_tuning_allowed": data.get("production_tuning_allowed"),
+ "truth_matrix_allowed": data.get("truth_matrix_allowed"),
+ "case_count": len(converted),
+ "blocked_case_count": len(blocked_rows),
+ "cases": converted,
+ "blocked_rows": blocked_rows,
+ "boundary": "Machine-prepared candidate labels only; diagnostic replay is allowed for audit, not promotion.",
+ }
+
+
+def _load_manifest_cases(manifest_path: Path) -> list[dict[str, Any]]:
+ if not manifest_path.exists():
+ return []
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ cases = manifest.get("cases") if isinstance(manifest.get("cases"), list) else []
+ return [case for case in cases if isinstance(case, dict)]
+
+
+def _count_by_value(values: list[Any]) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for value in values:
+ key = str(value or "unknown")
+ counts[key] = counts.get(key, 0) + 1
+ return dict(sorted(counts.items()))
+
+
+def _manifest_coverage_rows(cases: list[dict[str, Any]], *, label: str, path: Path | str | None) -> dict[str, Any]:
+ event_rows = [
+ event
+ for case in cases
+ for event in (case.get("event_outcomes") if isinstance(case.get("event_outcomes"), list) else [])
+ if isinstance(event, dict)
+ ]
+ runnable_cases = [
+ case
+ for case in cases
+ if case.get("case_id") and isinstance(case.get("subject"), dict) and isinstance(case.get("event_outcomes"), list)
+ ]
+ birth_sources = [
+ (case.get("subject") or {}).get("birth_source", {}) if isinstance(case.get("subject"), dict) else {}
+ for case in cases
+ ]
+ return {
+ "label": label,
+ "path": str(path) if path else None,
+ "case_count": len(cases),
+ "runnable_case_count": len(runnable_cases),
+ "event_outcome_count": len(event_rows),
+ "domain_counts": _count_by_value([event.get("domain") or event.get("event_type") for event in event_rows]),
+ "source_grade_counts": _count_by_value([
+ source.get("source_grade") if isinstance(source, dict) else None
+ for source in birth_sources
+ ]),
+ "time_accuracy_counts": _count_by_value([
+ source.get("time_accuracy_rating") if isinstance(source, dict) else None
+ for source in birth_sources
+ ]),
+ }
+
+
+def _build_case_corpus_coverage(
+ *,
+ source_cases: list[dict[str, Any]],
+ source_path: Path,
+ admitted_cases: list[dict[str, Any]],
+ replay_results: list[dict[str, Any]],
+ candidate_negative_manifest: dict[str, Any],
+) -> dict[str, Any]:
+ holdout_cases = _load_manifest_cases(DEFAULT_POSITIVE_HOLDOUT_MANIFEST)
+ negative_cases = candidate_negative_manifest.get("cases") if isinstance(candidate_negative_manifest.get("cases"), list) else []
+ negative_window_count = sum(
+ len(case.get("non_event_windows") or [])
+ for case in negative_cases
+ if isinstance(case, dict)
+ )
+ primary = _manifest_coverage_rows(source_cases, label="primary_positive_replay", path=source_path)
+ holdout = _manifest_coverage_rows(holdout_cases, label="positive_holdout", path=DEFAULT_POSITIVE_HOLDOUT_MANIFEST)
+ return {
+ "status": "partial" if primary["case_count"] else "blocked",
+ "primary_manifest": primary,
+ "positive_holdout_manifest": holdout,
+ "candidate_negative_manifest": {
+ "status": candidate_negative_manifest.get("status", "blocked"),
+ "path": candidate_negative_manifest.get("path"),
+ "case_count": candidate_negative_manifest.get("case_count", 0),
+ "blocked_case_count": candidate_negative_manifest.get("blocked_case_count", 0),
+ "non_event_window_count": negative_window_count,
+ "truth_matrix_allowed": candidate_negative_manifest.get("truth_matrix_allowed"),
+ },
+ "current_replay_scope": {
+ "admitted_case_count": len(admitted_cases),
+ "replayed_event_count": len(replay_results),
+ "case_ids": [case.get("case_id") for case in admitted_cases],
+ },
+ "promotion_status": "blocked",
+ "boundary": "Corpus coverage describes available audit material only; it is not a blind validation result or predictive accuracy claim.",
+ }
+
+
+def _negative_holdout_source_audit_summary(path: Path = DEFAULT_NEGATIVE_HOLDOUT_AUDIT) -> dict[str, Any]:
+ if not path.exists():
+ return {"status": "blocked", "path": str(path), "reason": "negative holdout source audit file is missing"}
+ data = json.loads(path.read_text(encoding="utf-8"))
+ sources = data.get("sources") if isinstance(data.get("sources"), list) else []
+ blockers = sorted({
+ blocker
+ for source in sources
+ if isinstance(source, dict)
+ for blocker in source.get("blockers", [])
+ })
+ usable_sources = [
+ source.get("name")
+ for source in sources
+ if isinstance(source, dict) and source.get("usable_for_promotion") is True
+ ]
+ return {
+ "status": "blocked" if not usable_sources else "partial",
+ "path": str(path),
+ "claim_status": data.get("claim_status"),
+ "production_tuning_allowed": data.get("production_tuning_allowed"),
+ "required_label_contract": data.get("required_label_contract", {}),
+ "source_count": len(sources),
+ "usable_for_promotion_count": len(usable_sources),
+ "usable_sources": usable_sources,
+ "blockers": blockers,
+ "boundary": "Parsed source-audit summary only; it does not score negative controls or promote predictions.",
+ }
+
+
+def _load_classical_blind_validation_manifest(path: Path = DEFAULT_CLASSICAL_BLIND_VALIDATION_MANIFEST) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "case_count": 0,
+ "blind_label_count": 0,
+ "promotion_status": "blocked",
+ "reason": "classical blind validation manifest is missing",
+ }
+ data = json.loads(path.read_text(encoding="utf-8"))
+ cases = data.get("cases") if isinstance(data.get("cases"), list) else []
+ blind_cases = [
+ case for case in cases
+ if isinstance(case, dict) and (case.get("labels") or {}).get("blind_frozen") is True
+ ]
+ frozen_sample_queue = [
+ case for case in cases
+ if isinstance(case, dict) and str(case.get("sample_status", "")).startswith("frozen_")
+ ]
+ blockers = data.get("blockers") if isinstance(data.get("blockers"), list) else []
+ return {
+ "status": "partial" if cases else "blocked",
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "claim_status": data.get("claim_status"),
+ "case_count": len(cases),
+ "frozen_sample_queue_count": len(frozen_sample_queue),
+ "blind_label_count": len(blind_cases),
+ "sample_status_counts": _count_by_value([case.get("sample_status") for case in cases if isinstance(case, dict)]),
+ "blockers": blockers,
+ "required_protocol": data.get("required_protocol", {}),
+ "frozen_sample_queue": frozen_sample_queue,
+ "promotion_status": "blocked",
+ "boundary": "Blind validation manifest is an intake contract only; labels are not promotion-eligible until independently frozen.",
+ }
+
+
+def _load_classical_domain_sample_packet_summary(
+ path: Path,
+ family: str,
+ reviewer_path: Path | None = None,
+) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "sample_count": 0,
+ "reviewer_path": str(reviewer_path) if reviewer_path else None,
+ "reviewer_label_count": 0,
+ "reviewer_matched_count": 0,
+ "minimum_counts": {},
+ "meets_minimums": False,
+ "reviewer_evidence_status": "blocked",
+ "statistics_gate_status": "blocked",
+ "reviewer_unseal_ready": False,
+ "promotion_allowed": False,
+ "reason": "domain_sample_packet_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - malformed local artifact
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "sample_count": 0,
+ "reviewer_path": str(reviewer_path) if reviewer_path else None,
+ "reviewer_label_count": 0,
+ "reviewer_matched_count": 0,
+ "minimum_counts": {},
+ "meets_minimums": False,
+ "reviewer_evidence_status": "blocked",
+ "statistics_gate_status": "blocked",
+ "reviewer_unseal_ready": False,
+ "promotion_allowed": False,
+ "reason": f"domain_sample_packet_unreadable:{type(exc).__name__}",
+ }
+ samples = data.get("samples") if isinstance(data.get("samples"), list) else []
+ contract = data.get("required_sample_contract") if isinstance(data.get("required_sample_contract"), dict) else {}
+ minimum_counts = {
+ key: int(value)
+ for key, value in contract.items()
+ if str(key).startswith("minimum_") and isinstance(value, int)
+ }
+ sample_count = len(samples)
+ required_total = sum(minimum_counts.values())
+ frozen_count = sum(
+ 1 for sample in samples
+ if isinstance(sample, dict) and str(sample.get("freeze_status", sample.get("sample_status", ""))).startswith("frozen")
+ )
+ reviewer_ready_count = sum(
+ 1 for sample in samples
+ if isinstance(sample, dict) and sample.get("human_reviewer_id") not in {None, "", "required_different_from_freezer"}
+ )
+ reviewer_data: dict[str, Any] = {}
+ reviewer_label_count = 0
+ reviewer_matched_count = 0
+ reviewer_domain_counts: dict[str, int] = {}
+ reviewer_minimums: dict[str, int] = {}
+ if reviewer_path and reviewer_path.exists():
+ try:
+ reviewer_data = json.loads(reviewer_path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - malformed local artifact
+ reviewer_data = {"status": "blocked", "reason": f"reviewer_packet_unreadable:{type(exc).__name__}"}
+ reviewer_labels = reviewer_data.get("labels") if isinstance(reviewer_data.get("labels"), list) else []
+ reviewer_label_count = len(reviewer_labels)
+ reviewer_matched_samples = [
+ sample for sample in samples
+ if isinstance(sample, dict) and any(
+ isinstance(label, dict) and str(label.get("sample_id") or "") == str(sample.get("case_id") or sample.get("sample_id") or "")
+ for label in reviewer_labels
+ )
+ ]
+ reviewer_matched_count = len(reviewer_matched_samples)
+ reviewer_domain_counts = _count_by_value([
+ sample.get("domain")
+ for sample in reviewer_matched_samples
+ if isinstance(sample, dict)
+ ])
+ reviewer_protocol = reviewer_data.get("required_protocol") if isinstance(reviewer_data.get("required_protocol"), dict) else {}
+ if isinstance(reviewer_protocol.get("domain_minimum_sample_counts"), dict):
+ reviewer_minimums = {
+ key: int(value)
+ for key, value in reviewer_protocol.get("domain_minimum_sample_counts", {}).items()
+ if isinstance(value, int)
+ }
+ effective_required_total = max(required_total, reviewer_label_count)
+ reviewer_protocol_ready = bool(reviewer_label_count and reviewer_matched_count and (
+ reviewer_minimums or reviewer_label_count == reviewer_matched_count
+ ))
+ review_ready = bool(sample_count and sample_count >= effective_required_total and reviewer_protocol_ready)
+ reviewer_status = "partial_review_ready" if review_ready else "blocked"
+ return {
+ "family": family,
+ "status": "partial" if sample_count else "blocked",
+ "path": str(path),
+ "reviewer_path": str(reviewer_path) if reviewer_path else None,
+ "manifest_id": data.get("manifest_id"),
+ "claim_status": data.get("claim_status"),
+ "promotion_status": data.get("promotion_status", "blocked"),
+ "sample_count": sample_count,
+ "frozen_count": frozen_count,
+ "reviewer_ready_count": reviewer_ready_count,
+ "reviewer_label_count": reviewer_label_count,
+ "reviewer_matched_count": reviewer_matched_count,
+ "reviewer_domain_counts": reviewer_domain_counts,
+ "minimum_counts": minimum_counts,
+ "reviewer_minimum_counts": reviewer_minimums,
+ "required_total_minimum": required_total,
+ "effective_required_total": effective_required_total,
+ "missing_sample_count": max(effective_required_total - sample_count, 0) if effective_required_total else 0,
+ "meets_minimums": bool(sample_count >= effective_required_total) if effective_required_total else bool(sample_count),
+ "reviewer_evidence_status": reviewer_status,
+ "statistics_gate_status": reviewer_status,
+ "reviewer_unseal_ready": review_ready,
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Domain sample packet summary only; not a validation result."),
+ }
+
+
+def _load_classical_case_acquisition_queue_summary(path: Path, family: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "frozen_item_count": 0,
+ "blocked_item_count": 0,
+ "ready_to_freeze_count": 0,
+ "promotion_allowed": False,
+ "reason": "case_acquisition_queue_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - malformed local artifact
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "frozen_item_count": 0,
+ "blocked_item_count": 0,
+ "ready_to_freeze_count": 0,
+ "promotion_allowed": False,
+ "reason": f"case_acquisition_queue_unreadable:{type(exc).__name__}",
+ }
+ items = data.get("items") if isinstance(data.get("items"), list) else []
+ item_count = len(items)
+ frozen_item_count = sum(1 for item in items if isinstance(item, dict) and str(item.get("current_status") or "").startswith("frozen"))
+ ready_to_freeze_count = sum(
+ 1 for item in items
+ if isinstance(item, dict)
+ and str(item.get("current_status") or "").lower() in {"ready_to_freeze", "freeze_ready"}
+ )
+ blocked_item_count = sum(
+ 1 for item in items
+ if isinstance(item, dict)
+ and (
+ "blocked" in str(item.get("current_status") or "").lower()
+ or (
+ bool(item.get("missing_for_freeze"))
+ and str(item.get("current_status") or "").lower() not in {"ready_to_freeze", "freeze_ready"}
+ and not str(item.get("current_status") or "").startswith("frozen")
+ )
+ )
+ )
+ return {
+ "family": family,
+ "status": "partial" if item_count else "blocked",
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "claim_status": data.get("claim_status"),
+ "promotion_status": data.get("promotion_status", "blocked"),
+ "queue_status": data.get("queue_status"),
+ "item_count": item_count,
+ "frozen_item_count": frozen_item_count,
+ "blocked_item_count": blocked_item_count,
+ "ready_to_freeze_count": ready_to_freeze_count,
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Acquisition queue only; not a sample packet."),
+ }
+
+
+_CLASSICAL_CASE_ACQUISITION_REQUIRED_FIELDS = {
+ "horary": [
+ "case_id",
+ "question_text",
+ "question_datetime_local",
+ "location",
+ "timezone",
+ "source_refs",
+ "published_outcome",
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ ],
+ "electional": [
+ "case_id",
+ "activity_label",
+ "election_datetime_local",
+ "location",
+ "timezone",
+ "source_refs",
+ "accepted_or_rejected_label",
+ "published_outcome",
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ ],
+ "zr": [
+ "case_id",
+ "birth_datetime_local",
+ "birth_location",
+ "timezone",
+ "source_refs",
+ "event_datetime_or_period",
+ "lot_basis_fortune_or_spirit",
+ "release_level_scope",
+ "published_event_outcome",
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ ],
+}
+
+
+def _classical_supported_family_response(schema: str, family: str, layer: str | None = None) -> dict[str, Any]:
+ packet = {
+ "schema": schema,
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "unsupported_family",
+ "supported_families": list(CLASSICAL_CASE_VALIDATION_FAMILIES),
+ "promotion_allowed": False,
+ }
+ if layer:
+ packet["layer"] = layer
+ return packet
+
+
+def _classical_default_source_discovery_path(family: str) -> Path:
+ return {
+ "horary": DEFAULT_CLASSICAL_HORARY_SOURCE_DISCOVERY,
+ "electional": DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_DISCOVERY,
+ "zr": DEFAULT_CLASSICAL_ZR_SOURCE_DISCOVERY,
+ }[family]
+
+
+def _classical_default_acquisition_queue_path(family: str) -> Path:
+ return {
+ "horary": DEFAULT_CLASSICAL_HORARY_ACQUISITION_QUEUE,
+ "electional": DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_QUEUE,
+ "zr": DEFAULT_CLASSICAL_ZR_ACQUISITION_QUEUE,
+ }[family]
+
+
+def _classical_default_technique_scope(family: str) -> list[str]:
+ scopes = {
+ "horary": [
+ "horary_significator",
+ "horary_perfection_timing_thresholds",
+ "horary_translation_collection_prohibition",
+ ],
+ "electional": [
+ "electional_goal_specific_rules",
+ "electional_moon_condition",
+ "electional_accepted_rejected_window",
+ ],
+ "zr": [
+ "zr_release_lord",
+ "zr_peak_lord",
+ "zr_profection_arbitration",
+ "zr_loosing_cross_lot",
+ "fortune_spirit_topic_arbitration",
+ ],
+ }
+ return list(scopes[family])
+
+
+def _classical_sample_kind(family: str) -> str:
+ return {
+ "horary": "horary_question",
+ "electional": "electional_accepted_rejected",
+ "zr": "zr_cross_lot_cross_release_event",
+ }[family]
+
+
+def _classical_case_acquisition_missing_fields(item: dict[str, Any], family: str) -> list[str]:
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ casebook = candidate.get("casebook_fields") if isinstance(candidate.get("casebook_fields"), dict) else {}
+ aliases = {
+ "question_text": ("question_text",),
+ "question_datetime_local": ("question_datetime_local", "observed_datetime_text"),
+ "location": ("location", "question_location"),
+ "published_outcome": ("published_outcome", "published_outcome_or_followup_present"),
+ "activity_label": ("activity_label", "goal_type"),
+ "election_datetime_local": ("election_datetime_local", "candidate_datetime_local"),
+ "accepted_or_rejected_label": ("accepted_or_rejected_label", "accepted_or_rejected_label_status"),
+ }
+ missing = []
+ for field in _CLASSICAL_CASE_ACQUISITION_REQUIRED_FIELDS.get(family, []):
+ values = [candidate.get(field), casebook.get(field)]
+ values.extend(casebook.get(alias) for alias in aliases.get(field, ()))
+ value = next((entry for entry in values if entry is not None and entry != "" and entry != []), None)
+ if value is None or value == "" or value == []:
+ missing.append(field)
+ explicit_missing = item.get("missing_for_freeze") if isinstance(item.get("missing_for_freeze"), list) else []
+ for field in explicit_missing:
+ field_name = str(field)
+ alias_values = [candidate.get(field_name), casebook.get(field_name)]
+ alias_values.extend(casebook.get(alias) for alias in aliases.get(field_name, ()))
+ if any(value is not None and value != "" and value != [] for value in alias_values):
+ continue
+ if field_name and field_name not in missing:
+ missing.append(field_name)
+ return missing
+
+
+def _load_classical_source_discovery_summary(path: Path, family: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "family": family,
+ "path": str(path),
+ "reason": "source_discovery_packet_missing",
+ "runtime_case_candidate_count": 0,
+ "promotion_allowed": False,
+ }
+ try:
+ packet = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - defensive audit path
+ return {
+ "status": "blocked",
+ "family": family,
+ "path": str(path),
+ "reason": f"source_discovery_packet_unreadable:{type(exc).__name__}",
+ "runtime_case_candidate_count": 0,
+ "promotion_allowed": False,
+ }
+ receipts = packet.get("source_receipts") if isinstance(packet.get("source_receipts"), list) else []
+ candidates = [
+ row for row in receipts
+ if isinstance(row, dict)
+ and row.get("status") == "candidate_recorded"
+ and row.get("family") == family
+ ]
+ missing_counts: dict[str, int] = {}
+ for candidate in candidates:
+ for field in candidate.get("missing_for_freeze") or []:
+ missing_counts[str(field)] = missing_counts.get(str(field), 0) + 1
+ return {
+ "status": "partial" if candidates else "blocked",
+ "family": family,
+ "path": str(path),
+ "schema": packet.get("schema"),
+ "source_packet_status": packet.get("status"),
+ "runtime_case_candidate_count": len(candidates),
+ "candidate_source_ids": [row.get("source_id") for row in candidates],
+ "candidate_freeze_readiness": [
+ {"source_id": row.get("source_id"), "freeze_readiness": row.get("freeze_readiness")}
+ for row in candidates
+ ],
+ "top_missing_for_freeze": [
+ {"field": field, "missing_source_count": count}
+ for field, count in sorted(missing_counts.items(), key=lambda item: (-item[1], item[0]))
+ ],
+ "promotion_allowed": False,
+ "boundary": "Source discovery candidates are not frozen samples and cannot enter scoring until case-level fields are supplied.",
+ }
+
+
+def _classical_source_candidate_to_freeze_item(row: dict[str, Any], family: str, index: int) -> dict[str, Any]:
+ source_id = str(row.get("source_id") or f"{family}_source_{index}")
+ missing = row.get("missing_for_freeze") if isinstance(row.get("missing_for_freeze"), list) else []
+ source_url = row.get("url")
+ return {
+ "item_id": f"{family}_source_freeze_{source_id}",
+ "source_role": row.get("use_role") or "public source candidate",
+ "source_path": row.get("path"),
+ "source_url": source_url,
+ "source_id": source_id,
+ "current_status": row.get("freeze_readiness") or "blocked_needs_case_level_capture",
+ "missing_for_freeze": missing,
+ "candidate_sample": {
+ "case_id": f"{family}_case_from_{source_id}",
+ "source_refs": [source_url or row.get("path")] if (source_url or row.get("path")) else [],
+ "source_id": source_id,
+ "source_type": row.get("source_type"),
+ "family": family,
+ "casebooks_packet": {
+ "casebook_style": True,
+ "before_outcome_written": True,
+ "after_the_fact_only": False,
+ },
+ "labels": {
+ "blind_frozen": False,
+ "expected_label_visible_to_scorer": False,
+ "label_release_status": "blocked_pending_case_level_freeze",
+ },
+ },
+ "next_action": row.get("allowed_next_action") or "extract_case_level_fields_then_rerun_classical_case_acquisition",
+ "boundary": "Converted from source discovery packet; still not a frozen sample and labels remain unavailable to scoring.",
+ }
+
+
+def _classical_expand_source_freeze_items(row: dict[str, Any], family: str, index: int) -> list[dict[str, Any]]:
+ source_id = str(row.get("source_id") or "")
+ base_item = _classical_source_candidate_to_freeze_item(row, family, index)
+ if family == "horary" and source_id == "skyscript_wit_horary_case_article":
+ cases = [
+ {
+ "case_id": "skyscript_wit_money",
+ "url": "https://www.skyscript.co.uk/money.html",
+ "role": "lost-money horary case",
+ "question_text": "Will I find my money or is it lost?",
+ "question_location": "London",
+ "timezone": "GMT",
+ "observed_time_text": "2.45 pm (GMT)",
+ "chart_image_url": "https://www.skyscript.co.uk/im/horary/charts/money.gif",
+ },
+ {
+ "case_id": "skyscript_wit_ring",
+ "url": "https://www.skyscript.co.uk/ring.html",
+ "role": "lost-ring horary case",
+ "question_text": "Where is my ring?",
+ "question_location": "London",
+ "observed_time_text": "12:30 pm",
+ "chart_image_url": "https://www.skyscript.co.uk/im/horary/charts/rings.gif",
+ },
+ {
+ "case_id": "skyscript_wit_hook",
+ "url": "https://www.skyscript.co.uk/hook.html",
+ "role": "lost-hook horary case",
+ "question_text": "Where is the hook?",
+ "question_location": "Court St Etienne, Belgium",
+ "chart_image_url": "https://www.skyscript.co.uk/im/horary/charts/hook.gif",
+ },
+ {
+ "case_id": "skyscript_wit_glasses",
+ "url": "https://www.skyscript.co.uk/glasses.html",
+ "role": "lost-glasses horary case",
+ "question_text": "Where did I lose my glasses?",
+ "question_location": "London",
+ "chart_image_url": "https://www.skyscript.co.uk/im/horary/charts/glasses.gif",
+ },
+ {
+ "case_id": "skyscript_wit_earring",
+ "url": "https://www.skyscript.co.uk/earring.html",
+ "role": "lost-earring horary case",
+ "question_text": "Where is my diamond earring?",
+ "question_location": "London",
+ "chart_image_url": "https://www.skyscript.co.uk/im/horary/charts/earring.gif",
+ },
+ ]
+ expanded = []
+ for child_index, case in enumerate(cases, start=1):
+ case_id = case["case_id"]
+ url = case["url"]
+ role = case["role"]
+ casebook_fields = {
+ "case_topic": role,
+ "case_url": url,
+ "case_sequence": child_index,
+ "question_text": case["question_text"],
+ "published_outcome_or_followup_present": True,
+ "chart_image_url": case["chart_image_url"],
+ "chart_data_source": "chart_image_ocr_required",
+ "ocr_required_fields": ["question_date", "question_datetime_local"],
+ }
+ for optional_field in ("question_location", "timezone", "observed_time_text"):
+ if case.get(optional_field):
+ casebook_fields[optional_field] = case[optional_field]
+ item = dict(base_item)
+ item.update({
+ "item_id": f"horary_source_freeze_{case_id}",
+ "source_role": role,
+ "source_url": url,
+ "source_id": case_id,
+ "current_status": "blocked_needs_case_level_time_location_or_reviewer_freeze",
+ "missing_for_freeze": [
+ "question_datetime_local",
+ "question_location",
+ "timezone",
+ "outcome_label_hidden_from_scorer",
+ "independent_reviewer_attestation",
+ "freeze_timestamp",
+ "raw_capture_hash",
+ ],
+ "candidate_sample": {
+ **base_item["candidate_sample"],
+ "case_id": f"horary_case_{case_id}",
+ "source_refs": [url],
+ "source_id": case_id,
+ "casebook_fields": casebook_fields,
+ },
+ "next_action": "extract question datetime/location from chart image or page text, then reviewer freezes hidden outcome label",
+ })
+ expanded.append(item)
+ return expanded
+ if family == "electional" and source_id == "skyscript_marriage_election_case_article":
+ item = dict(base_item)
+ item.update({
+ "item_id": "electional_source_freeze_skyscript_marriage_fig4_one_hour",
+ "source_role": "specific published marriage election case: Fig. 4 Marriage Lasted One Hour",
+ "source_url": "https://www.skyscript.co.uk/marriage.html#1h",
+ "source_id": "skyscript_marriage_fig4_one_hour",
+ "current_status": "blocked_pending_reviewer_freeze",
+ "missing_for_freeze": [
+ "outcome_label_hidden_from_scorer",
+ "independent_reviewer_attestation",
+ "freeze_timestamp",
+ "raw_capture_hash",
+ ],
+ "candidate_sample": {
+ **base_item["candidate_sample"],
+ "case_id": "electional_case_skyscript_marriage_fig4_one_hour",
+ "source_refs": ["https://www.skyscript.co.uk/marriage.html#1h"],
+ "source_id": "skyscript_marriage_fig4_one_hour",
+ "casebook_fields": {
+ "goal_type": "marriage",
+ "candidate_datetime_local": "1946-03-17T08:14:00",
+ "timezone": "GMT",
+ "question_location": "Newcastle West, County Limerick, Ireland",
+ "coordinates": "52N25, 9W04",
+ "published_outcome_or_followup_present": True,
+ "accepted_or_rejected_label_status": "hidden_until_reviewer_unseal",
+ },
+ },
+ "next_action": "reviewer freezes hidden accepted/rejected outcome label after scoring manifest is written",
+ })
+ return [item]
+ return [base_item]
+
+
+def build_classical_source_freeze_conversion_packet(
+ *,
+ family: str,
+ source_packet_path: Path | str | None = None,
+ output_queue_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.source_freeze_conversion.v1", family)
+ packet_path = Path(source_packet_path) if source_packet_path else _classical_default_source_discovery_path(family)
+ if not packet_path.exists():
+ return {
+ "schema": "classical.source_freeze_conversion.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "source_packet_path": str(packet_path),
+ "reason": "source_discovery_packet_missing",
+ "promotion_allowed": False,
+ }
+ source_packet = json.loads(packet_path.read_text(encoding="utf-8"))
+ receipts = source_packet.get("source_receipts") if isinstance(source_packet.get("source_receipts"), list) else []
+ source_rows = [
+ row for row in receipts
+ if isinstance(row, dict)
+ and row.get("status") == "candidate_recorded"
+ and row.get("family") == family
+ and str(row.get("source_type") or "").startswith("public_")
+ ]
+ items = [
+ item
+ for index, row in enumerate(source_rows, start=1)
+ for item in _classical_expand_source_freeze_items(row, family, index)
+ ]
+ default_scope = _classical_default_technique_scope(family)
+ queue = {
+ "manifest_id": f"classical_{family}_source_freeze_conversion_queue_runtime",
+ "schema": "classical.case_acquisition_queue.v1",
+ "system": "classical_astrology",
+ "sample_family": family,
+ "source_packet_path": str(packet_path),
+ "source_packet_schema": source_packet.get("schema"),
+ "default_technique_scope": default_scope,
+ "items": items,
+ "promotion_allowed": False,
+ "boundary": "Queue is converted from public source candidates only. It is not a blind scoring manifest and contains no outcome labels.",
+ }
+ runner = {
+ "status": "partial" if items else "blocked",
+ "item_count": len(items),
+ "ready_to_freeze_count": 0,
+ "blocked_item_count": len(items),
+ "missing_field_summary": [],
+ }
+ if output_queue_path:
+ queue_path = Path(output_queue_path)
+ queue_path.parent.mkdir(parents=True, exist_ok=True)
+ queue_path.write_text(json.dumps(queue, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ runner = build_classical_case_acquisition_runner_packet(family=family, queue_path=queue_path)
+ packet = {
+ "schema": "classical.source_freeze_conversion.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if items else "blocked",
+ "source_packet_path": str(packet_path),
+ "source_candidate_count": len(source_rows),
+ "converted_queue_item_count": len(items),
+ "ready_to_freeze_count": int(runner.get("ready_to_freeze_count") or 0),
+ "blocked_item_count": int(runner.get("blocked_item_count") or 0),
+ "queue_preview": queue,
+ "case_acquisition_runner_summary": {
+ "status": runner.get("status"),
+ "item_count": runner.get("item_count", 0),
+ "ready_to_freeze_count": runner.get("ready_to_freeze_count", 0),
+ "blocked_item_count": runner.get("blocked_item_count", 0),
+ "missing_field_summary": runner.get("missing_field_summary", []),
+ "promotion_allowed": False,
+ },
+ "promotion_allowed": False,
+ "boundary": "Conversion creates case-level intake rows from public source packets; it does not freeze, score, unseal, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def _fetch_public_case_source_metadata(url: str) -> dict[str, Any]:
+ try:
+ request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 classical-case-field-extraction"})
+ with urllib.request.urlopen(request, timeout=12) as response:
+ body = response.read(250_000)
+ text = body.decode(response.headers.get_content_charset() or "utf-8", errors="replace")
+ title_match = re.search(r"
]*>(.*?)", text, re.IGNORECASE | re.DOTALL)
+ title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else None
+ plain_text = re.sub(r"<[^>]+>", " ", text)
+ plain_text = re.sub(r"\s+", " ", plain_text)
+ base_url = url.rsplit("/", 1)[0] + "/"
+ chart_image_urls: list[str] = []
+ chart_image_alts: list[str] = []
+ for image_match in re.finditer(r"
]+>", text, re.IGNORECASE):
+ tag = image_match.group(0)
+ if not re.search(r"im/horary/charts|im/elections|im/election|marriage\.gif", tag, re.IGNORECASE):
+ continue
+ src_match = re.search(r"\bsrc=[\"']([^\"']+)[\"']", tag, re.IGNORECASE)
+ alt_match = re.search(r"\balt=[\"']([^\"']+)[\"']", tag, re.IGNORECASE)
+ if src_match:
+ src = src_match.group(1)
+ if src.startswith("http://") or src.startswith("https://"):
+ chart_image_urls.append(src.replace("http://skyscript.co.uk/", "https://www.skyscript.co.uk/"))
+ elif src.startswith("/"):
+ chart_image_urls.append("https://www.skyscript.co.uk" + src)
+ else:
+ chart_image_urls.append(urllib.parse.urljoin(base_url, src))
+ if alt_match:
+ chart_image_alts.append(re.sub(r"\s+", " ", alt_match.group(1)).strip())
+ snippet_patterns = {
+ "time_snippets": r"\b\d{1,2}[.:]\d{2}\s*(?:am|pm|AM|PM)?\b[^.;<]{0,80}",
+ "timezone_snippets": r"\b(?:GMT|BST|UT|UTC|EST|PST)\b[^.;<]{0,80}",
+ "date_snippets": r"\b\d{1,2}(?:st|nd|rd|th)?\s+[A-Z][a-z]+\s+\d{4}\b[^.;<]{0,80}",
+ "place_snippets": r"\b(?:London|England|France|Paris|USA|Ireland|County|Newcastle|Surrey|Kent|California|Australia)\b[^.;<]{0,100}",
+ }
+ snippets = {
+ key: list(dict.fromkeys(
+ re.sub(r"\s+", " ", match.group(0)).strip()
+ for match in re.finditer(pattern, plain_text)
+ ))[:6]
+ for key, pattern in snippet_patterns.items()
+ }
+ signal_patterns = {
+ "question": r"\bquestion\b|querent|where is|will\b",
+ "chart": r"\bchart\b|figure|horoscope",
+ "time": r"\btime\b|\b\d{1,2}:\d{2}\b|\bAM\b|\bPM\b",
+ "location": r"\blocation\b|\bLondon\b|\bEngland\b|\bUSA\b|\bUK\b",
+ "outcome": r"\boutcome\b|result|found|happened|marriage|divorce|success|failure",
+ "accepted_rejected": r"accepted|rejected|alternative|chosen|elected",
+ "zodiacal_releasing": r"zodiacal releasing|releasing from (fortune|spirit)|loosing of the bond|loosing|peak period",
+ }
+ return {
+ "fetch_status": "fetched",
+ "http_status": getattr(response, "status", None),
+ "content_type": response.headers.get("content-type"),
+ "raw_capture_hash": hashlib.sha256(body).hexdigest(),
+ "captured_bytes": len(body),
+ "page_title": title,
+ "chart_image_urls": chart_image_urls[:8],
+ "chart_image_alts": chart_image_alts[:8],
+ "extracted_snippets": snippets,
+ "signals": {
+ key: bool(re.search(pattern, text, re.IGNORECASE))
+ for key, pattern in signal_patterns.items()
+ },
+ }
+ except urllib.error.HTTPError as exc:
+ return {
+ "fetch_status": "rejected_or_unavailable",
+ "http_status": exc.code,
+ "error": f"HTTPError:{exc.code}",
+ "raw_capture_hash": None,
+ "signals": {},
+ }
+ except Exception as exc: # pragma: no cover - network edge
+ return {
+ "fetch_status": "rejected_or_unavailable",
+ "http_status": None,
+ "error": type(exc).__name__,
+ "raw_capture_hash": None,
+ "signals": {},
+ }
+
+
+def _case_field_extraction_status(item: dict[str, Any], metadata: dict[str, Any], family: str) -> tuple[str, list[str], str | None]:
+ if metadata.get("fetch_status") != "fetched":
+ return "rejected_or_unavailable", item.get("missing_for_freeze") or [], metadata.get("error") or "source_unavailable"
+ signals = metadata.get("signals") if isinstance(metadata.get("signals"), dict) else {}
+ missing = list(item.get("missing_for_freeze") or [])
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ casebook = candidate.get("casebook_fields") if isinstance(candidate.get("casebook_fields"), dict) else {}
+ if metadata.get("raw_capture_hash") and "raw_capture_hash" in missing:
+ missing.remove("raw_capture_hash")
+ if metadata.get("page_title"):
+ for field in ("specific_case_url", "specific_article_url", "specific_thread_url"):
+ if field in missing:
+ missing.remove(field)
+ if signals.get("time") and family == "horary" and "case_level_question_metadata" in missing:
+ missing.remove("case_level_question_metadata")
+ if signals.get("outcome") and "outcome_or_followup" in missing:
+ missing.remove("outcome_or_followup")
+ if signals.get("outcome") and "published_outcome_or_followup" in missing:
+ missing.remove("published_outcome_or_followup")
+ if signals.get("outcome") and "published_event_outcome" in missing:
+ missing.remove("published_event_outcome")
+ if signals.get("zodiacal_releasing") and "lot_basis_fortune_or_spirit" in missing:
+ missing.remove("lot_basis_fortune_or_spirit")
+ if signals.get("zodiacal_releasing") and "release_level_scope" in missing:
+ missing.remove("release_level_scope")
+ if casebook.get("candidate_datetime_local"):
+ for field in ("candidate_datetime_local", "chosen_datetime_local", "accepted_rejected_times"):
+ if field in missing:
+ missing.remove(field)
+ if casebook.get("question_datetime_local") and "question_datetime_local" in missing:
+ missing.remove("question_datetime_local")
+ if casebook.get("goal_type") and "goal_type" in missing:
+ missing.remove("goal_type")
+ if casebook.get("question_location") and "question_location" in missing:
+ missing.remove("question_location")
+ if casebook.get("timezone") and "timezone" in missing:
+ missing.remove("timezone")
+ if casebook.get("published_outcome_or_followup_present"):
+ for field in ("published_outcome_or_followup", "outcome_or_followup", "specific_real_event"):
+ if field in missing:
+ missing.remove(field)
+ if not missing:
+ return "ready_to_freeze_pending_reviewer", [], None
+ return "blocked_missing_case_level_fields", missing, None
+
+
+def build_classical_case_level_field_extraction_packet(
+ *,
+ family: str,
+ conversion_queue_path: Path | str | None = None,
+ output_queue_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+ fetch_public_urls: bool = True,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.case_level_field_extraction.v1", family)
+ queue_path = Path(conversion_queue_path) if conversion_queue_path else Path(
+ f"references/real_case_calibration/classical_{family}_source_freeze_conversion_queue_2026_08_30.json"
+ )
+ if not queue_path.exists():
+ return {
+ "schema": "classical.case_level_field_extraction.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "queue_path": str(queue_path),
+ "reason": "conversion_queue_missing",
+ "promotion_allowed": False,
+ }
+ queue = json.loads(queue_path.read_text(encoding="utf-8"))
+ rows: list[dict[str, Any]] = []
+ extracted_items: list[dict[str, Any]] = []
+ rejected_sources: list[dict[str, Any]] = []
+ for index, item in enumerate(queue.get("items") if isinstance(queue.get("items"), list) else [], start=1):
+ if not isinstance(item, dict):
+ continue
+ source_url = str(item.get("source_url") or "")
+ metadata = _fetch_public_case_source_metadata(source_url) if fetch_public_urls and source_url else {
+ "fetch_status": "not_fetched",
+ "raw_capture_hash": None,
+ "signals": {},
+ }
+ extraction_status, missing_after, rejection_reason = _case_field_extraction_status(item, metadata, family)
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ extracted_candidate = dict(candidate)
+ extracted_candidate.update({
+ "specific_case_url": source_url or None,
+ "source_page_title": metadata.get("page_title"),
+ "raw_capture_hash": metadata.get("raw_capture_hash"),
+ "case_level_extraction_status": extraction_status,
+ "labels": {
+ "blind_frozen": False,
+ "expected_label_visible_to_scorer": False,
+ "label_release_status": "blocked_pending_independent_reviewer_unseal",
+ },
+ })
+ extracted_item = dict(item)
+ extracted_item.update({
+ "item_id": item.get("item_id") or f"{family}_field_extraction_{index}",
+ "current_status": extraction_status,
+ "missing_for_freeze": missing_after,
+ "candidate_sample": extracted_candidate,
+ "case_level_field_extraction": {
+ "fetch_status": metadata.get("fetch_status"),
+ "http_status": metadata.get("http_status"),
+ "content_type": metadata.get("content_type"),
+ "page_title": metadata.get("page_title"),
+ "captured_bytes": metadata.get("captured_bytes"),
+ "raw_capture_hash": metadata.get("raw_capture_hash"),
+ "chart_image_urls": metadata.get("chart_image_urls", []),
+ "chart_image_alts": metadata.get("chart_image_alts", []),
+ "extracted_snippets": metadata.get("extracted_snippets", {}),
+ "signals": metadata.get("signals", {}),
+ "rejection_reason": rejection_reason,
+ },
+ "boundary": "Case-level extraction stores metadata and hashes only; outcome labels remain absent from scoring.",
+ })
+ extracted_items.append(extracted_item)
+ row = {
+ "item_id": extracted_item["item_id"],
+ "source_url": source_url,
+ "field_extraction_status": extraction_status,
+ "fetch_status": metadata.get("fetch_status"),
+ "http_status": metadata.get("http_status"),
+ "page_title": metadata.get("page_title"),
+ "raw_capture_hash_present": bool(metadata.get("raw_capture_hash")),
+ "chart_image_url_count": len(metadata.get("chart_image_urls") or []),
+ "time_snippet_count": len((metadata.get("extracted_snippets") or {}).get("time_snippets") or []),
+ "place_snippet_count": len((metadata.get("extracted_snippets") or {}).get("place_snippets") or []),
+ "signals": metadata.get("signals", {}),
+ "missing_after_extraction": missing_after,
+ "rejection_reason": rejection_reason,
+ "promotion_allowed": False,
+ }
+ rows.append(row)
+ if extraction_status == "rejected_or_unavailable":
+ rejected_sources.append(row)
+ extracted_queue = dict(queue)
+ extracted_queue.update({
+ "manifest_id": f"classical_{family}_case_level_field_extraction_queue_runtime",
+ "schema": "classical.case_acquisition_queue.v1",
+ "field_extraction_source_queue_path": str(queue_path),
+ "items": extracted_items,
+ "promotion_allowed": False,
+ "boundary": "Extracted queue contains source metadata and capture hashes only; it is still not a frozen blind sample packet.",
+ })
+ if output_queue_path:
+ out_queue = Path(output_queue_path)
+ out_queue.parent.mkdir(parents=True, exist_ok=True)
+ out_queue.write_text(json.dumps(extracted_queue, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ ready_count = sum(1 for row in rows if row["field_extraction_status"] == "ready_to_freeze_pending_reviewer")
+ packet = {
+ "schema": "classical.case_level_field_extraction.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "queue_path": str(queue_path),
+ "item_count": len(rows),
+ "fetched_count": sum(1 for row in rows if row["fetch_status"] == "fetched"),
+ "rejected_or_unavailable_count": len(rejected_sources),
+ "ready_to_freeze_pending_reviewer_count": ready_count,
+ "blocked_after_extraction_count": sum(1 for row in rows if row["field_extraction_status"] == "blocked_missing_case_level_fields"),
+ "rows": rows,
+ "rejected_or_unavailable_sources": rejected_sources,
+ "output_queue_path": str(output_queue_path) if output_queue_path else None,
+ "promotion_allowed": False,
+ "boundary": "Field extraction narrows freeze blockers; it does not freeze samples, expose labels, score, unseal, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def _classical_item_chart_image_urls(item: dict[str, Any]) -> list[str]:
+ urls: list[str] = []
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ casebook = candidate.get("casebook_fields") if isinstance(candidate.get("casebook_fields"), dict) else {}
+ if casebook.get("chart_image_url"):
+ urls.append(str(casebook["chart_image_url"]))
+ extraction = item.get("case_level_field_extraction") if isinstance(item.get("case_level_field_extraction"), dict) else {}
+ for url in extraction.get("chart_image_urls") or []:
+ if url:
+ urls.append(str(url))
+ return list(dict.fromkeys(urls))
+
+
+def build_classical_chart_ocr_cache_packet(
+ *,
+ family: str,
+ extracted_queue_path: Path | str | None = None,
+ output_cache_dir: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.chart_ocr_cache.v1", family)
+ queue_path = Path(extracted_queue_path) if extracted_queue_path else Path(
+ f"references/real_case_calibration/classical_{family}_case_level_extracted_queue_2026_08_30.json"
+ )
+ cache_dir = Path(output_cache_dir) if output_cache_dir else Path(
+ f"references/real_case_calibration/chart_ocr_cache/{family}"
+ )
+ if not queue_path.exists():
+ return {
+ "schema": "classical.chart_ocr_cache.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "extracted_queue_missing",
+ "queue_path": str(queue_path),
+ "promotion_allowed": False,
+ }
+ queue = json.loads(queue_path.read_text(encoding="utf-8"))
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ tesseract_path = shutil.which("tesseract")
+ swift_path = shutil.which("swift")
+ macos_vision_script = Path(__file__).resolve().parent / "macos_vision_ocr.swift"
+ macos_vision_available = bool(swift_path and macos_vision_script.exists())
+ selected_backend = "tesseract" if tesseract_path else "macos_vision" if macos_vision_available else None
+ rows: list[dict[str, Any]] = []
+ for item in queue.get("items") if isinstance(queue.get("items"), list) else []:
+ if not isinstance(item, dict):
+ continue
+ item_id = str(item.get("item_id") or "unknown_item")
+ for index, image_url in enumerate(_classical_item_chart_image_urls(item), start=1):
+ safe_item = re.sub(r"[^A-Za-z0-9_.-]+", "_", item_id).strip("_") or "chart"
+ image_suffix = Path(urllib.parse.urlparse(image_url).path).suffix or ".img"
+ image_path = cache_dir / f"{safe_item}_{index}{image_suffix}"
+ text_path = cache_dir / f"{safe_item}_{index}.ocr.txt"
+ row = {
+ "item_id": item_id,
+ "case_id": (item.get("candidate_sample") or {}).get("case_id") if isinstance(item.get("candidate_sample"), dict) else None,
+ "image_url": image_url,
+ "image_cache_path": str(image_path),
+ "ocr_text_path": str(text_path),
+ "image_hash": None,
+ "image_bytes": 0,
+ "download_status": "not_attempted",
+ "ocr_backend": selected_backend,
+ "ocr_status": "blocked_no_ocr_backend" if not selected_backend else "not_attempted",
+ "ocr_text_preview": None,
+ "promotion_allowed": False,
+ }
+ try:
+ # Check the filesystem before parsing as a URL. On Windows
+ # urlparse reads the drive letter of "D:\\...\\chart.png" as the
+ # scheme "d", so a local file fell through to the HTTP branch
+ # and never downloaded. See ERR-188.
+ local_candidate = Path(image_url)
+ parsed_url = urllib.parse.urlparse(image_url)
+ if local_candidate.exists():
+ image_bytes = local_candidate.read_bytes()
+ elif parsed_url.scheme in ("", "file") and Path(parsed_url.path or image_url).exists():
+ image_bytes = Path(parsed_url.path or image_url).read_bytes()
+ else:
+ request = urllib.request.Request(image_url, headers={"User-Agent": "Mozilla/5.0 classical-chart-ocr-cache"})
+ with urllib.request.urlopen(request, timeout=15) as response:
+ image_bytes = response.read(2_000_000)
+ image_path.write_bytes(image_bytes)
+ row.update({
+ "download_status": "downloaded",
+ "image_hash": hashlib.sha256(image_bytes).hexdigest(),
+ "image_bytes": len(image_bytes),
+ })
+ except Exception as exc: # pragma: no cover - network edge
+ row.update({
+ "download_status": "rejected_or_unavailable",
+ "download_error": type(exc).__name__,
+ "ocr_status": "blocked_image_unavailable",
+ })
+ rows.append(row)
+ continue
+ if selected_backend == "tesseract":
+ try:
+ from PIL import Image
+ import pytesseract
+ ocr_text = pytesseract.image_to_string(Image.open(image_path))
+ text_path.write_text(ocr_text, encoding="utf-8", newline="\n")
+ row.update({
+ "ocr_status": "ocr_text_cached" if ocr_text.strip() else "partial_empty_ocr_text",
+ "ocr_text_preview": re.sub(r"\s+", " ", ocr_text).strip()[:240],
+ })
+ except Exception as exc: # pragma: no cover - OCR edge
+ row.update({
+ "ocr_status": "blocked_ocr_failed",
+ "ocr_error": type(exc).__name__,
+ })
+ elif selected_backend == "macos_vision":
+ try:
+ completed = subprocess.run(
+ [str(swift_path), str(macos_vision_script), str(image_path)],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if completed.returncode != 0:
+ row.update({
+ "ocr_status": "blocked_ocr_failed",
+ "ocr_error": (completed.stderr or completed.stdout or "").strip()[:240],
+ })
+ else:
+ vision_payload = json.loads(completed.stdout)
+ text_items = [
+ str(entry.get("text") or "")
+ for entry in vision_payload.get("items") or []
+ if isinstance(entry, dict) and str(entry.get("text") or "").strip()
+ ]
+ ocr_text = "\n".join(text_items)
+ text_path.write_text(ocr_text, encoding="utf-8", newline="\n")
+ row.update({
+ "ocr_status": "ocr_text_cached" if ocr_text.strip() else "partial_empty_ocr_text",
+ "ocr_text_preview": re.sub(r"\s+", " ", ocr_text).strip()[:240],
+ "ocr_text_item_count": len(text_items),
+ })
+ except Exception as exc: # pragma: no cover - OCR edge
+ row.update({
+ "ocr_status": "blocked_ocr_failed",
+ "ocr_error": type(exc).__name__,
+ })
+ rows.append(row)
+ downloaded_count = sum(1 for row in rows if row["download_status"] == "downloaded")
+ ocr_cached_count = sum(1 for row in rows if row["ocr_status"] == "ocr_text_cached")
+ packet = {
+ "schema": "classical.chart_ocr_cache.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "queue_path": str(queue_path),
+ "cache_dir": str(cache_dir),
+ "tesseract_path": tesseract_path,
+ "swift_path": swift_path,
+ "macos_vision_script": str(macos_vision_script),
+ "selected_ocr_backend": selected_backend,
+ "chart_image_count": len(rows),
+ "downloaded_count": downloaded_count,
+ "ocr_cached_count": ocr_cached_count,
+ "blocked_ocr_count": sum(1 for row in rows if str(row["ocr_status"]).startswith("blocked")),
+ "rows": rows,
+ "next_action": "Promote readable OCR snippets into case-level field extraction after manual/reviewer confirmation.",
+ "promotion_allowed": False,
+ "boundary": "OCR cache is evidence capture only. It does not freeze samples, expose outcome labels, score, unseal, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def _parse_horary_ocr_question_metadata(text: str, fallback_location: str | None = None) -> dict[str, Any]:
+ clean = re.sub(r"\s+", " ", text or "").strip()
+ match = re.search(
+ r"(\d{1,2}):(\d{2})\s*(am|pm)\s*([A-Z]{2,5})\b.{0,45}?"
+ r"(\d{1,2})\s+([A-Z][a-z]+)\s+(\d{4})(?:\s+([A-Za-z][A-Za-z .'-]{1,60}))?",
+ clean,
+ )
+ if not match:
+ return {"status": "blocked_no_datetime_pattern", "promotion_allowed": False}
+ hour = int(match.group(1))
+ minute = int(match.group(2))
+ meridiem = match.group(3).lower()
+ if meridiem == "pm" and hour != 12:
+ hour += 12
+ elif meridiem == "am" and hour == 12:
+ hour = 0
+ date_text = f"{match.group(5)} {match.group(6)} {match.group(7)}"
+ try:
+ parsed_date = datetime.strptime(date_text, "%d %B %Y")
+ except ValueError:
+ return {"status": "blocked_unparseable_date", "date_text": date_text, "promotion_allowed": False}
+ location_candidate = re.sub(r"[^A-Za-z .'-].*$", "", match.group(8) or "").strip(" .'-")
+ location = fallback_location or location_candidate
+ return {
+ "status": "parsed_candidate",
+ "question_datetime_local": parsed_date.replace(hour=hour, minute=minute).strftime("%Y-%m-%dT%H:%M:00"),
+ "timezone": match.group(4),
+ "question_location": location,
+ "raw_ocr_match": match.group(0)[:220],
+ "promotion_allowed": False,
+ }
+
+
+def build_classical_ocr_field_backfill_packet(
+ *,
+ family: str,
+ extracted_queue_path: Path | str | None = None,
+ ocr_cache_packet_path: Path | str | None = None,
+ output_queue_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.ocr_field_backfill.v1", family)
+ queue_path = Path(extracted_queue_path) if extracted_queue_path else Path(
+ f"references/real_case_calibration/classical_{family}_case_level_extracted_queue_2026_08_30.json"
+ )
+ ocr_path = Path(ocr_cache_packet_path) if ocr_cache_packet_path else Path(
+ f"references/real_case_calibration/classical_{family}_chart_ocr_cache_2026_08_31.json"
+ )
+ if not queue_path.exists() or not ocr_path.exists():
+ return {
+ "schema": "classical.ocr_field_backfill.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "extracted_queue_or_ocr_packet_missing",
+ "queue_path": str(queue_path),
+ "ocr_cache_packet_path": str(ocr_path),
+ "promotion_allowed": False,
+ }
+ queue = json.loads(queue_path.read_text(encoding="utf-8"))
+ ocr_packet = json.loads(ocr_path.read_text(encoding="utf-8"))
+ ocr_by_item: dict[str, list[dict[str, Any]]] = {}
+ for row in ocr_packet.get("rows") if isinstance(ocr_packet.get("rows"), list) else []:
+ if isinstance(row, dict) and row.get("item_id"):
+ ocr_by_item.setdefault(str(row["item_id"]), []).append(row)
+ rows: list[dict[str, Any]] = []
+ backfilled_items: list[dict[str, Any]] = []
+ for item in queue.get("items") if isinstance(queue.get("items"), list) else []:
+ if not isinstance(item, dict):
+ continue
+ item_id = str(item.get("item_id") or "")
+ new_item = dict(item)
+ candidate = dict(item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {})
+ casebook = dict(candidate.get("casebook_fields") if isinstance(candidate.get("casebook_fields"), dict) else {})
+ parsed = None
+ if family == "horary":
+ for ocr_row in ocr_by_item.get(item_id, []):
+ text_path = ocr_row.get("ocr_text_path")
+ if ocr_row.get("ocr_status") != "ocr_text_cached" or not text_path or not Path(text_path).exists():
+ continue
+ candidate_parse = _parse_horary_ocr_question_metadata(
+ Path(text_path).read_text(encoding="utf-8"),
+ fallback_location=casebook.get("question_location"),
+ )
+ if candidate_parse.get("status") == "parsed_candidate":
+ parsed = candidate_parse
+ break
+ if parsed:
+ casebook.update({
+ "question_datetime_local": parsed["question_datetime_local"],
+ "question_location": parsed["question_location"],
+ "timezone": parsed["timezone"],
+ "ocr_question_metadata_status": "candidate_requires_reviewer_confirmation",
+ "ocr_raw_match": parsed.get("raw_ocr_match"),
+ })
+ candidate["casebook_fields"] = casebook
+ new_item["candidate_sample"] = candidate
+ remaining_missing = _case_field_extraction_status(new_item, {
+ "fetch_status": "fetched",
+ "raw_capture_hash": (item.get("case_level_field_extraction") or {}).get("raw_capture_hash")
+ if isinstance(item.get("case_level_field_extraction"), dict) else None,
+ "page_title": (item.get("case_level_field_extraction") or {}).get("page_title")
+ if isinstance(item.get("case_level_field_extraction"), dict) else None,
+ "signals": {"outcome": True},
+ }, family)[1]
+ new_item["missing_for_freeze"] = remaining_missing
+ new_item["current_status"] = (
+ "ready_to_freeze_pending_reviewer"
+ if not remaining_missing
+ else "blocked_missing_case_level_fields_after_ocr_backfill"
+ )
+ else:
+ remaining_missing = list(item.get("missing_for_freeze") or [])
+ new_item["ocr_field_backfill"] = {
+ "status": parsed.get("status") if parsed else "blocked_no_parseable_ocr_metadata",
+ "source_ocr_row_count": len(ocr_by_item.get(item_id, [])),
+ "promotion_allowed": False,
+ }
+ backfilled_items.append(new_item)
+ rows.append({
+ "item_id": item_id,
+ "status": new_item["ocr_field_backfill"]["status"],
+ "question_datetime_local": casebook.get("question_datetime_local"),
+ "question_location": casebook.get("question_location"),
+ "timezone": casebook.get("timezone"),
+ "missing_after_backfill": remaining_missing,
+ "promotion_allowed": False,
+ })
+ backfilled_queue = dict(queue)
+ backfilled_queue.update({
+ "manifest_id": f"classical_{family}_ocr_field_backfilled_queue_runtime",
+ "schema": "classical.case_acquisition_queue.v1",
+ "ocr_cache_packet_path": str(ocr_path),
+ "items": backfilled_items,
+ "promotion_allowed": False,
+ "boundary": "OCR backfill writes candidate metadata only; reviewer confirmation is still required before freeze.",
+ })
+ if output_queue_path:
+ out_queue = Path(output_queue_path)
+ out_queue.parent.mkdir(parents=True, exist_ok=True)
+ out_queue.write_text(json.dumps(backfilled_queue, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet = {
+ "schema": "classical.ocr_field_backfill.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "queue_path": str(queue_path),
+ "ocr_cache_packet_path": str(ocr_path),
+ "item_count": len(rows),
+ "parsed_candidate_count": sum(1 for row in rows if row["status"] == "parsed_candidate"),
+ "ready_to_freeze_pending_reviewer_count": sum(1 for row in rows if row["missing_after_backfill"] == []),
+ "rows": rows,
+ "promotion_allowed": False,
+ "boundary": "OCR-derived fields are candidate metadata and cannot expose labels, score, unseal, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+_CLASSICAL_REVIEWER_ONLY_FIELDS = {
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ "independent_reviewer_attestation",
+ "outcome_label_hidden_from_scorer",
+}
+
+
+def build_classical_freeze_admission_packet(
+ *,
+ family: str,
+ extracted_queue_path: Path | str | None = None,
+ output_reviewer_todo_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.freeze_admission.v1", family)
+ queue_path = Path(extracted_queue_path) if extracted_queue_path else Path(
+ f"references/real_case_calibration/classical_{family}_case_level_extracted_queue_2026_08_30.json"
+ )
+ if not queue_path.exists():
+ return {
+ "schema": "classical.freeze_admission.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "queue_path": str(queue_path),
+ "reason": "extracted_queue_missing",
+ "promotion_allowed": False,
+ }
+ queue = json.loads(queue_path.read_text(encoding="utf-8"))
+ rows: list[dict[str, Any]] = []
+ reviewer_todo: list[dict[str, Any]] = []
+ for index, item in enumerate(queue.get("items") if isinstance(queue.get("items"), list) else [], start=1):
+ if not isinstance(item, dict):
+ continue
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ extraction = item.get("case_level_field_extraction") if isinstance(item.get("case_level_field_extraction"), dict) else {}
+ missing = item.get("missing_for_freeze") if isinstance(item.get("missing_for_freeze"), list) else []
+ missing_set = {str(field) for field in missing if field}
+ non_reviewer_missing = sorted(missing_set - _CLASSICAL_REVIEWER_ONLY_FIELDS)
+ if extraction.get("fetch_status") != "fetched":
+ admission_status = "rejected_source_unavailable"
+ next_action = "replace_with_accessible_public_case_source"
+ elif not missing_set:
+ admission_status = "ready_to_freeze"
+ next_action = "freeze_sample_packet_then_run_blind_intake"
+ elif not non_reviewer_missing:
+ admission_status = "reviewer_todo"
+ next_action = "independent_reviewer_confirms_outcome_label_independence_and_freeze_timestamp"
+ else:
+ admission_status = "blocked_needs_case_field_extraction"
+ next_action = "manually_extract_specific_case_fields_before_reviewer_unseal"
+ row = {
+ "item_id": item.get("item_id") or f"{family}_freeze_admission_{index}",
+ "source_url": item.get("source_url"),
+ "case_id": candidate.get("case_id"),
+ "admission_status": admission_status,
+ "fetch_status": extraction.get("fetch_status"),
+ "http_status": extraction.get("http_status"),
+ "page_title": extraction.get("page_title"),
+ "raw_capture_hash_present": bool(extraction.get("raw_capture_hash") or candidate.get("raw_capture_hash")),
+ "missing_for_freeze": sorted(missing_set),
+ "non_reviewer_missing": non_reviewer_missing,
+ "reviewer_missing": sorted(missing_set & _CLASSICAL_REVIEWER_ONLY_FIELDS),
+ "next_action": next_action,
+ "promotion_allowed": False,
+ }
+ rows.append(row)
+ if admission_status == "reviewer_todo":
+ reviewer_todo.append({
+ "case_id": candidate.get("case_id"),
+ "source_url": item.get("source_url"),
+ "page_title": extraction.get("page_title"),
+ "raw_capture_hash": extraction.get("raw_capture_hash") or candidate.get("raw_capture_hash"),
+ "required_reviewer_fields": row["reviewer_missing"],
+ "expected_label": "",
+ "expected_label_visible_to_scorer": False,
+ "label_independence": "",
+ "freeze_timestamp": "",
+ "reviewer_source": "",
+ })
+ status_counts: dict[str, int] = {}
+ for row in rows:
+ status = str(row.get("admission_status") or "unknown")
+ status_counts[status] = status_counts.get(status, 0) + 1
+ missing_field_counts: dict[str, int] = {}
+ for row in rows:
+ for field in row.get("missing_for_freeze", []):
+ field_name = str(field)
+ if field_name:
+ missing_field_counts[field_name] = missing_field_counts.get(field_name, 0) + 1
+ missing_field_summary = [
+ {"field": field, "missing_item_count": count}
+ for field, count in sorted(missing_field_counts.items(), key=lambda item: (-item[1], item[0]))
+ ]
+ admission_rank = {
+ "ready_to_freeze": 0,
+ "reviewer_todo": 1,
+ "blocked_needs_case_field_extraction": 2,
+ "rejected_source_unavailable": 3,
+ }
+ top_actionable_items = [
+ {
+ "item_id": row.get("item_id"),
+ "case_id": row.get("case_id"),
+ "source_url": row.get("source_url"),
+ "admission_status": row.get("admission_status"),
+ "missing_count": len(row.get("missing_for_freeze", [])),
+ "non_reviewer_missing": row.get("non_reviewer_missing", []),
+ "reviewer_missing": row.get("reviewer_missing", []),
+ "next_action": row.get("next_action"),
+ "promotion_allowed": False,
+ }
+ for row in sorted(
+ rows,
+ key=lambda entry: (
+ admission_rank.get(str(entry.get("admission_status")), 99),
+ len(entry.get("non_reviewer_missing", [])),
+ len(entry.get("reviewer_missing", [])),
+ str(entry.get("item_id") or ""),
+ ),
+ )[:10]
+ ]
+ reviewer_todo_manifest = {
+ "schema": "classical.freeze_admission_reviewer_todo.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "claim_status": "reviewer_todo_only",
+ "todo_count": len(reviewer_todo),
+ "items": reviewer_todo,
+ "promotion_allowed": False,
+ "boundary": "Reviewer TODO contains no scorer-visible labels and must be completed only after scoring manifests are frozen.",
+ }
+ if output_reviewer_todo_path:
+ todo_path = Path(output_reviewer_todo_path)
+ todo_path.parent.mkdir(parents=True, exist_ok=True)
+ todo_path.write_text(json.dumps(reviewer_todo_manifest, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet = {
+ "schema": "classical.freeze_admission.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "extracted_queue_path": str(queue_path),
+ "item_count": len(rows),
+ "status_counts": status_counts,
+ "missing_field_summary": missing_field_summary,
+ "top_actionable_items": top_actionable_items,
+ "reviewer_todo_count": len(reviewer_todo),
+ "ready_to_freeze_count": status_counts.get("ready_to_freeze", 0),
+ "blocked_count": status_counts.get("blocked_needs_case_field_extraction", 0),
+ "rejected_count": status_counts.get("rejected_source_unavailable", 0),
+ "rows": rows,
+ "reviewer_todo_manifest": reviewer_todo_manifest,
+ "output_reviewer_todo_path": str(output_reviewer_todo_path) if output_reviewer_todo_path else None,
+ "promotion_allowed": False,
+ "boundary": "Freeze admission separates fetched reviewer-todo candidates from blocked/rejected sources; it does not score, unseal, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def build_classical_reviewer_freeze_packet(
+ *,
+ family: str,
+ reviewer_todo_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_freeze_packet.v1", family)
+ todo_path = Path(reviewer_todo_path) if reviewer_todo_path else Path(
+ f"references/real_case_calibration/classical_{family}_freeze_admission_reviewer_todo_2026_08_30.json"
+ )
+ if not todo_path.exists():
+ return {
+ "schema": "classical.reviewer_freeze_packet.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "reviewer_todo_missing",
+ "reviewer_todo_path": str(todo_path),
+ "promotion_allowed": False,
+ }
+ todo = json.loads(todo_path.read_text(encoding="utf-8"))
+ items = []
+ for index, row in enumerate(todo.get("items") if isinstance(todo.get("items"), list) else [], start=1):
+ if not isinstance(row, dict):
+ continue
+ items.append({
+ "case_id": row.get("case_id") or f"{family}_reviewer_case_{index}",
+ "family": family,
+ "source_url": row.get("source_url"),
+ "page_title": row.get("page_title"),
+ "source_capture_hash": row.get("raw_capture_hash"),
+ "required_reviewer_fields": row.get("required_reviewer_fields") or [
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ "independent_reviewer_attestation",
+ "outcome_label_hidden_from_scorer",
+ ],
+ "reviewer_fields": {
+ "reviewer_source": "",
+ "label_independence": "",
+ "freeze_timestamp": "",
+ "independent_reviewer_attestation": "",
+ "outcome_label_hidden_from_scorer": True,
+ "expected_label": "",
+ "evidence_hash": "",
+ "before_outcome_written": "",
+ "after_the_fact_only": "",
+ },
+ "freeze_contract": {
+ "before_scoring_manifest_written": True,
+ "expected_label_visible_to_scorer": False,
+ "sample_status_after_admission": "frozen_sample_pending_blind_intake",
+ },
+ "promotion_allowed": False,
+ })
+ packet = {
+ "schema": "classical.reviewer_freeze_packet.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if items else "blocked",
+ "reviewer_todo_path": str(todo_path),
+ "item_count": len(items),
+ "items": items,
+ "promotion_allowed": False,
+ "boundary": "Reviewer freeze packet is a fillable evidence template. It must not be used as a scoring manifest and does not promote prediction claims.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def _classical_reviewer_value_missing(value: Any) -> bool:
+ if value is None or value == "" or value == []:
+ return True
+ if isinstance(value, str) and value.strip().startswith("<"):
+ return True
+ return False
+
+
+_CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS = (
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ "independent_reviewer_attestation",
+ "outcome_label_hidden_from_scorer",
+ "expected_label",
+ "evidence_hash",
+ "before_outcome_written",
+ "after_the_fact_only",
+)
+
+
+def _classical_reviewer_evidence_lint(item: dict[str, Any], family: str) -> dict[str, Any]:
+ reviewer_fields = item.get("reviewer_fields") if isinstance(item.get("reviewer_fields"), dict) else {}
+ missing = [
+ field for field in _CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS
+ if _classical_reviewer_value_missing(reviewer_fields.get(field))
+ ]
+ invalid = []
+ if reviewer_fields.get("label_independence") not in {"independent_of_scoring", "independent_public_casebook"}:
+ invalid.append("label_independence")
+ if reviewer_fields.get("independent_reviewer_attestation") is not True:
+ invalid.append("independent_reviewer_attestation")
+ if reviewer_fields.get("outcome_label_hidden_from_scorer") is not True:
+ invalid.append("outcome_label_hidden_from_scorer")
+ if reviewer_fields.get("before_outcome_written") is not True:
+ invalid.append("before_outcome_written")
+ if reviewer_fields.get("after_the_fact_only") is not False:
+ invalid.append("after_the_fact_only")
+ try:
+ if not _classical_reviewer_value_missing(reviewer_fields.get("freeze_timestamp")):
+ datetime.fromisoformat(str(reviewer_fields["freeze_timestamp"]).replace("Z", "+00:00"))
+ except ValueError:
+ invalid.append("freeze_timestamp")
+ invalid = sorted(set(field for field in invalid if field not in missing))
+ return {
+ "case_id": item.get("case_id"),
+ "family": family,
+ "status": "pass" if not missing and not invalid else "blocked",
+ "missing_fields": sorted(missing),
+ "invalid_fields": invalid,
+ "source_url": item.get("source_url"),
+ "page_title": item.get("page_title"),
+ "source_capture_hash": item.get("source_capture_hash"),
+ "json_pointer": f"/items/{item.get('_index', 0)}/reviewer_fields",
+ "allowed_values": {
+ "label_independence": ["independent_of_scoring", "independent_public_casebook"],
+ "independent_reviewer_attestation": [True],
+ "outcome_label_hidden_from_scorer": [True],
+ "before_outcome_written": [True],
+ "after_the_fact_only": [False],
+ "expected_label": _classical_expected_label_options(family),
+ },
+ "promotion_allowed": False,
+ }
+
+
+def _classical_expected_label_options(family: str) -> list[str]:
+ return {
+ "horary": ["positive_found", "negative_not_found", "positive_yes", "negative_no"],
+ "electional": ["positive_accepted_good_outcome", "negative_rejected_or_bad_outcome"],
+ "zr": ["positive_event_container", "negative_non_event_window"],
+ }.get(family, ["positive_event_container", "negative_non_event_window"])
+
+
+def build_classical_reviewer_freeze_admission_packet(
+ *,
+ family: str,
+ reviewer_freeze_packet_path: Path | str,
+ output_sample_packet_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_freeze_admission.v1", family)
+ packet_path = Path(reviewer_freeze_packet_path)
+ if not packet_path.exists():
+ return {
+ "schema": "classical.reviewer_freeze_admission.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "reviewer_freeze_packet_missing",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "promotion_allowed": False,
+ }
+ reviewer_packet = json.loads(packet_path.read_text(encoding="utf-8"))
+ rows: list[dict[str, Any]] = []
+ samples: list[dict[str, Any]] = []
+ required = [
+ "reviewer_source",
+ "label_independence",
+ "freeze_timestamp",
+ "independent_reviewer_attestation",
+ "outcome_label_hidden_from_scorer",
+ "expected_label",
+ "evidence_hash",
+ "before_outcome_written",
+ "after_the_fact_only",
+ ]
+ for item in reviewer_packet.get("items") if isinstance(reviewer_packet.get("items"), list) else []:
+ if not isinstance(item, dict):
+ continue
+ reviewer_fields = item.get("reviewer_fields") if isinstance(item.get("reviewer_fields"), dict) else {}
+ missing = [field for field in required if _classical_reviewer_value_missing(reviewer_fields.get(field))]
+ if reviewer_fields.get("outcome_label_hidden_from_scorer") is not True and "outcome_label_hidden_from_scorer" not in missing:
+ missing.append("outcome_label_hidden_from_scorer")
+ if reviewer_fields.get("label_independence") not in {"independent_of_scoring", "independent_public_casebook"}:
+ if "label_independence" not in missing:
+ missing.append("label_independence")
+ if reviewer_fields.get("before_outcome_written") is not True and "before_outcome_written" not in missing:
+ missing.append("before_outcome_written")
+ if reviewer_fields.get("after_the_fact_only") is not False and "after_the_fact_only" not in missing:
+ missing.append("after_the_fact_only")
+ try:
+ if not _classical_reviewer_value_missing(reviewer_fields.get("freeze_timestamp")):
+ datetime.fromisoformat(str(reviewer_fields["freeze_timestamp"]).replace("Z", "+00:00"))
+ except ValueError:
+ if "freeze_timestamp" not in missing:
+ missing.append("freeze_timestamp")
+ status = "admitted_frozen_sample" if not missing else "blocked_reviewer_evidence_incomplete"
+ row = {
+ "case_id": item.get("case_id"),
+ "family": family,
+ "status": status,
+ "missing_reviewer_fields": sorted(missing),
+ "source_url": item.get("source_url"),
+ "promotion_allowed": False,
+ }
+ rows.append(row)
+ if status == "admitted_frozen_sample":
+ samples.append({
+ "case_id": item.get("case_id"),
+ "domain": family,
+ "sample_status": "frozen_sample_pending_blind_intake",
+ "sample_kind": _classical_sample_kind(family),
+ "source_refs": [item.get("source_url")] if item.get("source_url") else [],
+ "source_capture_hash": item.get("source_capture_hash"),
+ "reviewer_source": reviewer_fields.get("reviewer_source"),
+ "label_independence": reviewer_fields.get("label_independence"),
+ "freeze_timestamp": reviewer_fields.get("freeze_timestamp"),
+ "reviewer_evidence_hash": reviewer_fields.get("evidence_hash"),
+ "labels": {
+ "blind_frozen": True,
+ "expected_label_visible_to_scorer": False,
+ "label_release_status": "withheld_until_independent_unseal",
+ },
+ "casebooks_packet": {
+ "before_outcome_written": reviewer_fields.get("before_outcome_written") is True,
+ "after_the_fact_only": reviewer_fields.get("after_the_fact_only") is True,
+ "reviewer_freeze_admission": "admitted_frozen_sample",
+ },
+ "promotion_allowed": False,
+ })
+ sample_packet = {
+ "manifest_id": f"classical_{family}_reviewer_freeze_admitted_samples_runtime",
+ "schema": "classical.reviewer_freeze_sample_packet.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "claim_status": "frozen_samples_pending_blind_intake",
+ "default_technique_scope": _classical_default_technique_scope(family),
+ "samples": samples,
+ "promotion_allowed": False,
+ "boundary": "Admitted samples hide expected labels from scoring; labels remain only in the reviewer packet for later unseal.",
+ }
+ if output_sample_packet_path:
+ sample_out = Path(output_sample_packet_path)
+ sample_out.parent.mkdir(parents=True, exist_ok=True)
+ sample_out.write_text(json.dumps(sample_packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet = {
+ "schema": "classical.reviewer_freeze_admission.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "item_count": len(rows),
+ "admitted_sample_count": len(samples),
+ "blocked_count": sum(1 for row in rows if row["status"] != "admitted_frozen_sample"),
+ "rows": rows,
+ "sample_packet_preview": sample_packet,
+ "output_sample_packet_path": str(output_sample_packet_path) if output_sample_packet_path else None,
+ "promotion_allowed": False,
+ "boundary": "Reviewer freeze admission validates evidence fields only. It does not score, unseal, calculate statistics, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def _classical_parse_reviewer_answer_value(value: Any) -> Any:
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ stripped = value.strip()
+ if not stripped or stripped.startswith("<"):
+ return None
+ lowered = stripped.lower()
+ if lowered in {"true", "yes", "1"}:
+ return True
+ if lowered in {"false", "no", "0"}:
+ return False
+ return stripped
+ return value
+
+
+def _classical_load_reviewer_answer_rows(answer_sheet_path: Path) -> list[dict[str, Any]]:
+ if answer_sheet_path.suffix.lower() == ".json":
+ data = json.loads(answer_sheet_path.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return [row for row in data if isinstance(row, dict)]
+ rows = data.get("items") or data.get("rows") or data.get("answers")
+ return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
+ with answer_sheet_path.open("r", encoding="utf-8", newline="") as handle:
+ return [dict(row) for row in csv.DictReader(handle)]
+
+
+def build_classical_reviewer_answer_apply_packet(
+ *,
+ family: str,
+ reviewer_freeze_packet_path: Path | str,
+ answer_sheet_path: Path | str,
+ output_filled_packet_path: Path | str | None = None,
+ output_admission_packet_path: Path | str | None = None,
+ output_sample_packet_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_answer_apply.v1", family)
+ packet_path = Path(reviewer_freeze_packet_path)
+ answer_path = Path(answer_sheet_path)
+ reviewer_packet = json.loads(packet_path.read_text(encoding="utf-8"))
+ answer_rows = _classical_load_reviewer_answer_rows(answer_path)
+ answer_by_case = {
+ str(row.get("case_id") or ""): row
+ for row in answer_rows
+ if row.get("case_id")
+ }
+ applied_rows: list[dict[str, Any]] = []
+ filled_packet = json.loads(json.dumps(reviewer_packet, ensure_ascii=False))
+ allowed_labels = set(_classical_expected_label_options(family))
+ reviewer_fields = list(_CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS)
+ for index, item in enumerate(filled_packet.get("items") if isinstance(filled_packet.get("items"), list) else []):
+ if not isinstance(item, dict):
+ continue
+ case_id = str(item.get("case_id") or "")
+ answer = answer_by_case.get(case_id)
+ existing = item.get("reviewer_fields") if isinstance(item.get("reviewer_fields"), dict) else {}
+ fields = dict(existing)
+ copied_fields: list[str] = []
+ rejected_fields: list[dict[str, Any]] = []
+ if answer:
+ for field in reviewer_fields:
+ parsed = _classical_parse_reviewer_answer_value(answer.get(field))
+ if parsed is None:
+ continue
+ if field == "expected_label" and parsed not in allowed_labels:
+ rejected_fields.append({
+ "field": field,
+ "value": parsed,
+ "reason": "expected_label_not_allowed_for_family",
+ })
+ continue
+ fields[field] = parsed
+ copied_fields.append(field)
+ item["reviewer_fields"] = fields
+ lint = _classical_reviewer_evidence_lint({**item, "_index": index}, family)
+ applied_rows.append({
+ "case_id": case_id,
+ "answer_row_present": answer is not None,
+ "copied_fields": sorted(copied_fields),
+ "rejected_fields": rejected_fields,
+ "lint_status": lint.get("status"),
+ "missing_fields": lint.get("missing_fields", []),
+ "invalid_fields": lint.get("invalid_fields", []),
+ "promotion_allowed": False,
+ })
+ if output_filled_packet_path:
+ filled_out = Path(output_filled_packet_path)
+ filled_out.parent.mkdir(parents=True, exist_ok=True)
+ filled_packet["promotion_allowed"] = False
+ filled_packet["answer_apply_boundary"] = (
+ "Reviewer answers were copied verbatim from the answer sheet and linted. "
+ "This step does not infer labels, score, unseal, or promote prediction readiness."
+ )
+ filled_out.write_text(json.dumps(filled_packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ admission = build_classical_reviewer_freeze_admission_packet(
+ family=family,
+ reviewer_freeze_packet_path=output_filled_packet_path or packet_path,
+ output_sample_packet_path=output_sample_packet_path,
+ output_packet_path=output_admission_packet_path,
+ ) if output_filled_packet_path else {
+ "status": "blocked",
+ "reason": "output_filled_packet_required_for_admission",
+ "promotion_allowed": False,
+ }
+ packet = {
+ "schema": "classical.reviewer_answer_apply.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "pass" if applied_rows and all(row["lint_status"] == "pass" for row in applied_rows) else "blocked",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "answer_sheet_path": str(answer_path),
+ "output_filled_packet_path": str(output_filled_packet_path) if output_filled_packet_path else None,
+ "answer_row_count": len(answer_rows),
+ "applied_item_count": len(applied_rows),
+ "pass_item_count": sum(1 for row in applied_rows if row["lint_status"] == "pass"),
+ "blocked_item_count": sum(1 for row in applied_rows if row["lint_status"] != "pass"),
+ "rows": applied_rows,
+ "admission_preview": {
+ "status": admission.get("status"),
+ "admitted_sample_count": admission.get("admitted_sample_count", 0),
+ "blocked_count": admission.get("blocked_count", 0),
+ "output_admission_packet_path": str(output_admission_packet_path) if output_admission_packet_path else None,
+ "output_sample_packet_path": str(output_sample_packet_path) if output_sample_packet_path else None,
+ "promotion_allowed": False,
+ },
+ "promotion_allowed": False,
+ "boundary": "Imports explicit reviewer answers and runs admission. It does not infer missing labels, score, unseal, or promote prediction readiness.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ return packet
+
+
+def build_classical_reviewer_unseal_manifest_from_freeze_packet(
+ *,
+ family: str,
+ reviewer_freeze_packet_path: Path | str,
+ admission_packet_path: Path | str,
+ output_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_unseal_manifest_builder.v1", family)
+ packet_path = Path(reviewer_freeze_packet_path)
+ admission_path = Path(admission_packet_path)
+ if not packet_path.exists() or not admission_path.exists():
+ return {
+ "schema": "classical.reviewer_unseal_manifest_builder.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "reviewer_freeze_or_admission_packet_missing",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "admission_packet_path": str(admission_path),
+ "promotion_allowed": False,
+ }
+ reviewer_packet = json.loads(packet_path.read_text(encoding="utf-8"))
+ admission = json.loads(admission_path.read_text(encoding="utf-8"))
+ admitted_ids = {
+ row.get("case_id")
+ for row in admission.get("rows", [])
+ if isinstance(row, dict) and row.get("status") == "admitted_frozen_sample" and row.get("case_id")
+ }
+ labels = []
+ blocked_rows = []
+ for item in reviewer_packet.get("items") if isinstance(reviewer_packet.get("items"), list) else []:
+ if not isinstance(item, dict):
+ continue
+ case_id = item.get("case_id")
+ if case_id not in admitted_ids:
+ blocked_rows.append({
+ "case_id": case_id,
+ "status": "blocked_not_admitted_by_reviewer_freeze_gate",
+ })
+ continue
+ reviewer_fields = item.get("reviewer_fields") if isinstance(item.get("reviewer_fields"), dict) else {}
+ labels.append({
+ "sample_id": case_id,
+ "domain": family,
+ "expected_label": reviewer_fields.get("expected_label"),
+ "label_source": reviewer_fields.get("reviewer_source"),
+ "reviewer_source": reviewer_fields.get("reviewer_source"),
+ "label_independence": True,
+ "label_independence_mode": reviewer_fields.get("label_independence"),
+ "freeze_timestamp": reviewer_fields.get("freeze_timestamp"),
+ "evidence_hash": reviewer_fields.get("evidence_hash"),
+ "before_outcome_written": reviewer_fields.get("before_outcome_written") is True,
+ "after_the_fact_only": reviewer_fields.get("after_the_fact_only") is True,
+ "label_release_status": "reviewer_unseal_only",
+ "promotion_allowed": False,
+ })
+ manifest = {
+ "manifest_id": f"classical_{family}_reviewer_unseal_manifest_runtime",
+ "schema": "classical.reviewer_unseal_manifest.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "claim_status": "reviewer_unseal_only",
+ "required_protocol": {
+ "label_source": "admitted reviewer freeze packet only",
+ "scoring_recomputed_during_unseal": False,
+ "labels_available_to_scoring": False,
+ "domain_minimum_sample_counts": {"positive": 1, "negative": 1},
+ },
+ "labels": labels,
+ "label_count": len(labels),
+ "blocked_rows": blocked_rows,
+ "blocked_count": len(blocked_rows),
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "boundary": "Generated only for reviewer unseal after freeze admission. It must not be supplied to blind scoring and cannot promote prediction readiness.",
+ }
+ status = "partial" if labels else "blocked"
+ packet = {
+ "schema": "classical.reviewer_unseal_manifest_builder.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": status,
+ "reviewer_freeze_packet_path": str(packet_path),
+ "admission_packet_path": str(admission_path),
+ "output_manifest_path": str(output_manifest_path) if output_manifest_path else None,
+ "label_count": len(labels),
+ "blocked_count": len(blocked_rows),
+ "manifest_preview": manifest,
+ "promotion_allowed": False,
+ "boundary": "This builder bridges admitted reviewer labels into the unseal entrypoint only; scoring remains label-blind.",
+ }
+ if output_manifest_path:
+ out = Path(output_manifest_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ return packet
+
+
+def build_classical_reviewer_evidence_pipeline_packet(
+ *,
+ family: str,
+ reviewer_freeze_packet_path: Path | str,
+ admission_packet_path: Path | str | None = None,
+ sample_packet_path: Path | str | None = None,
+ intake_manifest_path: Path | str | None = None,
+ scoring_manifest_path: Path | str | None = None,
+ reviewer_unseal_manifest_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+ year: int = 1935,
+ month: int = 6,
+ day: int = 21,
+ hour: int = 11,
+ minute: int = 0,
+ second: int = 0,
+ latitude: float = 44.486,
+ longitude: float = 1.842,
+ timezone: str | float = 1,
+ house_system: str = "W",
+ reference_date: str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_evidence_pipeline.v1", family)
+ root = Path("references/real_case_calibration")
+ admission_output = Path(admission_packet_path) if admission_packet_path else root / f"classical_{family}_reviewer_freeze_admission_runtime.json"
+ sample_output = Path(sample_packet_path) if sample_packet_path else root / f"classical_{family}_reviewer_freeze_sample_packet_runtime.json"
+ intake_output = Path(intake_manifest_path) if intake_manifest_path else root / f"classical_{family}_reviewer_freeze_intake_manifest_runtime.json"
+ scoring_output = Path(scoring_manifest_path) if scoring_manifest_path else root / f"classical_{family}_reviewer_freeze_scoring_manifest_runtime.json"
+ unseal_output = Path(reviewer_unseal_manifest_path) if reviewer_unseal_manifest_path else root / f"classical_{family}_reviewer_unseal_manifest_runtime.json"
+ empty_base = root / f"classical_{family}_empty_blind_base_manifest_runtime.json"
+
+ admission = build_classical_reviewer_freeze_admission_packet(
+ family=family,
+ reviewer_freeze_packet_path=reviewer_freeze_packet_path,
+ output_sample_packet_path=sample_output,
+ output_packet_path=admission_output,
+ )
+ unseal_manifest = build_classical_reviewer_unseal_manifest_from_freeze_packet(
+ family=family,
+ reviewer_freeze_packet_path=reviewer_freeze_packet_path,
+ admission_packet_path=admission_output,
+ output_manifest_path=unseal_output,
+ )
+ admitted_count = int(admission.get("admitted_sample_count") or 0)
+ if not empty_base.exists():
+ empty_base.write_text(json.dumps({
+ "manifest_id": f"classical_{family}_empty_blind_base_runtime",
+ "required_protocol": {},
+ "frozen_sample_queue_policy": {},
+ "blockers": [],
+ "cases": [],
+ }, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
+ if admitted_count:
+ intake = build_classical_blind_sample_intake_manifest_packet(
+ sample_packet_path=sample_output,
+ output_manifest_path=intake_output,
+ base_manifest_path=empty_base,
+ )
+ scoring = build_classical_blind_scoring_packet(
+ year=year,
+ month=month,
+ day=day,
+ hour=hour,
+ minute=minute,
+ second=second,
+ latitude=latitude,
+ longitude=longitude,
+ timezone=timezone,
+ house_system=house_system,
+ reference_date=reference_date,
+ manifest_path=intake_output,
+ output_manifest_path=scoring_output,
+ )
+ unseal = build_classical_blind_unseal_packet(
+ scoring_manifest_path=scoring_output,
+ reviewer_manifest_path=unseal_output,
+ )
+ else:
+ intake = {
+ "status": "blocked",
+ "reason": "no_admitted_frozen_samples",
+ "output_manifest_path": str(intake_output),
+ "promotion_allowed": False,
+ }
+ scoring = {
+ "status": "blocked",
+ "reason": "skipped_until_admitted_frozen_samples_exist",
+ "score_row_count": 0,
+ "promotion_allowed": False,
+ }
+ unseal = {
+ "status": "blocked",
+ "reason": "skipped_until_blind_scoring_manifest_exists",
+ "unsealed_row_count": 0,
+ "statistics_shell": {"status": "blocked", "promotion_status": "blocked"},
+ "promotion_allowed": False,
+ }
+ command_plan = [
+ {
+ "step": "reviewer_freeze_admission",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-reviewer-freeze-admission "
+ f"--family {family} --reviewer-freeze-packet {reviewer_freeze_packet_path} "
+ f"--output-sample-packet {sample_output} --output-packet {admission_output}"
+ ),
+ },
+ {
+ "step": "blind_sample_intake",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-blind-sample-intake "
+ f"--sample-packet {sample_output} --output-manifest {intake_output} --base-manifest {empty_base}"
+ ),
+ },
+ {
+ "step": "blind_scoring",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-blind-scoring "
+ f"--year {year} --month {month} --day {day} --hour {hour} --minute {minute} "
+ f"--lat {latitude} --lon {longitude} --tz {timezone} --house-system {house_system} "
+ f"--manifest {intake_output} --output-manifest {scoring_output}"
+ ),
+ },
+ {
+ "step": "reviewer_unseal_manifest",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-reviewer-unseal-manifest "
+ f"--family {family} --reviewer-freeze-packet {reviewer_freeze_packet_path} "
+ f"--admission-packet {admission_output} --output-manifest {unseal_output}"
+ ),
+ },
+ {
+ "step": "blind_unseal_statistics_gate",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-blind-unseal "
+ f"--scoring-manifest {scoring_output} --reviewer-manifest {unseal_output}"
+ ),
+ },
+ ]
+ statistics_status = (unseal.get("statistics_shell") or {}).get("status", "blocked")
+ packet = {
+ "manifest_id": f"classical_{family}_reviewer_evidence_pipeline_runtime",
+ "schema": "classical.reviewer_evidence_pipeline.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "partial" if admitted_count else "blocked",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "artifact_paths": {
+ "reviewer_freeze_packet": str(reviewer_freeze_packet_path),
+ "reviewer_freeze_admission": str(admission_output),
+ "frozen_sample_packet": str(sample_output),
+ "blind_intake_manifest": str(intake_output),
+ "blind_scoring_manifest": str(scoring_output),
+ "reviewer_unseal_manifest": str(unseal_output),
+ "empty_base_manifest": str(empty_base),
+ },
+ "gate_summary": {
+ "admitted_sample_count": admitted_count,
+ "blocked_admission_count": int(admission.get("blocked_count") or 0),
+ "intake_case_count": int(intake.get("merged_case_count") or 0),
+ "score_row_count": int(scoring.get("score_row_count") or 0),
+ "unseal_label_count": int(unseal_manifest.get("label_count") or 0),
+ "unsealed_row_count": int(unseal.get("unsealed_row_count") or 0),
+ "statistics_status": statistics_status,
+ },
+ "artifacts": {
+ "admission": admission,
+ "unseal_manifest_builder": unseal_manifest,
+ "intake": {
+ "status": intake.get("status", "blocked"),
+ "merged_case_count": intake.get("merged_case_count", 0),
+ "redaction_audit": intake.get("redaction_audit", {}),
+ },
+ "scoring": {
+ "status": scoring.get("status", "blocked"),
+ "score_row_count": scoring.get("score_row_count", 0),
+ "label_policy": scoring.get("label_policy", {}),
+ },
+ "unseal": {
+ "status": unseal.get("status", "blocked"),
+ "unsealed_row_count": unseal.get("unsealed_row_count", 0),
+ "unsealed_rows": unseal.get("unsealed_rows", []),
+ "statistics_shell": unseal.get("statistics_shell", {}),
+ "separation_policy": unseal.get("separation_policy", {}),
+ },
+ },
+ "command_plan": command_plan,
+ "boundary": "Runs the reviewer-evidence path from admission through statistics only when admitted frozen samples exist. It never exposes labels to scoring and never promotes prediction readiness.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ return packet
+
+
+def build_classical_reviewer_evidence_kit_packet(
+ *,
+ family: str,
+ reviewer_freeze_packet_path: Path | str,
+ output_packet_path: Path | str | None = None,
+ output_markdown_path: Path | str | None = None,
+ output_csv_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response("classical.reviewer_evidence_kit.v1", family)
+ packet_path = Path(reviewer_freeze_packet_path)
+ if not packet_path.exists():
+ return {
+ "schema": "classical.reviewer_evidence_kit.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": "reviewer_freeze_packet_missing",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "promotion_allowed": False,
+ }
+ data = json.loads(packet_path.read_text(encoding="utf-8"))
+ lint_rows = []
+ for index, item in enumerate(data.get("items") if isinstance(data.get("items"), list) else []):
+ if not isinstance(item, dict):
+ continue
+ lint_rows.append(_classical_reviewer_evidence_lint({**item, "_index": index}, family))
+ blocked_rows = [row for row in lint_rows if row["status"] != "pass"]
+ workbench_items = []
+ for row in lint_rows:
+ missing_fields = list(row.get("missing_fields", []))
+ invalid_fields = list(row.get("invalid_fields", []))
+ workbench_items.append({
+ "case_id": row.get("case_id"),
+ "family": family,
+ "status": "admission_ready" if row.get("status") == "pass" else "reviewer_fill_required",
+ "json_pointer": row.get("json_pointer"),
+ "source_url": row.get("source_url"),
+ "source_capture_hash": row.get("source_capture_hash"),
+ "blocked_fields": sorted(set(missing_fields + invalid_fields)),
+ "reviewer_fields_template": {
+ field: (
+ False if field in {"outcome_label_hidden_from_scorer", "before_outcome_written", "after_the_fact_only"}
+ else ""
+ )
+ for field in _CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS
+ },
+ "allowed_expected_labels": row.get("allowed_values", {}).get("expected_label", []),
+ "next_action": (
+ "run_reviewer_freeze_admission"
+ if row.get("status") == "pass"
+ else "fill_blocked_fields_then_rerun_evidence_kit"
+ ),
+ "promotion_allowed": False,
+ })
+ required_field_summary = [
+ {
+ "field": field,
+ "blocked_item_count": sum(1 for row in lint_rows if field in row["missing_fields"] or field in row["invalid_fields"]),
+ }
+ for field in _CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS
+ ]
+ root = Path("references/real_case_calibration")
+ admission_path = root / f"classical_{family}_reviewer_freeze_admission_2026_08_31.json"
+ sample_path = root / f"classical_{family}_reviewer_freeze_sample_packet_2026_08_31.json"
+ intake_path = root / f"classical_{family}_reviewer_freeze_intake_manifest_2026_08_31.json"
+ scoring_path = root / f"classical_{family}_reviewer_freeze_scoring_manifest_2026_08_31.json"
+ unseal_path = root / f"classical_{family}_reviewer_unseal_manifest_2026_08_31.json"
+ pipeline_path = root / f"classical_{family}_reviewer_evidence_pipeline_2026_08_31.json"
+ packet = {
+ "manifest_id": f"classical_{family}_reviewer_evidence_kit_runtime",
+ "schema": "classical.reviewer_evidence_kit.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "ready_for_reviewer" if lint_rows else "blocked",
+ "reviewer_freeze_packet_path": str(packet_path),
+ "item_count": len(lint_rows),
+ "blocked_item_count": len(blocked_rows),
+ "pass_item_count": len(lint_rows) - len(blocked_rows),
+ "required_fields": list(_CLASSICAL_REVIEWER_EVIDENCE_REQUIRED_FIELDS),
+ "required_field_summary": required_field_summary,
+ "lint_rows": lint_rows,
+ "reviewer_workbench": {
+ "schema": "classical.reviewer_workbench.v1",
+ "family": family,
+ "item_count": len(workbench_items),
+ "admission_ready_count": sum(1 for row in workbench_items if row["status"] == "admission_ready"),
+ "reviewer_fill_required_count": sum(1 for row in workbench_items if row["status"] != "admission_ready"),
+ "items": workbench_items,
+ "promotion_allowed": False,
+ "boundary": "Workbench organizes reviewer evidence tasks only; it does not infer labels or promote prediction readiness.",
+ },
+ "reviewer_instructions": {
+ "do_not_edit": [
+ "case_id",
+ "source_url",
+ "source_capture_hash",
+ "freeze_contract.expected_label_visible_to_scorer",
+ ],
+ "fill_only": "items[].reviewer_fields",
+ "label_visibility": "expected_label stays in reviewer freeze/unseal files only; never copy it into scoring manifests",
+ "admission_boundary": "Every required field must pass before a frozen sample packet is emitted.",
+ },
+ "next_commands": [
+ (
+ "python3 scripts/jyotish_engine.py classical-reviewer-evidence-pipeline "
+ f"--family {family} --reviewer-freeze-packet {packet_path} "
+ f"--admission-packet {admission_path} --sample-packet {sample_path} "
+ f"--intake-manifest {intake_path} --scoring-manifest {scoring_path} "
+ f"--reviewer-unseal-manifest {unseal_path} --output {pipeline_path} "
+ "--year 1935 --month 6 --day 21 --hour 11 --minute 0 --lat 44.486 --lon 1.842 --tz 1 --house-system W"
+ )
+ ],
+ "promotion_allowed": False,
+ "boundary": "Reviewer evidence kit helps a human reviewer fill and lint evidence. It does not infer labels, score cases, unseal labels, or promote predictions.",
+ }
+ if output_packet_path:
+ out = Path(output_packet_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(out)
+ if output_markdown_path:
+ md_out = Path(output_markdown_path)
+ md_out.parent.mkdir(parents=True, exist_ok=True)
+ md_out.write_text(_classical_reviewer_evidence_kit_markdown(packet), encoding="utf-8", newline="\n")
+ packet["output_markdown_path"] = str(md_out)
+ if output_csv_path:
+ csv_out = Path(output_csv_path)
+ csv_out.parent.mkdir(parents=True, exist_ok=True)
+ csv_out.write_text(_classical_reviewer_evidence_kit_csv(packet), encoding="utf-8", newline="\n")
+ packet["output_csv_path"] = str(csv_out)
+ return packet
+
+
+def _classical_reviewer_evidence_kit_markdown(packet: dict[str, Any]) -> str:
+ lines = [
+ f"# Classical {packet.get('family')} Reviewer Evidence Kit",
+ "",
+ f"- status: `{packet.get('status')}`",
+ f"- item_count: `{packet.get('item_count', 0)}`",
+ f"- blocked_item_count: `{packet.get('blocked_item_count', 0)}`",
+ f"- pass_item_count: `{packet.get('pass_item_count', 0)}`",
+ f"- promotion_allowed: `{str(packet.get('promotion_allowed') is True).lower()}`",
+ "",
+ "## Fill Rules",
+ "",
+ "- Edit only `items[].reviewer_fields` in the reviewer freeze packet.",
+ "- Keep `outcome_label_hidden_from_scorer=true`.",
+ "- Set `before_outcome_written=true` only when the frozen sample container predates the outcome/follow-up.",
+ "- Set `after_the_fact_only=false` for promotion evidence; known retrospective cases must remain regression-only.",
+ "",
+ "## Items",
+ "",
+ ]
+ for row in packet.get("lint_rows", []) if isinstance(packet.get("lint_rows"), list) else []:
+ missing = ", ".join(f"`{field}`" for field in row.get("missing_fields", [])) or "`none`"
+ invalid = ", ".join(f"`{field}`" for field in row.get("invalid_fields", [])) or "`none`"
+ labels = ", ".join(f"`{label}`" for label in row.get("allowed_values", {}).get("expected_label", []))
+ lines.extend([
+ f"### {row.get('case_id')}",
+ "",
+ f"- status: `{row.get('status')}`",
+ f"- json_pointer: `{row.get('json_pointer')}`",
+ f"- source_url: `{row.get('source_url') or ''}`",
+ f"- source_capture_hash: `{row.get('source_capture_hash') or ''}`",
+ f"- missing_fields: {missing}",
+ f"- invalid_fields: {invalid}",
+ f"- expected_label_options: {labels}",
+ "",
+ ])
+ lines.extend(["## Next Command", ""])
+ for command in packet.get("next_commands", []):
+ lines.extend(["```bash", str(command), "```", ""])
+ lines.extend(["## Boundary", "", str(packet.get("boundary") or "")])
+ return "\n".join(lines)
+
+
+def _classical_reviewer_evidence_kit_csv(packet: dict[str, Any]) -> str:
+ buffer = io.StringIO()
+ writer = csv.DictWriter(buffer, fieldnames=[
+ "family",
+ "case_id",
+ "status",
+ "json_pointer",
+ "missing_fields",
+ "invalid_fields",
+ "source_url",
+ "source_capture_hash",
+ "expected_label_options",
+ ], lineterminator="\n")
+ writer.writeheader()
+ for row in packet.get("lint_rows", []) if isinstance(packet.get("lint_rows"), list) else []:
+ writer.writerow({
+ "family": packet.get("family", ""),
+ "case_id": row.get("case_id", ""),
+ "status": row.get("status", ""),
+ "json_pointer": row.get("json_pointer", ""),
+ "missing_fields": "|".join(row.get("missing_fields", [])),
+ "invalid_fields": "|".join(row.get("invalid_fields", [])),
+ "source_url": row.get("source_url") or "",
+ "source_capture_hash": row.get("source_capture_hash") or "",
+ "expected_label_options": "|".join(row.get("allowed_values", {}).get("expected_label", [])),
+ })
+ return buffer.getvalue()
+
+
+def build_classical_case_acquisition_runner_packet(
+ *,
+ family: str,
+ queue_path: Path | str | None = None,
+ output_packet_path: Path | str | None = None,
+ output_sample_packet_path: Path | str | None = None,
+ output_intake_manifest_path: Path | str | None = None,
+ output_reviewer_template_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response(
+ "classical.case_acquisition_runner.v1",
+ family,
+ layer="case_acquisition_runner",
+ )
+ path = Path(queue_path) if queue_path else _classical_default_acquisition_queue_path(family)
+ source_discovery_path = _classical_default_source_discovery_path(family)
+ source_discovery_summary = _load_classical_source_discovery_summary(source_discovery_path, family)
+ if not path.exists():
+ return {
+ "system": "classical_astrology",
+ "layer": "case_acquisition_runner",
+ "schema": "classical.case_acquisition_runner.v1",
+ "status": "blocked",
+ "family": family,
+ "queue_path": str(path),
+ "reason": "case_acquisition_queue_missing",
+ "promotion_allowed": False,
+ }
+ queue = json.loads(path.read_text(encoding="utf-8"))
+ items = queue.get("items") if isinstance(queue.get("items"), list) else []
+ rows: list[dict[str, Any]] = []
+ frozen_ready_cases: list[dict[str, Any]] = []
+ rejected_or_mismatch_sources: list[dict[str, Any]] = []
+ for index, item in enumerate(items, start=1):
+ if not isinstance(item, dict):
+ continue
+ missing = _classical_case_acquisition_missing_fields(item, family)
+ candidate = item.get("candidate_sample") if isinstance(item.get("candidate_sample"), dict) else {}
+ current_status = str(item.get("current_status") or "")
+ has_mismatch = "mismatch" in current_status.lower() or bool(item.get("mismatch_reason"))
+ freeze_status = "ready_to_freeze" if not missing and candidate else "blocked_missing_required_fields"
+ if has_mismatch:
+ freeze_status = "rejected_or_mismatch"
+ rejected_or_mismatch_sources.append({
+ "item_id": item.get("item_id") or f"{family}_queue_item_{index}",
+ "source_role": item.get("source_role"),
+ "reason": item.get("mismatch_reason") or current_status,
+ "source_path": item.get("source_path"),
+ "source_url": item.get("source_url"),
+ })
+ if freeze_status == "ready_to_freeze":
+ labels = candidate.get("labels") if isinstance(candidate.get("labels"), dict) else {}
+ casebooks_packet = {
+ "casebook_style": True,
+ "before_outcome_written": True,
+ "after_the_fact_only": False,
+ }
+ if family == "horary":
+ casebooks_packet.update({
+ "question_text": candidate.get("question_text"),
+ "question_context": candidate.get("question_context") or candidate.get("question_subject"),
+ "judgment_before_outcome": candidate.get("judgment_before_outcome") or candidate.get("published_outcome"),
+ "outcome_followup_date": candidate.get("outcome_followup_date") or candidate.get("published_outcome_date"),
+ "outcome_followup": candidate.get("published_outcome"),
+ })
+ elif family == "electional":
+ casebooks_packet.update({
+ "candidate_time": candidate.get("election_datetime_local") or candidate.get("candidate_time"),
+ "accepted_or_rejected": candidate.get("accepted_or_rejected_label") or candidate.get("accepted_or_rejected"),
+ "outcome": candidate.get("published_outcome"),
+ "followup_date": candidate.get("followup_date") or candidate.get("published_outcome_date"),
+ })
+ elif family == "zr":
+ casebooks_packet.update({
+ "event_date_or_period": candidate.get("event_datetime_or_period"),
+ "lot_basis": candidate.get("lot_basis_fortune_or_spirit"),
+ "release_level_scope": candidate.get("release_level_scope"),
+ "published_event_outcome": candidate.get("published_event_outcome"),
+ })
+ case_id = str(candidate.get("case_id"))
+ frozen_ready_cases.append({
+ "case_id": case_id,
+ "domain": family,
+ "sample_status": "frozen_container_pending_intake",
+ "sample_kind": _classical_sample_kind(family),
+ "reference_date": (
+ candidate.get("question_datetime_local")
+ or candidate.get("election_datetime_local")
+ or candidate.get("event_datetime_or_period")
+ ),
+ "birth_data": candidate.get("birth_data", {}),
+ "source_refs": candidate.get("source_refs", []),
+ "technique_scope": queue.get("default_technique_scope", []),
+ "casebooks_packet": casebooks_packet,
+ **({
+ "question_text": candidate.get("question_text"),
+ "question_context": candidate.get("question_context") or candidate.get("question_subject"),
+ "judgment_before_outcome": candidate.get("judgment_before_outcome") or candidate.get("published_outcome"),
+ "outcome_followup_date": candidate.get("outcome_followup_date") or candidate.get("published_outcome_date"),
+ "outcome_followup": candidate.get("published_outcome"),
+ "before_outcome_written": True,
+ "after_the_fact_only": False,
+ } if family == "horary" else {}),
+ **({
+ "candidate_time": candidate.get("election_datetime_local") or candidate.get("candidate_time"),
+ "accepted_or_rejected": candidate.get("accepted_or_rejected_label") or candidate.get("accepted_or_rejected"),
+ "outcome": candidate.get("published_outcome"),
+ "followup_date": candidate.get("followup_date") or candidate.get("published_outcome_date"),
+ "before_outcome_written": True,
+ "after_the_fact_only": False,
+ } if family == "electional" else {}),
+ **({
+ "event_date_or_period": candidate.get("event_datetime_or_period"),
+ "lot_basis": candidate.get("lot_basis_fortune_or_spirit"),
+ "release_level_scope": candidate.get("release_level_scope"),
+ "published_event_outcome": candidate.get("published_event_outcome"),
+ "before_outcome_written": True,
+ "after_the_fact_only": False,
+ } if family == "zr" else {}),
+ "labels": {
+ "blind_frozen": True,
+ "freeze_timestamp": candidate.get("freeze_timestamp"),
+ "label_independence": candidate.get("label_independence"),
+ "reviewer_source": candidate.get("reviewer_source"),
+ "expected_label_visible_to_scorer": False,
+ "expected_label": labels.get("expected_label") or candidate.get("accepted_or_rejected_label"),
+ },
+ })
+ rows.append({
+ "item_id": item.get("item_id") or f"{family}_queue_item_{index}",
+ "source_role": item.get("source_role"),
+ "current_status": current_status,
+ "freeze_status": freeze_status,
+ "missing_required_fields": missing,
+ "missing_count": len(missing),
+ "source_path": item.get("source_path"),
+ "source_url": item.get("source_url"),
+ "next_action": item.get("next_action"),
+ "boundary": item.get("boundary"),
+ })
+ missing_field_counts: dict[str, int] = {}
+ for row in rows:
+ for field in row.get("missing_required_fields", []):
+ missing_field_counts[str(field)] = missing_field_counts.get(str(field), 0) + 1
+ missing_field_summary = [
+ {"field": field, "missing_item_count": count}
+ for field, count in sorted(missing_field_counts.items(), key=lambda item: (-item[1], item[0]))
+ ]
+ sample_packet_preview = {
+ "manifest_id": f"classical_{family}_candidate_samples_runtime",
+ "system": "classical_astrology",
+ "claim_status": "freeze_ready_candidates_only",
+ "promotion_status": "blocked",
+ "default_technique_scope": queue.get("default_technique_scope", []),
+ "samples": frozen_ready_cases,
+ "boundary": "Only freeze-ready candidates are exported. Labels remain withheld from scoring after intake.",
+ }
+ reviewer_label_template = {
+ "manifest_id": f"classical_{family}_reviewer_unseal_template_runtime",
+ "system": "classical_astrology",
+ "schema": "classical.reviewer_unseal_template.v1",
+ "claim_status": "reviewer_label_template_only",
+ "required_protocol": {
+ "reviewer_source_required": True,
+ "freeze_timestamp_required": True,
+ "label_independence_required": True,
+ "domain_minimum_sample_counts": {"positive": 20, "negative": 20},
+ },
+ "labels": [
+ {
+ "sample_id": case.get("case_id"),
+ "expected_label": "",
+ "reviewer_source": "",
+ "freeze_timestamp": case.get("labels", {}).get("freeze_timestamp"),
+ "label_independence": True,
+ "label_source": "",
+ }
+ for case in frozen_ready_cases
+ ],
+ "label_count": len(frozen_ready_cases),
+ "promotion_status": "blocked",
+ "boundary": "Reviewer template is separate from scoring. It must be filled only after score manifests are frozen.",
+ }
+ packet = {
+ "manifest_id": f"classical_{family}_case_acquisition_runner_runtime",
+ "system": "classical_astrology",
+ "layer": "case_acquisition_runner",
+ "schema": "classical.case_acquisition_runner.v1",
+ "family": family,
+ "status": "partial" if rows else "blocked",
+ "queue_path": str(path),
+ "queue_manifest_id": queue.get("manifest_id"),
+ "item_count": len(rows),
+ "source_discovery_summary": source_discovery_summary,
+ "ready_to_freeze_count": len(frozen_ready_cases),
+ "blocked_item_count": sum(1 for row in rows if row["freeze_status"] == "blocked_missing_required_fields"),
+ "rejected_or_mismatch_count": len(rejected_or_mismatch_sources),
+ "required_fields": _CLASSICAL_CASE_ACQUISITION_REQUIRED_FIELDS[family],
+ "missing_field_summary": missing_field_summary,
+ "rows": rows,
+ "sample_packet_preview": sample_packet_preview,
+ "reviewer_label_template": reviewer_label_template,
+ "sample_packet_written": False,
+ "intake_manifest_written": False,
+ "reviewer_template_written": False,
+ "rejected_or_mismatch_sources": rejected_or_mismatch_sources,
+ "promotion_allowed": False,
+ "boundary": "Acquisition runner audits source candidates and freeze readiness only; it does not unblind labels or promote prediction claims.",
+ }
+ if output_sample_packet_path:
+ sample_output_path = Path(output_sample_packet_path)
+ sample_output_path.parent.mkdir(parents=True, exist_ok=True)
+ sample_output_path.write_text(json.dumps(sample_packet_preview, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["sample_packet_written"] = True
+ packet["sample_packet_output_path"] = str(sample_output_path)
+ if output_intake_manifest_path:
+ intake_path = Path(output_intake_manifest_path)
+ sample_source_path = Path(output_sample_packet_path) if output_sample_packet_path else intake_path.with_suffix(".sample_packet.json")
+ sample_source_path.parent.mkdir(parents=True, exist_ok=True)
+ if not sample_source_path.exists():
+ sample_source_path.write_text(json.dumps(sample_packet_preview, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["sample_packet_written"] = True
+ packet["sample_packet_output_path"] = str(sample_source_path)
+ intake = build_classical_blind_sample_intake_manifest_packet(
+ sample_packet_path=sample_source_path,
+ output_manifest_path=intake_path,
+ base_manifest_path=sample_source_path,
+ )
+ packet["intake_manifest_written"] = intake.get("status") in {"partial", "blocked"}
+ packet["intake_manifest_output_path"] = str(intake_path)
+ packet["intake_manifest_summary"] = {
+ "status": intake.get("status", "blocked"),
+ "sanitized_sample_count": intake.get("sanitized_sample_count", 0),
+ "merged_case_count": intake.get("merged_case_count", 0),
+ "expected_label_values_stored": (intake.get("redaction_audit") or {}).get("expected_label_values_stored", False),
+ "promotion_allowed": False,
+ }
+ if output_reviewer_template_path:
+ reviewer_output_path = Path(output_reviewer_template_path)
+ reviewer_output_path.parent.mkdir(parents=True, exist_ok=True)
+ reviewer_output_path.write_text(json.dumps(reviewer_label_template, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["reviewer_template_written"] = True
+ packet["reviewer_template_output_path"] = str(reviewer_output_path)
+ if output_packet_path:
+ output_path = Path(output_packet_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_packet_path"] = str(output_path)
+ return packet
+
+
+def build_classical_freeze_checklist_packet(
+ *,
+ family: str,
+ queue_path: Path | str | None = None,
+ output_path: Path | str | None = None,
+ output_markdown_path: Path | str | None = None,
+ output_csv_path: Path | str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ runner = build_classical_case_acquisition_runner_packet(family=family, queue_path=queue_path)
+ if runner.get("schema") != "classical.case_acquisition_runner.v1" or family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ packet = {
+ "schema": "classical.freeze_checklist.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "blocked",
+ "reason": runner.get("reason", "unsupported_or_unreadable_case_acquisition_runner"),
+ "promotion_allowed": False,
+ }
+ else:
+ checklist_items = []
+ for row in runner.get("rows", []):
+ if not isinstance(row, dict):
+ continue
+ checklist_items.append({
+ "item_id": row.get("item_id"),
+ "source_role": row.get("source_role"),
+ "freeze_status": row.get("freeze_status", "blocked_missing_required_fields"),
+ "missing_required_fields": row.get("missing_required_fields") if isinstance(row.get("missing_required_fields"), list) else [],
+ "missing_count": int(row.get("missing_count") or 0),
+ "source_path": row.get("source_path"),
+ "source_url": row.get("source_url"),
+ "next_action": row.get("next_action") or "fill_missing_fields_then_rerun_classical_case_acquisition",
+ "boundary": row.get("boundary") or "Checklist item only; not a frozen sample and not reviewer evidence.",
+ })
+ packet = {
+ "schema": "classical.freeze_checklist.v1",
+ "system": "classical_astrology",
+ "family": family,
+ "status": "ready" if checklist_items else "blocked",
+ "queue_path": runner.get("queue_path"),
+ "queue_manifest_id": runner.get("queue_manifest_id"),
+ "source_discovery_summary": runner.get("source_discovery_summary"),
+ "item_count": len(checklist_items),
+ "ready_to_freeze_count": int(runner.get("ready_to_freeze_count") or 0),
+ "blocked_item_count": int(runner.get("blocked_item_count") or 0),
+ "rejected_or_mismatch_count": int(runner.get("rejected_or_mismatch_count") or 0),
+ "missing_field_summary": runner.get("missing_field_summary") if isinstance(runner.get("missing_field_summary"), list) else [],
+ "checklist_items": checklist_items,
+ "next_command_after_completion": (
+ f"python3 scripts/jyotish_engine.py classical-case-acquisition --family {family} "
+ f"--output-packet references/real_case_calibration/classical_{family}_case_acquisition_runner_packet_2026_08_29.json "
+ f"--output-sample-packet references/real_case_calibration/classical_{family}_case_acquisition_sample_packet_2026_08_29.json "
+ f"--output-intake-manifest references/real_case_calibration/classical_{family}_case_acquisition_intake_manifest_2026_08_29.json "
+ f"--output-reviewer-template references/real_case_calibration/classical_{family}_case_acquisition_reviewer_template_2026_08_29.json"
+ ),
+ "promotion_allowed": False,
+ "boundary": "Freeze checklist helps human/legal sample freezing only. It does not expose labels to scoring or promote final prediction claims.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ if output_markdown_path:
+ md_out = Path(output_markdown_path)
+ md_out.parent.mkdir(parents=True, exist_ok=True)
+ md_out.write_text(_classical_freeze_checklist_markdown(packet), encoding="utf-8", newline="\n")
+ packet["output_markdown_path"] = str(md_out)
+ if output_csv_path:
+ csv_out = Path(output_csv_path)
+ csv_out.parent.mkdir(parents=True, exist_ok=True)
+ csv_out.write_text(_classical_freeze_checklist_csv(packet), encoding="utf-8", newline="\n")
+ packet["output_csv_path"] = str(csv_out)
+ return packet
+
+
+def _classical_freeze_checklist_markdown(packet: dict[str, Any]) -> str:
+ family = packet.get("family", "unknown")
+ lines = [
+ f"# Classical {family} Freeze Checklist",
+ "",
+ f"- status: `{packet.get('status', 'blocked')}`",
+ f"- item_count: `{packet.get('item_count', 0)}`",
+ f"- ready_to_freeze: `{packet.get('ready_to_freeze_count', 0)}`",
+ f"- blocked_items: `{packet.get('blocked_item_count', 0)}`",
+ f"- promotion_allowed: `{str(packet.get('promotion_allowed') is True).lower()}`",
+ "",
+ "## Top Missing Fields",
+ "",
+ ]
+ for row in packet.get("missing_field_summary", []) if isinstance(packet.get("missing_field_summary"), list) else []:
+ lines.append(f"- `{row.get('field')}`: {row.get('missing_item_count', 0)}")
+ lines.extend(["", "## Checklist Items", ""])
+ for item in packet.get("checklist_items", []) if isinstance(packet.get("checklist_items"), list) else []:
+ missing = ", ".join(f"`{field}`" for field in item.get("missing_required_fields", []) if field)
+ lines.extend([
+ f"### {item.get('item_id', 'unknown_item')}",
+ "",
+ f"- freeze_status: `{item.get('freeze_status', 'blocked_missing_required_fields')}`",
+ f"- source_role: `{item.get('source_role', '')}`",
+ f"- missing_count: `{item.get('missing_count', 0)}`",
+ f"- missing_required_fields: {missing or '`none`'}",
+ f"- source_path: `{item.get('source_path') or ''}`",
+ f"- source_url: `{item.get('source_url') or ''}`",
+ f"- next_action: `{item.get('next_action') or ''}`",
+ "",
+ ])
+ lines.extend([
+ "## Boundary",
+ "",
+ str(packet.get("boundary") or "Checklist only; not promotion evidence."),
+ "",
+ ])
+ return "\n".join(lines)
+
+
+def _classical_freeze_checklist_csv(packet: dict[str, Any]) -> str:
+ buffer = io.StringIO()
+ writer = csv.DictWriter(buffer, fieldnames=[
+ "family",
+ "item_id",
+ "source_role",
+ "freeze_status",
+ "missing_count",
+ "missing_required_fields",
+ "source_path",
+ "source_url",
+ "next_action",
+ "promotion_allowed",
+ ])
+ writer.writeheader()
+ for item in packet.get("checklist_items", []) if isinstance(packet.get("checklist_items"), list) else []:
+ writer.writerow({
+ "family": packet.get("family", ""),
+ "item_id": item.get("item_id", ""),
+ "source_role": item.get("source_role", ""),
+ "freeze_status": item.get("freeze_status", ""),
+ "missing_count": item.get("missing_count", 0),
+ "missing_required_fields": "|".join(str(field) for field in item.get("missing_required_fields", []) if field),
+ "source_path": item.get("source_path") or "",
+ "source_url": item.get("source_url") or "",
+ "next_action": item.get("next_action") or "",
+ "promotion_allowed": "false",
+ })
+ return buffer.getvalue()
+
+
+def _load_classical_freeze_checklist_summary(path: Path, family: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "ready_to_freeze_count": 0,
+ "blocked_item_count": 0,
+ "promotion_allowed": False,
+ "reason": "freeze_checklist_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "ready_to_freeze_count": 0,
+ "blocked_item_count": 0,
+ "promotion_allowed": False,
+ "reason": f"freeze_checklist_unreadable:{type(exc).__name__}",
+ }
+ return {
+ "family": family,
+ "status": data.get("status", "blocked"),
+ "path": str(path),
+ "item_count": int(data.get("item_count") or 0),
+ "ready_to_freeze_count": int(data.get("ready_to_freeze_count") or 0),
+ "blocked_item_count": int(data.get("blocked_item_count") or 0),
+ "rejected_or_mismatch_count": int(data.get("rejected_or_mismatch_count") or 0),
+ "missing_field_summary": data.get("missing_field_summary") if isinstance(data.get("missing_field_summary"), list) else [],
+ "next_command_after_completion": data.get("next_command_after_completion"),
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Freeze checklist summary only; not a frozen sample or promotion result."),
+ }
+
+
+def _load_classical_case_acquisition_runner_summary(path: Path, family: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "ready_to_freeze_count": 0,
+ "blocked_item_count": 0,
+ "rejected_or_mismatch_count": 0,
+ "promotion_allowed": False,
+ "reason": "case_acquisition_runner_packet_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - malformed local artifact
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "item_count": 0,
+ "ready_to_freeze_count": 0,
+ "blocked_item_count": 0,
+ "rejected_or_mismatch_count": 0,
+ "promotion_allowed": False,
+ "reason": f"case_acquisition_runner_packet_unreadable:{type(exc).__name__}",
+ }
+ return {
+ "family": family,
+ "status": data.get("status", "blocked"),
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "queue_manifest_id": data.get("queue_manifest_id"),
+ "item_count": int(data.get("item_count") or 0),
+ "ready_to_freeze_count": int(data.get("ready_to_freeze_count") or 0),
+ "blocked_item_count": int(data.get("blocked_item_count") or 0),
+ "rejected_or_mismatch_count": int(data.get("rejected_or_mismatch_count") or 0),
+ "missing_field_summary": data.get("missing_field_summary") if isinstance(data.get("missing_field_summary"), list) else [],
+ "sample_packet_written": data.get("sample_packet_written") is True,
+ "intake_manifest_written": data.get("intake_manifest_written") is True,
+ "reviewer_template_written": data.get("reviewer_template_written") is True,
+ "sample_packet_output_path": data.get("sample_packet_output_path"),
+ "intake_manifest_output_path": data.get("intake_manifest_output_path"),
+ "reviewer_template_output_path": data.get("reviewer_template_output_path"),
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Acquisition runner summary only; not a blind-scoring result."),
+ }
+
+
+def build_classical_domain_validation_bundle_packet(
+ *,
+ family: str,
+ queue_path: Path | str | None = None,
+ acquisition_packet_path: Path | str | None = None,
+ sample_packet_path: Path | str | None = None,
+ intake_manifest_path: Path | str | None = None,
+ reviewer_template_path: Path | str | None = None,
+ scoring_manifest_path: Path | str | None = None,
+ reviewer_manifest_path: Path | str | None = None,
+ output_bundle_path: Path | str | None = None,
+ year: int = 1990,
+ month: int = 1,
+ day: int = 1,
+ hour: int = 12,
+ minute: int = 0,
+ second: int = 0,
+ latitude: float = 39.9,
+ longitude: float = 116.4,
+ timezone: str | float = 8,
+ house_system: str = "W",
+ reference_date: str | None = None,
+) -> dict[str, Any]:
+ family = str(family).strip().lower()
+ if family not in CLASSICAL_CASE_VALIDATION_FAMILIES:
+ return _classical_supported_family_response(
+ "classical.domain_validation_bundle.v1",
+ family,
+ layer="domain_validation_bundle",
+ )
+ root = Path("references/real_case_calibration")
+ acquisition_output = Path(acquisition_packet_path) if acquisition_packet_path else root / f"classical_{family}_domain_validation_acquisition_packet_runtime.json"
+ sample_output = Path(sample_packet_path) if sample_packet_path else root / f"classical_{family}_domain_validation_sample_packet_runtime.json"
+ intake_output = Path(intake_manifest_path) if intake_manifest_path else root / f"classical_{family}_domain_validation_intake_manifest_runtime.json"
+ reviewer_output = Path(reviewer_template_path) if reviewer_template_path else root / f"classical_{family}_domain_validation_reviewer_template_runtime.json"
+ scoring_output = Path(scoring_manifest_path) if scoring_manifest_path else root / f"classical_{family}_domain_validation_scoring_manifest_runtime.json"
+
+ acquisition = build_classical_case_acquisition_runner_packet(
+ family=family,
+ queue_path=queue_path,
+ output_packet_path=acquisition_output,
+ output_sample_packet_path=sample_output,
+ output_intake_manifest_path=intake_output,
+ output_reviewer_template_path=reviewer_output,
+ )
+ scoring = build_classical_blind_scoring_packet(
+ year=year,
+ month=month,
+ day=day,
+ hour=hour,
+ minute=minute,
+ second=second,
+ latitude=latitude,
+ longitude=longitude,
+ timezone=timezone,
+ house_system=house_system,
+ reference_date=reference_date,
+ manifest_path=intake_output,
+ output_manifest_path=scoring_output,
+ )
+ unseal = build_classical_blind_unseal_packet(
+ scoring_manifest_path=scoring_output,
+ reviewer_manifest_path=reviewer_manifest_path or reviewer_output,
+ )
+ command_plan = [
+ {
+ "step": "acquisition_to_intake",
+ "command": (
+ f"python3 scripts/jyotish_engine.py classical-case-acquisition --family {family} "
+ f"--output-packet {acquisition_output} --output-sample-packet {sample_output} "
+ f"--output-intake-manifest {intake_output} --output-reviewer-template {reviewer_output}"
+ ),
+ },
+ {
+ "step": "blind_scoring",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-blind-scoring "
+ f"--year {year} --month {month} --day {day} --hour {hour} --minute {minute} "
+ f"--lat {latitude} --lon {longitude} --tz {timezone} --house-system {house_system} "
+ f"--manifest {intake_output} --output-manifest {scoring_output}"
+ ),
+ },
+ {
+ "step": "reviewer_unseal",
+ "command": (
+ "python3 scripts/jyotish_engine.py classical-blind-unseal "
+ f"--scoring-manifest {scoring_output} --reviewer-manifest {reviewer_manifest_path or reviewer_output}"
+ ),
+ },
+ ]
+ packet = {
+ "manifest_id": f"classical_{family}_domain_validation_bundle_runtime",
+ "system": "classical_astrology",
+ "layer": "domain_validation_bundle",
+ "schema": "classical.domain_validation_bundle.v1",
+ "family": family,
+ "status": "blocked",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "artifact_paths": {
+ "acquisition_packet": str(acquisition_output),
+ "sample_packet": str(sample_output),
+ "intake_manifest": str(intake_output),
+ "reviewer_template": str(reviewer_output),
+ "scoring_manifest": str(scoring_output),
+ },
+ "gate_summary": {
+ "acquisition_ready_to_freeze_count": acquisition.get("ready_to_freeze_count", 0),
+ "acquisition_blocked_item_count": acquisition.get("blocked_item_count", 0),
+ "intake_case_count": scoring.get("manifest", {}).get("frozen_sample_queue_count", 0),
+ "score_row_count": scoring.get("score_row_count", 0),
+ "unsealed_row_count": unseal.get("unsealed_row_count", 0),
+ "statistics_status": (unseal.get("statistics_shell") or {}).get("status", "blocked"),
+ },
+ "command_plan": command_plan,
+ "artifacts": {
+ "acquisition": acquisition,
+ "scoring": {
+ "status": scoring.get("status", "blocked"),
+ "score_row_count": scoring.get("score_row_count", 0),
+ "label_policy": scoring.get("label_policy", {}),
+ "manifest": scoring.get("manifest", {}),
+ },
+ "unseal": {
+ "status": unseal.get("status", "blocked"),
+ "unsealed_row_count": unseal.get("unsealed_row_count", 0),
+ "unsealed_rows": unseal.get("unsealed_rows", []),
+ "missing_label_sample_ids": unseal.get("missing_label_sample_ids", []),
+ "statistics_shell": unseal.get("statistics_shell", {}),
+ "separation_policy": unseal.get("separation_policy", {}),
+ },
+ },
+ "boundary": "Domain validation bundle executes acquisition, intake, scoring, and unseal wiring while preserving blocked promotion until independent real samples and reviewer labels exist.",
+ }
+ if output_bundle_path:
+ out = Path(output_bundle_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_bundle_path"] = str(out)
+ return packet
+
+
+def _load_classical_domain_validation_bundle_summary(path: Path, family: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "promotion_allowed": False,
+ "reason": "domain_validation_bundle_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover
+ return {
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "promotion_allowed": False,
+ "reason": f"domain_validation_bundle_unreadable:{type(exc).__name__}",
+ }
+ gate = data.get("gate_summary") if isinstance(data.get("gate_summary"), dict) else {}
+ return {
+ "family": family,
+ "status": data.get("status", "blocked"),
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "promotion_status": data.get("promotion_status", "blocked"),
+ "promotion_allowed": False,
+ "artifact_paths": data.get("artifact_paths") if isinstance(data.get("artifact_paths"), dict) else {},
+ "gate_summary": {
+ "acquisition_ready_to_freeze_count": int(gate.get("acquisition_ready_to_freeze_count") or 0),
+ "acquisition_blocked_item_count": int(gate.get("acquisition_blocked_item_count") or 0),
+ "intake_case_count": int(gate.get("intake_case_count") or 0),
+ "score_row_count": int(gate.get("score_row_count") or 0),
+ "unsealed_row_count": int(gate.get("unsealed_row_count") or 0),
+ "statistics_status": gate.get("statistics_status", "blocked"),
+ },
+ "boundary": data.get("boundary", "Domain validation bundle summary only; not a promotion result."),
+ }
+
+
+def _evaluate_classical_remaining_task_statuses(
+ register: dict[str, Any],
+ *,
+ horary_runner: dict[str, Any],
+ electional_runner: dict[str, Any],
+ zr_runner: dict[str, Any] | None = None,
+ horary_bundle: dict[str, Any],
+ electional_bundle: dict[str, Any],
+ zr_bundle: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ def gate(bundle: dict[str, Any], key: str) -> int | str:
+ summary = bundle.get("gate_summary") if isinstance(bundle.get("gate_summary"), dict) else {}
+ return summary.get(key, 0)
+
+ zr_runner = zr_runner or {}
+ zr_bundle = zr_bundle or {}
+ ready_total = (
+ int(horary_runner.get("ready_to_freeze_count") or 0)
+ + int(electional_runner.get("ready_to_freeze_count") or 0)
+ + int(zr_runner.get("ready_to_freeze_count") or 0)
+ )
+ intake_total = (
+ int(gate(horary_bundle, "intake_case_count") or 0)
+ + int(gate(electional_bundle, "intake_case_count") or 0)
+ + int(gate(zr_bundle, "intake_case_count") or 0)
+ )
+ scored_total = (
+ int(gate(horary_bundle, "score_row_count") or 0)
+ + int(gate(electional_bundle, "score_row_count") or 0)
+ + int(gate(zr_bundle, "score_row_count") or 0)
+ )
+ unsealed_total = (
+ int(gate(horary_bundle, "unsealed_row_count") or 0)
+ + int(gate(electional_bundle, "unsealed_row_count") or 0)
+ + int(gate(zr_bundle, "unsealed_row_count") or 0)
+ )
+ statistics_ready = (
+ gate(horary_bundle, "statistics_status") == "partial_review_ready"
+ and gate(electional_bundle, "statistics_status") == "partial_review_ready"
+ and gate(zr_bundle, "statistics_status") == "partial_review_ready"
+ )
+ task_statuses = {
+ "real_source_acquisition": "completed" if ready_total > 0 else "blocked",
+ "freeze_ready_conversion": "completed" if intake_total > 0 else "blocked",
+ "independent_reviewer_unseal": "completed" if unsealed_total > 0 else "blocked",
+ "domain_statistics_gate": "completed" if statistics_ready else "blocked",
+ "promotion_threshold_calibration": "blocked",
+ }
+ next_commands = {
+ "real_source_acquisition": [
+ "python3 scripts/jyotish_engine.py classical-external-source-discovery --family horary --output-discovery-packet references/real_case_calibration/classical_horary_source_candidates.json --output-sample-packet references/real_case_calibration/classical_horary_source_candidate_sample_packet_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-external-source-discovery --family electional --output-discovery-packet references/real_case_calibration/classical_electional_source_candidates.json --output-sample-packet references/real_case_calibration/classical_electional_source_candidate_sample_packet_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-external-source-discovery --family zr --output-discovery-packet references/real_case_calibration/classical_zr_source_candidates.json --output-sample-packet references/real_case_calibration/classical_zr_source_candidate_sample_packet_2026_08_30.json",
+ ],
+ "freeze_ready_conversion": [
+ "python3 scripts/jyotish_engine.py classical-source-freeze-conversion --family horary --source-packet references/real_case_calibration/classical_horary_source_candidates.json --output-queue references/real_case_calibration/classical_horary_source_freeze_conversion_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_horary_source_freeze_conversion_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-source-freeze-conversion --family electional --source-packet references/real_case_calibration/classical_electional_source_candidates.json --output-queue references/real_case_calibration/classical_electional_source_freeze_conversion_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_electional_source_freeze_conversion_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-source-freeze-conversion --family zr --source-packet references/real_case_calibration/classical_zr_source_candidates.json --output-queue references/real_case_calibration/classical_zr_source_freeze_conversion_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_zr_source_freeze_conversion_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-case-field-extraction --family horary --queue references/real_case_calibration/classical_horary_source_freeze_conversion_queue_2026_08_30.json --output-queue references/real_case_calibration/classical_horary_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_horary_case_level_field_extraction_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-case-field-extraction --family electional --queue references/real_case_calibration/classical_electional_source_freeze_conversion_queue_2026_08_30.json --output-queue references/real_case_calibration/classical_electional_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_electional_case_level_field_extraction_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-case-field-extraction --family zr --queue references/real_case_calibration/classical_zr_source_freeze_conversion_queue_2026_08_30.json --output-queue references/real_case_calibration/classical_zr_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_zr_case_level_field_extraction_2026_08_30.json",
+ "python3 scripts/jyotish_engine.py classical-case-acquisition --family horary --queue references/real_case_calibration/classical_horary_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_horary_domain_validation_acquisition_packet_2026_08_29.json --output-sample-packet references/real_case_calibration/classical_horary_domain_validation_sample_packet_2026_08_29.json --output-intake-manifest references/real_case_calibration/classical_horary_domain_validation_intake_manifest_2026_08_29.json --output-reviewer-template references/real_case_calibration/classical_horary_domain_validation_reviewer_template_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-case-acquisition --family electional --queue references/real_case_calibration/classical_electional_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_electional_domain_validation_acquisition_packet_2026_08_29.json --output-sample-packet references/real_case_calibration/classical_electional_domain_validation_sample_packet_2026_08_29.json --output-intake-manifest references/real_case_calibration/classical_electional_domain_validation_intake_manifest_2026_08_29.json --output-reviewer-template references/real_case_calibration/classical_electional_domain_validation_reviewer_template_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-case-acquisition --family zr --queue references/real_case_calibration/classical_zr_case_level_extracted_queue_2026_08_30.json --output-packet references/real_case_calibration/classical_zr_domain_validation_acquisition_packet_2026_08_30.json --output-sample-packet references/real_case_calibration/classical_zr_domain_validation_sample_packet_2026_08_30.json --output-intake-manifest references/real_case_calibration/classical_zr_domain_validation_intake_manifest_2026_08_30.json --output-reviewer-template references/real_case_calibration/classical_zr_domain_validation_reviewer_template_2026_08_30.json",
+ ],
+ "independent_reviewer_unseal": [
+ "python3 scripts/jyotish_engine.py classical-reviewer-answer-apply --family horary --reviewer-freeze-packet references/real_case_calibration/classical_horary_reviewer_freeze_packet_autofilled_2026_09_02.json --answer-sheet references/real_case_calibration/reviewer_answer_sheets/classical_horary_reviewer_answers_2026_09_02.csv --output-filled-packet references/real_case_calibration/classical_horary_reviewer_freeze_packet_filled_2026_09_02.json --output-admission-packet references/real_case_calibration/classical_horary_reviewer_freeze_admission_2026_09_02.json --output-sample-packet references/real_case_calibration/classical_horary_reviewer_freeze_sample_packet_2026_09_02.json --output-packet references/real_case_calibration/classical_horary_reviewer_answer_apply_2026_09_02.json",
+ "python3 scripts/jyotish_engine.py classical-reviewer-answer-apply --family electional --reviewer-freeze-packet references/real_case_calibration/classical_electional_reviewer_freeze_packet_autofilled_2026_09_02.json --answer-sheet references/real_case_calibration/reviewer_answer_sheets/classical_electional_reviewer_answers_2026_09_02.csv --output-filled-packet references/real_case_calibration/classical_electional_reviewer_freeze_packet_filled_2026_09_02.json --output-admission-packet references/real_case_calibration/classical_electional_reviewer_freeze_admission_2026_09_02.json --output-sample-packet references/real_case_calibration/classical_electional_reviewer_freeze_sample_packet_2026_09_02.json --output-packet references/real_case_calibration/classical_electional_reviewer_answer_apply_2026_09_02.json",
+ "python3 scripts/jyotish_engine.py classical-blind-unseal --scoring-manifest references/real_case_calibration/classical_horary_domain_validation_scoring_manifest_2026_08_29.json --reviewer-manifest references/real_case_calibration/classical_horary_domain_validation_reviewer_template_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-blind-unseal --scoring-manifest references/real_case_calibration/classical_electional_domain_validation_scoring_manifest_2026_08_29.json --reviewer-manifest references/real_case_calibration/classical_electional_domain_validation_reviewer_template_2026_08_29.json",
+ "python3 scripts/jyotish_engine.py classical-blind-unseal --scoring-manifest references/real_case_calibration/classical_zr_domain_validation_scoring_manifest_2026_08_30.json --reviewer-manifest references/real_case_calibration/classical_zr_domain_validation_reviewer_template_2026_08_30.json",
+ ],
+ "domain_statistics_gate": [
+ "python3 scripts/jyotish_engine.py classical-domain-validation-bundle --family horary --year 1935 --month 6 --day 21 --hour 11 --minute 0 --lat 44.486 --lon 1.842 --tz 1 --reference-date 2026-08-29",
+ "python3 scripts/jyotish_engine.py classical-domain-validation-bundle --family electional --year 1935 --month 6 --day 21 --hour 11 --minute 0 --lat 44.486 --lon 1.842 --tz 1 --reference-date 2026-08-29",
+ "python3 scripts/jyotish_engine.py classical-domain-validation-bundle --family zr --year 1935 --month 6 --day 21 --hour 11 --minute 0 --lat 44.486 --lon 1.842 --tz 1 --reference-date 2026-08-30",
+ ],
+ "promotion_threshold_calibration": [
+ "python3 scripts/jyotish_engine.py classical-promotion-threshold-calibration --families horary,electional,zr --minimum-independent-samples 30",
+ ],
+ }
+ tasks = []
+ for task in register.get("tasks") if isinstance(register.get("tasks"), list) else []:
+ if not isinstance(task, dict):
+ continue
+ task_id = str(task.get("task_id") or "")
+ tasks.append({
+ **task,
+ "status": task_statuses.get(task_id, task.get("status", "blocked")),
+ "runtime_evidence": {
+ "ready_to_freeze_total": ready_total,
+ "intake_case_total": intake_total,
+ "score_row_total": scored_total,
+ "unsealed_row_total": unsealed_total,
+ "statistics_ready_all_domains": statistics_ready,
+ },
+ "next_commands": next_commands.get(task_id, []),
+ })
+ open_tasks = [task for task in tasks if task.get("status") != "completed"]
+ return {
+ **register,
+ "status": "completed" if not open_tasks else "blocked",
+ "task_count": len(tasks),
+ "open_task_count": len(open_tasks),
+ "completed_task_count": len(tasks) - len(open_tasks),
+ "tasks": tasks,
+ "open_tasks": open_tasks,
+ "runtime_counts": {
+ "ready_to_freeze_total": ready_total,
+ "intake_case_total": intake_total,
+ "score_row_total": scored_total,
+ "unsealed_row_total": unsealed_total,
+ "statistics_ready_all_domains": statistics_ready,
+ },
+ "promotion_allowed": False,
+ }
+
+
+def _load_classical_remaining_task_register(
+ path: Path = DEFAULT_CLASSICAL_HORARY_ELECTIONAL_REMAINING_TASK_REGISTER,
+ *,
+ horary_runner: dict[str, Any] | None = None,
+ electional_runner: dict[str, Any] | None = None,
+ zr_runner: dict[str, Any] | None = None,
+ horary_bundle: dict[str, Any] | None = None,
+ electional_bundle: dict[str, Any] | None = None,
+ zr_bundle: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "task_count": 0,
+ "open_task_count": 0,
+ "promotion_allowed": False,
+ "reason": "remaining_task_register_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "task_count": 0,
+ "open_task_count": 0,
+ "promotion_allowed": False,
+ "reason": f"remaining_task_register_unreadable:{type(exc).__name__}",
+ }
+ evaluated = _evaluate_classical_remaining_task_statuses(
+ data,
+ horary_runner=horary_runner or {},
+ electional_runner=electional_runner or {},
+ zr_runner=zr_runner or {},
+ horary_bundle=horary_bundle or {},
+ electional_bundle=electional_bundle or {},
+ zr_bundle=zr_bundle or {},
+ )
+ open_tasks = evaluated.get("open_tasks") if isinstance(evaluated.get("open_tasks"), list) else []
+ def current_count_row(runner: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
+ gate_summary = bundle.get("gate_summary") if isinstance(bundle.get("gate_summary"), dict) else {}
+ return {
+ "candidate_queue_items": int(runner.get("item_count") or 0),
+ "ready_to_freeze": int(runner.get("ready_to_freeze_count") or 0),
+ "intake_cases": int(gate_summary.get("intake_case_count") or 0),
+ "scored_rows": int(gate_summary.get("score_row_count") or 0),
+ "unsealed_rows": int(gate_summary.get("unsealed_row_count") or 0),
+ "statistics_status": gate_summary.get("statistics_status", "blocked"),
+ }
+
+ current_counts = {
+ "horary": current_count_row(horary_runner or {}, horary_bundle or {}),
+ "electional": current_count_row(electional_runner or {}, electional_bundle or {}),
+ "zr": current_count_row(zr_runner or {}, zr_bundle or {}),
+ }
+ packet = {
+ "status": evaluated.get("status", data.get("status", "blocked")),
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "task_count": int(evaluated.get("task_count") or 0),
+ "open_task_count": int(evaluated.get("open_task_count") or 0),
+ "completed_task_count": int(evaluated.get("completed_task_count") or 0),
+ "families": data.get("families") if isinstance(data.get("families"), list) else [],
+ "tasks": [
+ {
+ "task_id": task.get("task_id"),
+ "status": task.get("status", "blocked"),
+ "families": task.get("families") if isinstance(task.get("families"), list) else [],
+ "boundary": task.get("boundary"),
+ "runtime_evidence": task.get("runtime_evidence") if isinstance(task.get("runtime_evidence"), dict) else {},
+ "next_commands": task.get("next_commands") if isinstance(task.get("next_commands"), list) else [],
+ }
+ for task in open_tasks
+ if isinstance(task, dict)
+ ],
+ "current_counts": current_counts,
+ "runtime_counts": evaluated.get("runtime_counts", {}),
+ "next_engineering_step": data.get("next_engineering_step"),
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Remaining task register only; not a promotion result."),
+ }
+ for task in packet["tasks"]:
+ task["bucket_status_table"] = _classical_remaining_register_bucket_table(
+ str(task.get("task_id") or ""),
+ current_counts,
+ )
+ task["next_unblocked_action"] = (
+ task["bucket_status_table"][0]["next_unblocked_action"]
+ if task.get("bucket_status_table")
+ else "inspect_task_register"
+ )
+ packet["bucket_status_summary"] = {
+ task["task_id"]: {
+ "status": task.get("status", "blocked"),
+ "next_unblocked_action": task.get("next_unblocked_action"),
+ "family_rows": task.get("bucket_status_table", []),
+ "promotion_allowed": False,
+ }
+ for task in packet.get("tasks", [])
+ if isinstance(task, dict) and task.get("task_id")
+ }
+ packet["remaining_task_summary"] = _classical_remaining_task_summary(packet)
+ return packet
+
+
+def _classical_remaining_task_summary(register: dict[str, Any]) -> dict[str, Any]:
+ tasks = register.get("tasks") if isinstance(register.get("tasks"), list) else []
+ open_tasks = [task for task in tasks if isinstance(task, dict) and task.get("status") != "completed"]
+ open_task_ids = [str(task.get("task_id") or "") for task in open_tasks if task.get("task_id")]
+ runtime_counts = register.get("runtime_counts") if isinstance(register.get("runtime_counts"), dict) else {}
+ total = int(register.get("task_count") or len(tasks) or 0)
+ open_count = int(register.get("open_task_count") if register.get("open_task_count") is not None else len(open_tasks))
+ completed_count = int(register.get("completed_task_count") or 0)
+ evidence_zero = all(
+ int(runtime_counts.get(key) or 0) == 0
+ for key in ("ready_to_freeze_total", "intake_case_total", "score_row_total", "unsealed_row_total")
+ )
+ if open_count == 0 and total:
+ zh = "古典占星剩余闭环任务已完成;仍需通过 promotion gate 才能声明预测晋级。"
+ en = "Classical remaining closure tasks are complete; prediction promotion still depends on the promotion gate."
+ elif evidence_zero:
+ zh = f"古典占星还剩 {open_count}/{total} 类任务未闭环:缺真实冻结样本、独立 reviewer 解盲与分域统计证据。"
+ en = f"Classical astrology still has {open_count}/{total} open task families: real frozen samples, independent reviewer unseal evidence, and domain statistics are missing."
+ else:
+ zh = f"古典占星还剩 {open_count}/{total} 类任务未闭环;已有部分运行时证据,但 promotion gate 仍未通过。"
+ en = f"Classical astrology still has {open_count}/{total} open task families; some runtime evidence exists, but the promotion gate has not passed."
+ return {
+ "schema": "classical.remaining_task_summary.v1",
+ "status": register.get("status", "blocked"),
+ "task_count": total,
+ "open_task_count": open_count,
+ "completed_task_count": completed_count,
+ "open_task_ids": open_task_ids,
+ "runtime_counts": runtime_counts,
+ "zh": zh,
+ "en": en,
+ "promotion_allowed": False,
+ }
+
+
+def _classical_pipeline_bucket_row(pipeline: dict[str, Any], family: str) -> dict[str, Any]:
+ stages = pipeline.get("stages") if isinstance(pipeline.get("stages"), list) else []
+ stage_by_name = {
+ str(stage.get("stage")): stage
+ for stage in stages
+ if isinstance(stage, dict) and stage.get("stage")
+ }
+ first_blocked = next(
+ (
+ stage
+ for stage in stages
+ if isinstance(stage, dict)
+ and str(stage.get("status") or "").lower()
+ not in {"ready", "pass", "completed", "partial_review_ready", "freeze_ready_candidates_only"}
+ ),
+ None,
+ )
+ freeze_stage = stage_by_name.get("freeze_admission", {})
+ domain_stage = stage_by_name.get("domain_validation", {})
+ freeze_counts = freeze_stage.get("counts") if isinstance(freeze_stage.get("counts"), dict) else {}
+ domain_counts = domain_stage.get("counts") if isinstance(domain_stage.get("counts"), dict) else {}
+ return {
+ "family": family,
+ "pipeline_status": str(pipeline.get("status") or "blocked"),
+ "current_blocker": str(pipeline.get("next_blocker") or (first_blocked or {}).get("reason") or "blocked"),
+ "first_blocked_stage": (first_blocked or {}).get("stage"),
+ "first_blocked_artifact": (first_blocked or {}).get("path"),
+ "ready_to_freeze_count": int(pipeline.get("ready_to_freeze_count") or freeze_counts.get("ready_to_freeze_count") or 0),
+ "reviewer_todo_count": int(pipeline.get("reviewer_todo_count") or freeze_counts.get("reviewer_todo_count") or 0),
+ "score_row_count": int(pipeline.get("score_row_count") or domain_counts.get("score_row_count") or 0),
+ "promotion_allowed": False,
+ }
+
+
+def _classical_task_bucket_table(
+ task_id: str,
+ pipelines: dict[str, dict[str, Any]],
+) -> list[dict[str, Any]]:
+ rows = [_classical_pipeline_bucket_row(pipeline, family) for family, pipeline in sorted(pipelines.items())]
+ if task_id == "real_source_acquisition":
+ for row in rows:
+ row["bucket_target"] = "find_case_level_public_or_reviewer_sources"
+ row["next_unblocked_action"] = "run_or_refresh_classical_external_source_discovery_then_review_source_candidates"
+ elif task_id == "freeze_ready_conversion":
+ for row in rows:
+ row["bucket_target"] = "convert_candidates_to_freeze_ready_case_packets"
+ row["next_unblocked_action"] = "fill_case_level_fields_then_run_classical_case_acquisition"
+ elif task_id == "independent_reviewer_unseal":
+ for row in rows:
+ row["bucket_target"] = "collect_independent_reviewer_labels_without_leaking_to_scoring"
+ row["next_unblocked_action"] = "complete_reviewer_template_then_run_classical_blind_unseal"
+ elif task_id == "domain_statistics_gate":
+ for row in rows:
+ row["bucket_target"] = "compute_domain_accuracy_and_false_positive_after_unseal"
+ row["next_unblocked_action"] = "run_classical_domain_validation_bundle_after_samples_and_labels_exist"
+ elif task_id == "promotion_threshold_calibration":
+ for row in rows:
+ row["bucket_target"] = "compare_domain_statistics_against_promotion_thresholds"
+ row["next_unblocked_action"] = "run_classical_promotion_threshold_calibration_after_domain_gates_pass"
+ else:
+ for row in rows:
+ row["bucket_target"] = "review_task"
+ row["next_unblocked_action"] = "inspect_task_register"
+ return rows
+
+
+def _classical_remaining_register_bucket_table(
+ task_id: str,
+ current_counts: dict[str, dict[str, Any]],
+) -> list[dict[str, Any]]:
+ pipelines = {
+ family: {
+ "status": counts.get("statistics_status", "blocked"),
+ "next_blocker": "blocked_missing_freeze_or_reviewer_evidence",
+ "ready_to_freeze_count": counts.get("ready_to_freeze", 0),
+ "reviewer_todo_count": counts.get("reviewer_todo", 0),
+ "score_row_count": counts.get("scored_rows", 0),
+ "stages": [
+ {
+ "stage": "freeze_admission",
+ "status": "blocked",
+ "path": None,
+ "counts": {
+ "ready_to_freeze_count": counts.get("ready_to_freeze", 0),
+ "reviewer_todo_count": counts.get("reviewer_todo", 0),
+ },
+ },
+ {
+ "stage": "domain_validation",
+ "status": counts.get("statistics_status", "blocked"),
+ "path": None,
+ "counts": {"score_row_count": counts.get("scored_rows", 0)},
+ },
+ ],
+ }
+ for family, counts in sorted(current_counts.items())
+ if isinstance(counts, dict)
+ }
+ return _classical_task_bucket_table(task_id, pipelines)
+
+
+def _load_json_packet_summary(path: Path, family: str, stage: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "stage": stage,
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "reason": "artifact_missing",
+ "promotion_allowed": False,
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover - corrupt local artifact
+ return {
+ "stage": stage,
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "reason": f"artifact_unreadable:{type(exc).__name__}",
+ "promotion_allowed": False,
+ }
+ if not isinstance(data, dict):
+ return {
+ "stage": stage,
+ "family": family,
+ "status": "blocked",
+ "path": str(path),
+ "reason": "artifact_not_object",
+ "promotion_allowed": False,
+ }
+ keys = (
+ "source_count",
+ "public_case_source_count",
+ "web_case_source_count",
+ "runtime_case_candidate_count",
+ "open_source_project_count",
+ "local_doctrine_text_count",
+ "local_doctrine_text_extractable_count",
+ "local_doctrine_text_ocr_required_count",
+ "rejected_local_text_count",
+ "source_candidate_count",
+ "converted_queue_item_count",
+ "item_count",
+ "fetched_count",
+ "rejected_or_unavailable_count",
+ "ready_to_freeze_pending_reviewer_count",
+ "ready_to_freeze_count",
+ "blocked_item_count",
+ "blocked_after_extraction_count",
+ "blocked_count",
+ "rejected_count",
+ "reviewer_todo_count",
+ "sample_count",
+ "chart_image_count",
+ "downloaded_count",
+ "ocr_cached_count",
+ "blocked_ocr_count",
+ "parsed_candidate_count",
+ "admitted_sample_count",
+ "label_count",
+ "intake_case_count",
+ "score_row_count",
+ "unseal_label_count",
+ "unsealed_row_count",
+ "answer_row_count",
+ "applied_item_count",
+ "pass_item_count",
+ )
+ counts = {key: int(data.get(key) or 0) for key in keys if key in data}
+ if stage == "source_discovery" and "source_count" not in counts:
+ receipts = data.get("source_receipts")
+ if isinstance(receipts, list):
+ counts["source_count"] = len(receipts)
+ gate = data.get("gate_summary") if isinstance(data.get("gate_summary"), dict) else {}
+ for key in keys:
+ if key in gate and key not in counts:
+ counts[key] = int(gate.get(key) or 0)
+ fill_brief = data.get("reviewer_fill_brief") if isinstance(data.get("reviewer_fill_brief"), dict) else {}
+ if fill_brief:
+ counts["reviewer_fill_brief_item_count"] = int(fill_brief.get("item_count") or 0)
+ counts["reviewer_fill_brief_context_backfilled_count"] = int(fill_brief.get("context_backfilled_count") or 0)
+ return {
+ "stage": stage,
+ "family": str(data.get("family") or family),
+ "status": str(data.get("status") or data.get("promotion_status") or "blocked"),
+ "schema": data.get("schema") or data.get("manifest_id") or "",
+ "path": str(path),
+ "counts": counts,
+ "promotion_allowed": data.get("promotion_allowed") is True,
+ "boundary": str(data.get("boundary") or ""),
+ }
+
+
+def _classical_domain_pipeline_summary(
+ family: str,
+ *,
+ source_discovery_path: Path,
+ source_freeze_conversion_path: Path,
+ field_extraction_path: Path,
+ chart_ocr_cache_path: Path | None,
+ ocr_field_backfill_path: Path | None,
+ freeze_admission_path: Path,
+ domain_validation_bundle: dict[str, Any],
+ reviewer_freeze_packet_path: Path | None = None,
+ reviewer_freeze_admission_path: Path | None = None,
+ reviewer_unseal_manifest_path: Path | None = None,
+ reviewer_evidence_pipeline_path: Path | None = None,
+ reviewer_evidence_kit_path: Path | None = None,
+ reviewer_evidence_assistant_path: Path | None = None,
+ reviewer_answer_apply_path: Path | None = None,
+) -> dict[str, Any]:
+ stages = [
+ _load_json_packet_summary(source_discovery_path, family, "source_discovery"),
+ _load_json_packet_summary(source_freeze_conversion_path, family, "source_freeze_conversion"),
+ _load_json_packet_summary(field_extraction_path, family, "case_level_field_extraction"),
+ *([
+ _load_json_packet_summary(chart_ocr_cache_path, family, "chart_ocr_cache")
+ ] if chart_ocr_cache_path and chart_ocr_cache_path.exists() else []),
+ *([
+ _load_json_packet_summary(ocr_field_backfill_path, family, "ocr_field_backfill")
+ ] if ocr_field_backfill_path and ocr_field_backfill_path.exists() else []),
+ _load_json_packet_summary(freeze_admission_path, family, "freeze_admission"),
+ *([
+ _load_json_packet_summary(reviewer_freeze_packet_path, family, "reviewer_freeze_packet")
+ ] if reviewer_freeze_packet_path and reviewer_freeze_packet_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_freeze_admission_path, family, "reviewer_freeze_admission")
+ ] if reviewer_freeze_admission_path and reviewer_freeze_admission_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_unseal_manifest_path, family, "reviewer_unseal_manifest")
+ ] if reviewer_unseal_manifest_path and reviewer_unseal_manifest_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_evidence_pipeline_path, family, "reviewer_evidence_pipeline")
+ ] if reviewer_evidence_pipeline_path and reviewer_evidence_pipeline_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_evidence_kit_path, family, "reviewer_evidence_kit")
+ ] if reviewer_evidence_kit_path and reviewer_evidence_kit_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_evidence_assistant_path, family, "reviewer_evidence_assistant")
+ ] if reviewer_evidence_assistant_path and reviewer_evidence_assistant_path.exists() else []),
+ *([
+ _load_json_packet_summary(reviewer_answer_apply_path, family, "reviewer_answer_apply")
+ ] if reviewer_answer_apply_path and reviewer_answer_apply_path.exists() else []),
+ {
+ "stage": "domain_validation",
+ "family": family,
+ "status": str(domain_validation_bundle.get("status") or "blocked"),
+ "schema": domain_validation_bundle.get("schema") or "",
+ "path": str(domain_validation_bundle.get("path") or ""),
+ "counts": {
+ "score_row_count": int(domain_validation_bundle.get("gate_summary", {}).get("score_row_count") or 0)
+ if isinstance(domain_validation_bundle.get("gate_summary"), dict)
+ else 0,
+ },
+ "promotion_allowed": domain_validation_bundle.get("promotion_allowed") is True,
+ "boundary": str(domain_validation_bundle.get("boundary") or ""),
+ },
+ ]
+ freeze_stage = next((stage for stage in stages if stage["stage"] == "freeze_admission"), {})
+ validation_stage = next((stage for stage in stages if stage["stage"] == "domain_validation"), {})
+ freeze_counts = freeze_stage.get("counts") if isinstance(freeze_stage.get("counts"), dict) else {}
+ validation_counts = validation_stage.get("counts") if isinstance(validation_stage.get("counts"), dict) else {}
+ ready_to_freeze = int(freeze_counts.get("ready_to_freeze_count") or 0)
+ reviewer_todo = int(freeze_counts.get("reviewer_todo_count") or 0)
+ score_rows = int(validation_counts.get("score_row_count") or 0)
+ if ready_to_freeze and reviewer_todo == 0 and score_rows:
+ status = "partial_review_ready"
+ elif ready_to_freeze or reviewer_todo:
+ status = "partial_freeze_review_needed"
+ else:
+ status = "blocked_no_freeze_ready_cases"
+ next_blocker = (
+ "domain_scoring_rows_missing"
+ if ready_to_freeze and not score_rows
+ else "reviewer_todo_or_freeze_fields_missing"
+ if reviewer_todo
+ else "case_level_freeze_fields_missing"
+ )
+ return {
+ "schema": "classical.domain_pipeline_summary.v1",
+ "family": family,
+ "status": status,
+ "stage_count": len(stages),
+ "stages": stages,
+ "ready_to_freeze_count": ready_to_freeze,
+ "reviewer_todo_count": reviewer_todo,
+ "score_row_count": score_rows,
+ "next_blocker": next_blocker,
+ "promotion_allowed": False,
+ "boundary": (
+ "Domain pipeline summary reports source/case/freeze/reviewer/statistics progress only; "
+ "it does not approve horary yes/no, electional best-time, or ZR prediction-ready claims."
+ ),
+ }
+
+
+def build_classical_remaining_task_register_packet(
+ *,
+ output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ horary_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_HORARY_ACQUISITION_RUNNER_PACKET, "horary")
+ electional_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_RUNNER_PACKET, "electional")
+ zr_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_ZR_ACQUISITION_RUNNER_PACKET, "zr")
+ horary_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_HORARY_FREEZE_CHECKLIST, "horary")
+ electional_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_CHECKLIST, "electional")
+ horary_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE, "horary")
+ electional_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE, "electional")
+ zr_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_ZR_DOMAIN_VALIDATION_BUNDLE, "zr")
+ horary_pipeline = _classical_domain_pipeline_summary(
+ "horary",
+ source_discovery_path=DEFAULT_CLASSICAL_HORARY_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_HORARY_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_HORARY_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_HORARY_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_HORARY_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_HORARY_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=horary_bundle,
+ )
+ electional_pipeline = _classical_domain_pipeline_summary(
+ "electional",
+ source_discovery_path=DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_ELECTIONAL_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_ELECTIONAL_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_ELECTIONAL_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=electional_bundle,
+ )
+ zr_pipeline = _classical_domain_pipeline_summary(
+ "zr",
+ source_discovery_path=DEFAULT_CLASSICAL_ZR_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_ZR_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_ZR_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_ZR_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_ZR_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_ZR_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_ZR_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_ZR_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=zr_bundle,
+ )
+
+ def pipeline_current_counts(pipeline: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
+ stages = pipeline.get("stages") if isinstance(pipeline.get("stages"), list) else []
+ stage_counts = {
+ str(stage.get("stage")): stage.get("counts")
+ for stage in stages
+ if isinstance(stage, dict) and isinstance(stage.get("counts"), dict)
+ }
+ source_conversion = stage_counts.get("source_freeze_conversion", {})
+ field_extraction = stage_counts.get("case_level_field_extraction", {})
+ freeze_admission = stage_counts.get("freeze_admission", {})
+ gate_summary = bundle.get("gate_summary") if isinstance(bundle.get("gate_summary"), dict) else {}
+ candidate_items = max(
+ int(source_conversion.get("converted_queue_item_count") or 0),
+ int(source_conversion.get("item_count") or 0),
+ int(field_extraction.get("item_count") or 0),
+ int(freeze_admission.get("item_count") or 0),
+ )
+ return {
+ "candidate_queue_items": candidate_items,
+ "ready_to_freeze": int(freeze_admission.get("ready_to_freeze_count") or pipeline.get("ready_to_freeze_count") or 0),
+ "reviewer_todo": int(freeze_admission.get("reviewer_todo_count") or pipeline.get("reviewer_todo_count") or 0),
+ "blocked_after_extraction": int(
+ freeze_admission.get("blocked_count")
+ or field_extraction.get("blocked_after_extraction_count")
+ or 0
+ ),
+ "rejected_or_unavailable": int(
+ freeze_admission.get("rejected_count")
+ or field_extraction.get("rejected_or_unavailable_count")
+ or 0
+ ),
+ "intake_cases": int(gate_summary.get("intake_case_count") or 0),
+ "scored_rows": int(gate_summary.get("score_row_count") or 0),
+ "unsealed_rows": int(gate_summary.get("unsealed_row_count") or 0),
+ "statistics_status": gate_summary.get("statistics_status", "blocked"),
+ }
+
+ packet = _load_classical_remaining_task_register(
+ horary_runner=horary_runner,
+ electional_runner=electional_runner,
+ zr_runner=zr_runner,
+ horary_bundle=horary_bundle,
+ electional_bundle=electional_bundle,
+ zr_bundle=zr_bundle,
+ )
+ packet = {
+ **packet,
+ "schema": "classical.remaining_task_register.runtime.v1",
+ "current_counts": {
+ "horary": pipeline_current_counts(horary_pipeline, horary_bundle),
+ "electional": pipeline_current_counts(electional_pipeline, electional_bundle),
+ "zr": pipeline_current_counts(zr_pipeline, zr_bundle),
+ },
+ "runner_summaries": {
+ "horary": horary_runner,
+ "electional": electional_runner,
+ "zr": zr_runner,
+ },
+ "domain_validation_bundle_summaries": {
+ "horary": horary_bundle,
+ "electional": electional_bundle,
+ "zr": zr_bundle,
+ },
+ "domain_pipeline_summaries": {
+ "horary": horary_pipeline,
+ "electional": electional_pipeline,
+ "zr": zr_pipeline,
+ },
+ "runtime_reviewer_todo_total": (
+ int(horary_pipeline.get("reviewer_todo_count") or 0)
+ + int(electional_pipeline.get("reviewer_todo_count") or 0)
+ + int(zr_pipeline.get("reviewer_todo_count") or 0)
+ ),
+ "boundary": "Runtime remaining-task register only; blocked/partial/candidate states remain non-predictive until independent evidence gates pass.",
+ "promotion_allowed": False,
+ }
+ pipelines = {
+ "electional": electional_pipeline,
+ "horary": horary_pipeline,
+ "zr": zr_pipeline,
+ }
+ for task in packet.get("tasks", []):
+ if isinstance(task, dict):
+ task["bucket_status_table"] = _classical_task_bucket_table(
+ str(task.get("task_id") or ""),
+ pipelines,
+ )
+ task["next_unblocked_action"] = (
+ task["bucket_status_table"][0]["next_unblocked_action"]
+ if task.get("bucket_status_table")
+ else "inspect_task_register"
+ )
+ packet["bucket_status_summary"] = {
+ task["task_id"]: {
+ "status": task.get("status", "blocked"),
+ "next_unblocked_action": task.get("next_unblocked_action"),
+ "family_rows": task.get("bucket_status_table", []),
+ "promotion_allowed": False,
+ }
+ for task in packet.get("tasks", [])
+ if isinstance(task, dict) and task.get("task_id")
+ }
+ packet["remaining_task_summary"] = _classical_remaining_task_summary(packet)
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def _load_classical_productization_shell_summary(
+ path: Path = DEFAULT_CLASSICAL_PRODUCTIZATION_SHELL,
+) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "reason": "productization_shell_missing",
+ "promotion_allowed": False,
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "reason": f"productization_shell_unreadable:{type(exc).__name__}",
+ "promotion_allowed": False,
+ }
+ doctor = data.get("doctor") if isinstance(data.get("doctor"), dict) else {}
+ registry = data.get("artifact_registry") if isinstance(data.get("artifact_registry"), dict) else {}
+ freeze_checklist_summary = doctor.get("freeze_checklist_summary") if isinstance(doctor.get("freeze_checklist_summary"), dict) else {}
+ cards = data.get("technique_cards") if isinstance(data.get("technique_cards"), list) else []
+ return {
+ "status": data.get("status", "degraded"),
+ "path": str(path),
+ "doctor_status": doctor.get("status", "degraded"),
+ "artifact_count": int(registry.get("artifact_count") or 0),
+ "blocked_or_invalid_artifact_count": int(doctor.get("blocked_or_invalid_artifact_count") or registry.get("blocked_or_invalid_count") or 0),
+ "expected_blocked_artifact_count": int(doctor.get("expected_blocked_artifact_count") or registry.get("expected_blocked_artifact_count") or 0),
+ "invalid_or_unreadable_artifact_count": int(doctor.get("invalid_or_unreadable_artifact_count") or registry.get("invalid_or_unreadable_artifact_count") or 0),
+ "label_leakage_review_count": int(doctor.get("label_leakage_review_count") or registry.get("label_leakage_review_count") or 0),
+ "freeze_checklist_summary": freeze_checklist_summary,
+ "artifact_status_counts": registry.get("status_counts") if isinstance(registry.get("status_counts"), dict) else {},
+ "artifact_schema_counts": registry.get("schema_counts") if isinstance(registry.get("schema_counts"), dict) else {},
+ "label_leakage_risk_counts": registry.get("label_leakage_risk_counts") if isinstance(registry.get("label_leakage_risk_counts"), dict) else {},
+ "technique_card_count": len(cards),
+ "technique_card_families": [
+ str(card.get("family"))
+ for card in cards
+ if isinstance(card, dict) and card.get("family")
+ ],
+ "promotion_allowed": False,
+ "boundary": data.get("boundary", "Productization shell summary only; not a promotion result."),
+ }
+
+
+def _classical_blind_sample_intake_quality(manifest: dict[str, Any]) -> dict[str, Any]:
+ samples = manifest.get("frozen_sample_queue") if isinstance(manifest.get("frozen_sample_queue"), list) else []
+ rows = []
+ for sample in samples:
+ if not isinstance(sample, dict):
+ continue
+ labels = sample.get("labels") if isinstance(sample.get("labels"), dict) else {}
+ missing = []
+ if not sample.get("case_id"):
+ missing.append("case_id")
+ if not sample.get("domain"):
+ missing.append("domain")
+ if not sample.get("sample_kind"):
+ missing.append("sample_kind")
+ if not str(sample.get("sample_status", "")).startswith("frozen_"):
+ missing.append("frozen_sample_status")
+ if labels.get("expected_label") is not None:
+ missing.append("expected_label_must_be_withheld_in_scoring_manifest")
+ if labels.get("blind_frozen") is not True:
+ missing.append("blind_frozen_true")
+ if not _valid_iso_timestamp(labels.get("freeze_timestamp")):
+ missing.append("label_freeze_timestamp")
+ if labels.get("label_independence") is not True:
+ missing.append("label_independence_true")
+ rows.append({
+ "sample_id": sample.get("case_id"),
+ "domain": sample.get("domain"),
+ "sample_kind": sample.get("sample_kind"),
+ "sample_status": sample.get("sample_status"),
+ "status": "partial" if not missing else "blocked",
+ "missing_or_blocked_fields": missing,
+ "label_visibility": "withheld",
+ "promotion_allowed": False,
+ })
+ blocked_rows = [row for row in rows if row["status"] == "blocked"]
+ return {
+ "schema": "classical.blind_sample_intake_quality.v1",
+ "status": "partial" if rows and not blocked_rows else "blocked",
+ "row_count": len(rows),
+ "blocked_row_count": len(blocked_rows),
+ "rows": rows,
+ "required_fields": [
+ "case_id",
+ "domain",
+ "sample_kind",
+ "frozen sample_status",
+ "blind_frozen=true",
+ "valid label freeze_timestamp",
+ "label_independence=true",
+ "no expected_label in scoring-visible manifest",
+ ],
+ "promotion_allowed": False,
+ "boundary": "Intake quality checks frozen containers before scoring. It never exposes expected labels and cannot promote prediction readiness.",
+ }
+
+
+def _sanitize_blind_sample_case(sample: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
+ labels = sample.get("labels") if isinstance(sample.get("labels"), dict) else {}
+ redacted_label_fields = [
+ field for field in ("expected_label", "outcome_label", "actual_outcome")
+ if field in labels or field in sample
+ ]
+ sanitized_labels = {
+ "blind_frozen": labels.get("blind_frozen") is True,
+ "expected_label_visible_to_scorer": False,
+ "label_release_status": labels.get("label_release_status", "blocked_pending_independent_reviewer"),
+ }
+ for optional_field in ("freeze_timestamp", "label_independence", "reviewer_source", "label_source"):
+ if optional_field in labels:
+ sanitized_labels[optional_field] = labels[optional_field]
+ sanitized = {
+ "case_id": sample.get("case_id") or sample.get("sample_id"),
+ "domain": sample.get("domain"),
+ "sample_status": sample.get("sample_status", "frozen_container"),
+ "sample_kind": sample.get("sample_kind"),
+ "source_refs": sample.get("source_refs") if isinstance(sample.get("source_refs"), list) else [],
+ "labels": sanitized_labels,
+ "source_policy": sample.get(
+ "source_policy",
+ "intake sample sanitized for blind scoring; expected label values are withheld from scoring-visible manifest",
+ ),
+ }
+ for optional_field in (
+ "birth_data",
+ "reference_date",
+ "house_system",
+ "event_window_hours",
+ "event_step_hours",
+ "technique_scope",
+ "event_date",
+ "event_window",
+ "topic",
+ "notes",
+ ):
+ if optional_field in sample:
+ sanitized[optional_field] = sample[optional_field]
+ redaction = {
+ "sample_id": sanitized["case_id"],
+ "redacted_label_fields": redacted_label_fields,
+ "expected_label_value_stored": False,
+ "scoring_visible_expected_label": False,
+ }
+ return sanitized, redaction
+
+
+def build_classical_blind_sample_intake_manifest_packet(
+ *,
+ sample_packet_path: Path | str,
+ output_manifest_path: Path | str,
+ base_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ sample_path = Path(sample_packet_path)
+ output_path = Path(output_manifest_path)
+ if not sample_path.exists():
+ return {
+ "system": "classical_astrology",
+ "layer": "blind_sample_intake_manifest_builder",
+ "status": "blocked",
+ "reason": "sample packet is missing",
+ "sample_packet_path": str(sample_path),
+ "promotion_allowed": False,
+ }
+ packet = json.loads(sample_path.read_text(encoding="utf-8"))
+ raw_samples = packet.get("cases") if isinstance(packet.get("cases"), list) else packet.get("samples")
+ if not isinstance(raw_samples, list):
+ raw_samples = []
+ base_path = Path(base_manifest_path) if base_manifest_path else DEFAULT_CLASSICAL_BLIND_VALIDATION_MANIFEST
+ base = {}
+ if base_path.exists():
+ base = json.loads(base_path.read_text(encoding="utf-8"))
+ existing_cases = base.get("cases") if isinstance(base.get("cases"), list) else []
+ sanitized_cases = []
+ redactions = []
+ default_technique_scope = packet.get("default_technique_scope")
+ for sample in raw_samples:
+ if not isinstance(sample, dict):
+ continue
+ if isinstance(default_technique_scope, list) and "technique_scope" not in sample:
+ sample = {**sample, "technique_scope": default_technique_scope}
+ sanitized, redaction = _sanitize_blind_sample_case(sample)
+ sanitized_cases.append(sanitized)
+ redactions.append(redaction)
+ existing_by_id = {
+ case.get("case_id"): case for case in existing_cases
+ if isinstance(case, dict) and case.get("case_id")
+ }
+ for case in sanitized_cases:
+ if case.get("case_id"):
+ existing_by_id[case["case_id"]] = case
+ merged_cases = list(existing_by_id.values())
+ manifest = {
+ "manifest_id": packet.get("manifest_id") or base.get("manifest_id") or "classical_blind_case_validation_manifest_runtime",
+ "system": "classical_astrology",
+ "claim_status": "intake_contract_only",
+ "promotion_status": "blocked",
+ "production_tuning_allowed": False,
+ "truth_matrix_allowed": False,
+ "required_protocol": base.get("required_protocol", {}),
+ "frozen_sample_queue_policy": base.get("frozen_sample_queue_policy", {}),
+ "blockers": base.get("blockers", []),
+ "cases": merged_cases,
+ "boundary": "Runtime intake manifest for blind scoring. Expected labels are withheld and must be supplied only through an independent reviewer unseal manifest.",
+ }
+ loaded_manifest = {
+ "status": "partial" if merged_cases else "blocked",
+ "path": str(output_path),
+ "manifest_id": manifest["manifest_id"],
+ "claim_status": manifest["claim_status"],
+ "case_count": len(merged_cases),
+ "frozen_sample_queue_count": len([
+ case for case in merged_cases
+ if isinstance(case, dict) and str(case.get("sample_status", "")).startswith("frozen_")
+ ]),
+ "blind_label_count": 0,
+ "sample_status_counts": _count_by_value([case.get("sample_status") for case in merged_cases if isinstance(case, dict)]),
+ "blockers": manifest["blockers"],
+ "required_protocol": manifest["required_protocol"],
+ "frozen_sample_queue": [
+ case for case in merged_cases
+ if isinstance(case, dict) and str(case.get("sample_status", "")).startswith("frozen_")
+ ],
+ "promotion_status": "blocked",
+ }
+ intake_quality = _classical_blind_sample_intake_quality(loaded_manifest)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ return {
+ "system": "classical_astrology",
+ "layer": "blind_sample_intake_manifest_builder",
+ "schema": "classical.blind_sample_intake_manifest_builder.v1",
+ "status": "partial" if sanitized_cases else "blocked",
+ "sample_packet_path": str(sample_path),
+ "base_manifest_path": str(base_path),
+ "output_manifest_path": str(output_path),
+ "input_sample_count": len(raw_samples),
+ "sanitized_sample_count": len(sanitized_cases),
+ "merged_case_count": len(merged_cases),
+ "redaction_audit": {
+ "status": "executed",
+ "rows": redactions,
+ "expected_label_values_stored": False,
+ "label_visibility": "withheld",
+ },
+ "intake_quality": intake_quality,
+ "promotion_allowed": False,
+ "boundary": "Builds a scoring-visible blind intake manifest only; labels remain withheld and prediction promotion remains blocked.",
+ }
+
+
+def _load_classical_reviewer_unseal_manifest(path: Path = DEFAULT_CLASSICAL_REVIEWER_UNSEAL_MANIFEST) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "status": "blocked",
+ "path": str(path),
+ "label_count": 0,
+ "reason": "reviewer unseal manifest is missing",
+ }
+ data = json.loads(path.read_text(encoding="utf-8"))
+ labels = data.get("labels") if isinstance(data.get("labels"), list) else []
+ usable_labels = [
+ label for label in labels
+ if isinstance(label, dict) and label.get("sample_id") and label.get("expected_label")
+ ]
+ return {
+ "status": "partial" if usable_labels else "blocked",
+ "path": str(path),
+ "manifest_id": data.get("manifest_id"),
+ "claim_status": data.get("claim_status"),
+ "required_protocol": data.get("required_protocol", {}),
+ "label_count": len(usable_labels),
+ "labels": usable_labels,
+ "blockers": data.get("blockers") if isinstance(data.get("blockers"), list) else [],
+ "promotion_status": "blocked",
+ "boundary": "Reviewer labels are read only by the unseal entrypoint, never by the blind scoring runner.",
+ }
+
+
+def _verdict(
+ rule_id: str,
+ *,
+ status: str,
+ observed: Any,
+ blocks: bool,
+ reason: str,
+ rule_family: str = "threshold",
+) -> dict[str, Any]:
+ verdict = "blocks_promotion" if blocks else "supports_review"
+ family_loci_map = {
+ "threshold": ("zr",),
+ "promotion_gate": ("zr", "lots"),
+ "zr_peak_lord": ("zr",),
+ "zr_profection": ("zr",),
+ "zr_profection_arbitration": ("zr",),
+ "lot_ruler_condition": ("zr", "lots", "dignity"),
+ "loosing_cross_lot": ("zr", "lots"),
+ "horary_perfection": ("horary",),
+ "horary_timing": ("horary",),
+ "horary_prohibition": ("horary",),
+ "horary_moon_condition": ("horary", "electional"),
+ "horary_translation_collection": ("horary", "reception"),
+ "electional_candidate": ("electional",),
+ "electional_threshold": ("electional",),
+ "electional_goal_specific": ("electional",),
+ "electional_goal_specific_cautions": ("electional",),
+ "electional_moon_condition": ("electional",),
+ "electional_hard_prohibition": ("electional",),
+ }
+ rule_locus_family_map = {
+ "zr_peak_lord": "peak_lord",
+ "zr_profection": "profection",
+ "zr_profection_arbitration": "profection",
+ "lot_ruler_condition": "fortune_spirit",
+ "loosing_cross_lot": "loosing_cross_lot",
+ "promotion_gate": "release_lord",
+ "threshold": "release_lord",
+ "horary_perfection": "horary_perfection",
+ "horary_timing": "horary_timing",
+ "horary_prohibition": "horary_prohibition",
+ "horary_moon_condition": "horary_moon_condition",
+ "horary_translation_collection": "horary_translation_collection",
+ "electional_candidate": "electional_candidate",
+ "electional_threshold": "electional_threshold",
+ "electional_goal_specific": "electional_goal_specific",
+ "electional_goal_specific_cautions": "electional_goal_specific_cautions",
+ "electional_moon_condition": "electional_moon_condition",
+ "electional_hard_prohibition": "electional_hard_prohibition",
+ }
+ loci = _source_loci(
+ *family_loci_map.get(rule_family, ()),
+ rule_family=rule_locus_family_map.get(rule_family),
+ )
+ return {
+ "rule_id": rule_id,
+ "doctrine_rule": rule_id,
+ "rule_family": rule_family,
+ "source_loci": loci,
+ "source_loci_count": len(loci),
+ "status": status,
+ "raw_input": observed,
+ "observed": observed,
+ "verdict": verdict,
+ "blocks_promotion": blocks,
+ "cannot_promote_reason": reason if blocks else "",
+ "review_support_reason": "" if blocks else reason,
+ "reason": reason,
+ "promotion_boundary": (
+ "This doctrine verdict blocks prediction-ready promotion until its rule table and blind validation gates close."
+ if blocks else
+ "This doctrine verdict supports review only; it does not independently establish prediction-ready promotion."
+ ),
+ }
+
+
+def _evaluate_zr_event_prominence_rule_table(
+ zr_matrix: dict[str, Any],
+ *,
+ release: dict[str, Any] | None = None,
+ profection: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ release = release if isinstance(release, dict) else {}
+ profection = profection if isinstance(profection, dict) else {}
+ rows = zr_matrix.get("rows") if isinstance(zr_matrix.get("rows"), list) else []
+ support_count = sum(1 for row in rows if isinstance(row, dict) and row.get("supports_prominence") is True)
+ top_peak = next((row for row in rows if isinstance(row, dict) and row.get("factor") == "top_peak_candidate_weight"), {})
+ period_lord = next((row for row in rows if isinstance(row, dict) and row.get("factor") == "period_lord_condition"), {})
+ lot_ruler = next((row for row in rows if isinstance(row, dict) and row.get("factor") == "lot_ruler_condition"), {})
+ active_peak = next((row for row in rows if isinstance(row, dict) and row.get("factor") == "active_period_peak"), {})
+ top_peak_review = (
+ (release.get("peak_periods") or {}).get("top_candidate_review")
+ if isinstance(release.get("peak_periods"), dict)
+ else {}
+ )
+ active_period = release.get("active_period") if isinstance(release.get("active_period"), dict) else {}
+ profection_raw = profection.get("raw_result") if isinstance(profection.get("raw_result"), dict) else profection
+ year_lord = profection_raw.get("year_lord") if isinstance(profection_raw, dict) else None
+ active_lord = active_period.get("lord")
+ top_peak_lord = top_peak_review.get("lord") if isinstance(top_peak_review, dict) else None
+ cross_lot_review = release.get("cross_lot_release_review") if isinstance(release.get("cross_lot_release_review"), dict) else {}
+ cross_lot_alignment = (
+ cross_lot_review.get("cross_lot_alignment")
+ if isinstance(cross_lot_review.get("cross_lot_alignment"), dict)
+ else {}
+ )
+ loosing = release.get("loosing_of_bond") if isinstance(release.get("loosing_of_bond"), dict) else {}
+ threshold = _ZR_EVENT_PROMINENCE_RULE_TABLE["thresholds"]
+ zr_component_loci = {
+ "release_lord": _source_loci("zr", rule_family="release_lord"),
+ "peak_lord": _source_loci("zr", rule_family="peak_lord"),
+ "profection": _source_loci("zr", rule_family="profection"),
+ "lot_ruler_condition": _source_loci("zr", "lots", rule_family="fortune_spirit"),
+ "loosing_cross_lot": _source_loci("zr", rule_family="loosing_cross_lot"),
+ }
+ hard_blockers = []
+ if zr_matrix.get("promotion_status") == "blocked":
+ hard_blockers.append("event_prominence_gate_blocked")
+ if support_count < int(threshold["minimum_support_count_for_review"]):
+ hard_blockers.append("insufficient_support_count")
+ if float(top_peak.get("evidence") or 0) < float(threshold["minimum_top_peak_weight_for_review"]):
+ hard_blockers.append("insufficient_peak_weight")
+ doctrine_verdicts = [
+ _verdict(
+ "minimum_support_count_for_review",
+ status="executed",
+ observed=support_count,
+ blocks=support_count < int(threshold["minimum_support_count_for_review"]),
+ reason="ZR support rows must reach the review threshold before doctrine promotion.",
+ rule_family="threshold",
+ ),
+ _verdict(
+ "minimum_top_peak_weight_for_review",
+ status="executed" if top_peak else "blocked",
+ observed=top_peak.get("evidence"),
+ blocks=float(top_peak.get("evidence") or 0) < float(threshold["minimum_top_peak_weight_for_review"]),
+ reason="Top peak candidate must reach the review threshold before event-prominence doctrine can be promoted.",
+ rule_family="threshold",
+ ),
+ _verdict(
+ "event_prominence_gate",
+ status=zr_matrix.get("promotion_status", "blocked"),
+ observed=zr_matrix.get("promotion_status", "blocked"),
+ blocks=zr_matrix.get("promotion_status") == "blocked",
+ reason="Existing event-prominence gate remains the hard promotion boundary.",
+ rule_family="promotion_gate",
+ ),
+ _verdict(
+ "peak_lord_condition_family",
+ status=period_lord.get("status", "blocked"),
+ observed={
+ "period_lord_condition": period_lord.get("evidence"),
+ "lot_ruler_condition": lot_ruler.get("evidence"),
+ "active_period_peak": active_peak.get("evidence"),
+ "top_peak_lord": top_peak_lord,
+ },
+ blocks=not any([
+ period_lord.get("supports_prominence") is True,
+ lot_ruler.get("supports_prominence") is True,
+ active_peak.get("supports_prominence") is True,
+ ]),
+ reason="Peak-lord and lot-ruler condition must supply at least one review support before event prominence can be considered.",
+ rule_family="zr_peak_lord",
+ ),
+ _verdict(
+ "annual_profection_release_lord_arbitration",
+ status="partial" if year_lord or active_lord or top_peak_lord else "blocked",
+ observed={
+ "annual_lord": year_lord,
+ "active_release_lord": active_lord,
+ "top_peak_lord": top_peak_lord,
+ "annual_lord_matches_active_release_lord": year_lord == active_lord if year_lord and active_lord else None,
+ "annual_lord_matches_top_peak_lord": year_lord == top_peak_lord if year_lord and top_peak_lord else None,
+ },
+ blocks=not (year_lord and (year_lord == active_lord or year_lord == top_peak_lord)),
+ reason="Annual profection lord must be explicitly cross-checked with release and peak lords before ZR event-prominence promotion.",
+ rule_family="zr_profection_arbitration",
+ ),
+ ]
+ component_rows = [
+ {
+ "id": "release_lord_condition",
+ "family": "release_lord",
+ "weight": 0.2,
+ "support": period_lord.get("supports_prominence") is True,
+ "status": period_lord.get("status", "blocked"),
+ "raw_input": {
+ "active_release_lord": active_lord,
+ "period_lord_condition": period_lord.get("evidence"),
+ },
+ "calibration_boundary": "Release lord condition can support event prominence, but cannot alone predict an event.",
+ },
+ {
+ "id": "peak_lord_condition",
+ "family": "peak_lord",
+ "weight": 0.2,
+ "support": active_peak.get("supports_prominence") is True or top_peak.get("supports_prominence") is True,
+ "status": active_peak.get("status") or top_peak.get("status", "blocked"),
+ "raw_input": {
+ "active_period_peak": active_peak.get("evidence"),
+ "top_peak_weight": top_peak.get("evidence"),
+ "top_peak_lord": top_peak_lord,
+ },
+ "calibration_boundary": "Peak testimony is prominence evidence only until replay separates true events from famous non-events.",
+ },
+ {
+ "id": "annual_profection_arbitration",
+ "family": "profection",
+ "weight": 0.2,
+ "support": bool(year_lord and (year_lord == active_lord or year_lord == top_peak_lord)),
+ "status": "partial" if year_lord or active_lord or top_peak_lord else "blocked",
+ "raw_input": {
+ "annual_lord": year_lord,
+ "active_release_lord": active_lord,
+ "top_peak_lord": top_peak_lord,
+ "annual_lord_matches_active_release_lord": year_lord == active_lord if year_lord and active_lord else None,
+ "annual_lord_matches_top_peak_lord": year_lord == top_peak_lord if year_lord and top_peak_lord else None,
+ },
+ "calibration_boundary": "Profection agreement raises review priority but does not close prediction readiness.",
+ },
+ {
+ "id": "lot_ruler_condition",
+ "family": "lot_ruler_condition",
+ "weight": 0.2,
+ "support": lot_ruler.get("supports_prominence") is True,
+ "status": lot_ruler.get("status", "blocked"),
+ "raw_input": {
+ "lot_ruler_condition": lot_ruler.get("evidence"),
+ },
+ "calibration_boundary": "Lot ruler condition requires domain-specific replay before it can carry event-level weight.",
+ },
+ {
+ "id": "loosing_cross_lot_alignment",
+ "family": "loosing_cross_lot",
+ "weight": 0.2,
+ "support": bool(
+ loosing.get("detected") is True
+ or cross_lot_alignment.get("same_l1_lord") is True
+ or cross_lot_alignment.get("same_l2_lord") is True
+ ),
+ "status": cross_lot_review.get("status", "blocked"),
+ "raw_input": {
+ "loosing_detected": loosing.get("detected"),
+ "same_l1_lord": cross_lot_alignment.get("same_l1_lord"),
+ "same_l2_lord": cross_lot_alignment.get("same_l2_lord"),
+ },
+ "calibration_boundary": "Loosing and cross-lot agreement are high-salience review flags, not standalone event claims.",
+ },
+ ]
+ for component_row in component_rows:
+ family = component_row.get("family")
+ loci = zr_component_loci.get(str(family), [])
+ component_row["source_loci_summary"] = _trace_source_loci_summary(loci)
+ component_row["rule_locus_catalog_ids"] = component_row["source_loci_summary"].get("rule_locus_catalog_ids", [])
+ component_row["rule_locus_labels"] = component_row["source_loci_summary"].get("rule_locus_labels", [])
+ component_row["source_loci_boundary"] = (
+ "Component loci are doctrine retrieval anchors for calibration only; they do not promote ZR prediction readiness."
+ )
+ supported_weight = round(sum(row["weight"] for row in component_rows if row["support"]), 6)
+ scorecard = {
+ "schema": "classical.zr_event_prominence_component_scorecard.v1",
+ "status": "partial",
+ "scoring_mode": "component_weight_review_only",
+ "component_count": len(component_rows),
+ "supported_component_count": sum(1 for row in component_rows if row["support"]),
+ "supported_weight": supported_weight,
+ "review_score": supported_weight,
+ "rows": component_rows,
+ "promotion_allowed": False,
+ "boundary": "ZR component scoring separates doctrine families for calibration. It is not a prediction-ready promotion rule.",
+ }
+ return {
+ **_ZR_EVENT_PROMINENCE_RULE_TABLE,
+ "observed": {
+ "support_count": support_count,
+ "top_peak_weight": top_peak.get("evidence"),
+ "zr_promotion_status": zr_matrix.get("promotion_status", "blocked"),
+ },
+ "doctrine_verdicts": doctrine_verdicts,
+ "source_loci": _source_loci("zr", "lots", "dignity"),
+ "component_scorecard": scorecard,
+ "verdict_count": len(doctrine_verdicts),
+ "promotion_block_count": sum(1 for row in doctrine_verdicts if row["blocks_promotion"]),
+ "hard_blockers_observed": hard_blockers,
+ "review_status": "blocked" if hard_blockers else "partial_review_ready",
+ }
+
+
+def _evaluate_horary_perfection_timing_thresholds(horary: dict[str, Any]) -> dict[str, Any]:
+ perfection = horary.get("perfection_candidate", {}) if isinstance(horary.get("perfection_candidate"), dict) else {}
+ prohibition = horary.get("prohibition_candidate", {}) if isinstance(horary.get("prohibition_candidate"), dict) else {}
+ mediation = horary.get("translation_collection_candidate", {}) if isinstance(horary.get("translation_collection_candidate"), dict) else {}
+ moon_bridge = horary.get("moon_significator_bridge", {}) if isinstance(horary.get("moon_significator_bridge"), dict) else {}
+ moon_voc = horary.get("moon_void_of_course", {}) if isinstance(horary.get("moon_void_of_course"), dict) else {}
+ degrees = perfection.get("travel_degrees")
+ prohibition_candidate = prohibition.get("candidate") if isinstance(prohibition.get("candidate"), dict) else {}
+ mediation_span = mediation.get("span_degrees")
+ moon_bridge_degrees = moon_bridge.get("travel_degrees")
+ threshold = _HORARY_PERFECTION_TIMING_THRESHOLDS["thresholds"]
+ blockers = []
+ if perfection.get("status") != "partial":
+ blockers.append("no_direct_perfection")
+ if prohibition.get("detected") is True:
+ blockers.append("prohibition_detected")
+ if moon_voc.get("void_like_until_sign_exit") is True:
+ blockers.append("moon_void_like")
+ review_class = (
+ "close" if isinstance(degrees, (int, float)) and degrees <= threshold["close_perfection_degrees"]
+ else "reviewable" if isinstance(degrees, (int, float)) and degrees <= threshold["reviewable_perfection_degrees"]
+ else "wide" if isinstance(degrees, (int, float)) and degrees <= threshold["wide_perfection_degrees"]
+ else "blocked"
+ )
+ doctrine_verdicts = [
+ _verdict(
+ "direct_applying_aspect",
+ status=perfection.get("status", "blocked"),
+ observed={"travel_degrees": degrees, "timing_review_class": review_class},
+ blocks=perfection.get("status") != "partial",
+ reason="Horary timing needs a direct applying aspect or a separate mediation verdict.",
+ rule_family="horary_perfection",
+ ),
+ _verdict(
+ "reviewable_perfection_degrees",
+ status="executed" if isinstance(degrees, (int, float)) else "blocked",
+ observed=degrees,
+ blocks=not (isinstance(degrees, (int, float)) and degrees <= threshold["reviewable_perfection_degrees"]),
+ reason="Perfection must fall inside the reviewable degree threshold before timing doctrine can be reviewed.",
+ rule_family="horary_timing",
+ ),
+ _verdict(
+ "prohibition_screen",
+ status=prohibition.get("status", "blocked"),
+ observed=prohibition.get("detected"),
+ blocks=prohibition.get("detected") is True,
+ reason="Detected prohibition blocks a direct yes/no promotion in this conservative chain.",
+ rule_family="horary_prohibition",
+ ),
+ _verdict(
+ "moon_void_like",
+ status=moon_voc.get("status", "blocked"),
+ observed=moon_voc.get("void_like_until_sign_exit"),
+ blocks=moon_voc.get("void_like_until_sign_exit") is True,
+ reason="Moon void-like condition blocks promotion until the doctrine variant is adjudicated.",
+ rule_family="horary_moon_condition",
+ ),
+ _verdict(
+ "prohibition_timing_order",
+ status=prohibition.get("status", "blocked"),
+ observed={
+ "direct_perfection_travel_degrees": degrees,
+ "intervening_candidate": prohibition_candidate,
+ "comparison_limit_degrees": prohibition.get("comparison_limit_degrees"),
+ },
+ blocks=prohibition.get("detected") is True,
+ reason="Intervening aspects before perfection must remain a hard conservative blocker until prohibition doctrine is calibrated.",
+ rule_family="horary_prohibition",
+ ),
+ _verdict(
+ "translation_collection_mediation",
+ status=mediation.get("status", "blocked"),
+ observed={
+ "mediator": mediation.get("mediator"),
+ "pattern": mediation.get("pattern"),
+ "span_degrees": mediation_span,
+ "strength_snapshot": mediation.get("strength_snapshot"),
+ "reception_snapshot": mediation.get("reception_snapshot"),
+ },
+ blocks=mediation.get("status") != "partial",
+ reason="Translation/collection can only support review when a mediator, span, strength snapshot, and reception snapshot are present.",
+ rule_family="horary_translation_collection",
+ ),
+ _verdict(
+ "moon_bridge_timing_order",
+ status=moon_bridge.get("status", "blocked"),
+ observed={
+ "target": moon_bridge.get("to"),
+ "supports": moon_bridge.get("supports"),
+ "travel_degrees": moon_bridge_degrees,
+ "moon_condition_snapshot": moon_bridge.get("moon_condition_snapshot"),
+ "reception_snapshot": moon_bridge.get("reception_snapshot"),
+ },
+ blocks=moon_bridge.get("status") != "partial",
+ reason="Moon bridge can only support review when its next application, target, condition, and reception snapshots are present.",
+ rule_family="horary_moon_condition",
+ ),
+ ]
+ return {
+ **_HORARY_PERFECTION_TIMING_THRESHOLDS,
+ "observed": {
+ "perfection_status": perfection.get("status", "blocked"),
+ "travel_degrees": degrees,
+ "timing_review_class": review_class,
+ "prohibition_detected": prohibition.get("detected"),
+ "moon_void_like": moon_voc.get("void_like_until_sign_exit"),
+ },
+ "doctrine_verdicts": doctrine_verdicts,
+ "verdict_count": len(doctrine_verdicts),
+ "promotion_block_count": sum(1 for row in doctrine_verdicts if row["blocks_promotion"]),
+ "hard_blockers_observed": blockers,
+ "review_status": "blocked" if blockers else "partial_review_ready",
+ }
+
+
+def _evaluate_electional_goal_rule_tables(electional: dict[str, Any]) -> dict[str, Any]:
+ window = electional.get("window_search", {}) if isinstance(electional.get("window_search"), dict) else {}
+ top = window.get("top_candidate_review", {}) if isinstance(window.get("top_candidate_review"), dict) else {}
+ top_candidates = window.get("top_candidates") if isinstance(window.get("top_candidates"), list) else []
+ top_candidate = top_candidates[0] if top_candidates and isinstance(top_candidates[0], dict) else {}
+ goal_weighting = top_candidate.get("goal_weighting") if isinstance(top_candidate.get("goal_weighting"), dict) else {}
+ malefic_avoidance = top_candidate.get("malefic_avoidance") if isinstance(top_candidate.get("malefic_avoidance"), dict) else {}
+ topic_cautions = (
+ malefic_avoidance.get("topic_caution_weighting")
+ if isinstance(malefic_avoidance.get("topic_caution_weighting"), dict)
+ else {}
+ )
+ weighted_flags = topic_cautions.get("weighted_flags") if isinstance(topic_cautions.get("weighted_flags"), dict) else {}
+ minimums = _ELECTIONAL_GOAL_RULE_TABLES["minimum_candidate_review"]
+ weighted = float(top.get("weighted_score") or 0)
+ max_weighted = float(top.get("max_weighted_score") or 0)
+ ratio = weighted / max_weighted if max_weighted else 0.0
+ caution = float(top.get("weighted_caution_score") or 0)
+ required_hits = goal_weighting.get("required_hits") if isinstance(goal_weighting.get("required_hits"), list) else []
+ required_met = goal_weighting.get("required_met") if isinstance(goal_weighting.get("required_met"), list) else []
+ preferred_hits = goal_weighting.get("preferred_hits") if isinstance(goal_weighting.get("preferred_hits"), list) else []
+ preferred_met = goal_weighting.get("preferred_met") if isinstance(goal_weighting.get("preferred_met"), list) else []
+ blockers = []
+ if not top or top.get("status") != "partial":
+ blockers.append("no_ranked_candidate")
+ if ratio < float(minimums["minimum_weighted_score_ratio"]):
+ blockers.append("insufficient_weighted_score_ratio")
+ if caution > float(minimums["maximum_weighted_caution_score"]):
+ blockers.append("excess_weighted_caution_score")
+ doctrine_verdicts = [
+ _verdict(
+ "ranked_candidate_available",
+ status=top.get("status", "blocked"),
+ observed=top.get("datetime"),
+ blocks=not top or top.get("status") != "partial",
+ reason="Electional doctrine review requires at least one ranked candidate.",
+ rule_family="electional_candidate",
+ ),
+ _verdict(
+ "minimum_weighted_score_ratio",
+ status="executed",
+ observed=round(ratio, 6),
+ blocks=ratio < float(minimums["minimum_weighted_score_ratio"]),
+ reason="Top candidate must meet the minimum goal-rule score ratio before approval doctrine review.",
+ rule_family="electional_threshold",
+ ),
+ _verdict(
+ "maximum_weighted_caution_score",
+ status="executed",
+ observed=caution,
+ blocks=caution > float(minimums["maximum_weighted_caution_score"]),
+ reason="Top candidate caution score must remain within the conservative review threshold.",
+ rule_family="electional_threshold",
+ ),
+ _verdict(
+ "goal_required_hits_family",
+ status=goal_weighting.get("status", "blocked"),
+ observed={
+ "topic": goal_weighting.get("topic"),
+ "required_hits": required_hits,
+ "required_met": required_met,
+ "missing_required": [hit for hit in required_hits if hit not in required_met],
+ },
+ blocks=bool(required_hits) and len(required_met) < len(required_hits),
+ reason="All goal-specific required hits must be visible before an election can move beyond doctrine review.",
+ rule_family="electional_goal_specific",
+ ),
+ _verdict(
+ "goal_preferred_hits_family",
+ status=goal_weighting.get("status", "blocked"),
+ observed={
+ "topic": goal_weighting.get("topic"),
+ "preferred_hits": preferred_hits,
+ "preferred_met": preferred_met,
+ "missing_preferred": [hit for hit in preferred_hits if hit not in preferred_met],
+ },
+ blocks=False,
+ reason="Preferred hits can strengthen review but cannot by themselves approve or block an election.",
+ rule_family="electional_goal_specific",
+ ),
+ _verdict(
+ "moon_condition_family",
+ status="partial" if top_candidate else "blocked",
+ observed={
+ "next_aspect_class": top_candidate.get("next_aspect_class"),
+ "moon_next_aspect_to_malefic": weighted_flags.get("moon_next_aspect_to_malefic"),
+ "moon_void_like_no_major_application_within_sign": weighted_flags.get("moon_void_like_no_major_application_within_sign"),
+ },
+ blocks=bool(
+ weighted_flags.get("moon_next_aspect_to_malefic")
+ or weighted_flags.get("moon_void_like_no_major_application_within_sign")
+ ),
+ reason="Moon condition remains a family-level blocker when the next application is malefic or void-like.",
+ rule_family="electional_moon_condition",
+ ),
+ _verdict(
+ "goal_specific_caution_family",
+ status=topic_cautions.get("status", "blocked"),
+ observed={
+ "topic": topic_cautions.get("topic"),
+ "weighted_caution_score": caution,
+ "weighted_flags": weighted_flags,
+ },
+ blocks=caution > float(minimums["maximum_weighted_caution_score"]),
+ reason="Goal-specific caution weights must stay below the conservative ceiling before review can advance.",
+ rule_family="electional_goal_specific_cautions",
+ ),
+ ]
+ return {
+ **_ELECTIONAL_GOAL_RULE_TABLES,
+ "observed": {
+ "candidate_count": window.get("candidate_count"),
+ "top_candidate_status": top.get("status", "blocked"),
+ "weighted_score_ratio": round(ratio, 6),
+ "weighted_caution_score": caution,
+ },
+ "doctrine_verdicts": doctrine_verdicts,
+ "verdict_count": len(doctrine_verdicts),
+ "promotion_block_count": sum(1 for row in doctrine_verdicts if row["blocks_promotion"]),
+ "hard_blockers_observed": blockers,
+ "review_status": "blocked" if blockers else "partial_review_ready",
+ }
+
+
+def _summarize_zr_replay_evidence(replay_results: list[dict[str, Any]]) -> dict[str, Any]:
+ replay_rows = [
+ {
+ "case_id": row.get("case_id"),
+ "event": row.get("event") if isinstance(row.get("event"), dict) else {},
+ "evidence": row.get("zodiacal_release_replay_evidence"),
+ }
+ for row in replay_results
+ if isinstance(row.get("zodiacal_release_replay_evidence"), dict)
+ ]
+ evidence_rows = [
+ row["evidence"]
+ for row in replay_rows
+ ]
+ blocked_gates = [
+ row.get("event_prominence_gate")
+ for row in evidence_rows
+ if isinstance(row.get("event_prominence_gate"), dict)
+ and row["event_prominence_gate"].get("status") == "blocked"
+ ]
+ loosing_variant_rows = [
+ (row.get("rule_variants") or {})
+ for row in evidence_rows
+ if isinstance(row.get("rule_variants"), dict)
+ ]
+ theme_cross_rows = [
+ row.get("theme_lord_cross")
+ for row in evidence_rows
+ if isinstance(row.get("theme_lord_cross"), dict)
+ ]
+ annual_cross_rows = [
+ row.get("annual_lord_cross")
+ for row in evidence_rows
+ if isinstance(row.get("annual_lord_cross"), dict)
+ ]
+ matrix_rows = [
+ row.get("adjudication_matrix_snapshot")
+ for row in evidence_rows
+ if isinstance(row.get("adjudication_matrix_snapshot"), dict)
+ ]
+ event_matrix_rows = []
+ for row in replay_rows:
+ evidence = row["evidence"]
+ theme_cross = evidence.get("theme_lord_cross") if isinstance(evidence.get("theme_lord_cross"), dict) else {}
+ annual_cross = evidence.get("annual_lord_cross") if isinstance(evidence.get("annual_lord_cross"), dict) else {}
+ matrix = evidence.get("adjudication_matrix_snapshot") if isinstance(evidence.get("adjudication_matrix_snapshot"), dict) else {}
+ top_peak = evidence.get("top_peak_candidate") if isinstance(evidence.get("top_peak_candidate"), dict) else {}
+ variants = evidence.get("rule_variants") if isinstance(evidence.get("rule_variants"), dict) else {}
+ active_l2_variant = variants.get("active_l2_opposes_active_l1") if isinstance(variants.get("active_l2_opposes_active_l1"), dict) else {}
+ event = row.get("event") if isinstance(row.get("event"), dict) else {}
+ event_matrix_rows.append({
+ "case_id": row.get("case_id"),
+ "event_date": event.get("date"),
+ "event_domain": event.get("domain"),
+ "loosing_detected": evidence.get("loosing_detected"),
+ "active_l2_opposes_active_l1": active_l2_variant.get("detected"),
+ "active_l1_is_peak": evidence.get("active_l1_is_peak"),
+ "active_l2_is_peak": evidence.get("active_l2_is_peak"),
+ "top_peak_sign": top_peak.get("sign"),
+ "top_peak_lord": top_peak.get("lord"),
+ "top_peak_weight": evidence.get("top_peak_weight"),
+ "active_lord_matches_theme_lord": theme_cross.get("active_lord_matches_theme_lord"),
+ "top_peak_lord_matches_theme_lord": theme_cross.get("top_peak_lord_matches_theme_lord"),
+ "annual_lord_matches_active_release_lord": annual_cross.get("annual_lord_matches_active_release_lord"),
+ "annual_lord_matches_top_peak_lord": annual_cross.get("annual_lord_matches_top_peak_lord"),
+ "matrix_support_count": matrix.get("support_count"),
+ "matrix_blocked_count": matrix.get("blocked_count"),
+ "promotion_status": matrix.get("promotion_status", "blocked"),
+ "score_inclusion": "not_included",
+ })
+ peak_weight_values = [
+ row.get("top_peak_weight")
+ for row in event_matrix_rows
+ if isinstance(row.get("top_peak_weight"), int)
+ ]
+ loosing_peak_matrix = {
+ "status": "partial" if event_matrix_rows else "blocked",
+ "row_count": len(event_matrix_rows),
+ "rows": event_matrix_rows,
+ "aggregate": {
+ "loosing_detected_count": sum(1 for row in event_matrix_rows if row.get("loosing_detected") is True),
+ "active_l2_opposition_count": sum(1 for row in event_matrix_rows if row.get("active_l2_opposes_active_l1") is True),
+ "active_l1_peak_count": sum(1 for row in event_matrix_rows if row.get("active_l1_is_peak") is True),
+ "active_l2_peak_count": sum(1 for row in event_matrix_rows if row.get("active_l2_is_peak") is True),
+ "any_active_peak_count": sum(
+ 1
+ for row in event_matrix_rows
+ if row.get("active_l1_is_peak") is True or row.get("active_l2_is_peak") is True
+ ),
+ "top_peak_weight_max": max(peak_weight_values, default=None),
+ "top_peak_weight_ge_3_count": sum(1 for value in peak_weight_values if value >= 3),
+ "theme_cross_with_peak_lord_count": sum(
+ 1 for row in event_matrix_rows if row.get("top_peak_lord_matches_theme_lord") is True
+ ),
+ "annual_cross_with_peak_lord_count": sum(
+ 1 for row in event_matrix_rows if row.get("annual_lord_matches_top_peak_lord") is True
+ ),
+ "matrix_promotion_blocked_count": sum(
+ 1 for row in event_matrix_rows if row.get("promotion_status") == "blocked"
+ ),
+ },
+ "promotion_status": "blocked",
+ "score_inclusion": "not_included",
+ "boundary": "Loosing/peak replay matrix is audit evidence only; event-prominence and doctrine review remain blocked.",
+ }
+ return {
+ "status": "partial" if evidence_rows else "blocked",
+ "replayed_event_count": len(evidence_rows),
+ "loosing_detected_count": sum(1 for row in evidence_rows if row.get("loosing_detected") is True),
+ "loosing_variant_matrix_count": sum(
+ 1
+ for variants in loosing_variant_rows
+ if isinstance(variants.get("historical_rule_variant_matrix"), dict)
+ and variants["historical_rule_variant_matrix"].get("status") == "partial"
+ ),
+ "active_l1_peak_count": sum(1 for row in evidence_rows if row.get("active_l1_is_peak") is True),
+ "active_l2_peak_count": sum(1 for row in evidence_rows if row.get("active_l2_is_peak") is True),
+ "active_peak_any_count": sum(
+ 1
+ for row in evidence_rows
+ if row.get("active_l1_is_peak") is True or row.get("active_l2_is_peak") is True
+ ),
+ "theme_lord_cross_count": sum(
+ 1
+ for row in theme_cross_rows
+ if any([
+ row.get("active_lord_matches_theme_lord") is True,
+ row.get("lot_ruler_matches_theme_lord") is True,
+ row.get("top_peak_lord_matches_theme_lord") is True,
+ row.get("annual_lord_matches_theme_lord") is True,
+ ])
+ ),
+ "active_lord_theme_cross_count": sum(
+ 1 for row in theme_cross_rows if row.get("active_lord_matches_theme_lord") is True
+ ),
+ "top_peak_lord_theme_cross_count": sum(
+ 1 for row in theme_cross_rows if row.get("top_peak_lord_matches_theme_lord") is True
+ ),
+ "annual_lord_theme_cross_count": sum(
+ 1 for row in theme_cross_rows if row.get("annual_lord_matches_theme_lord") is True
+ ),
+ "annual_lord_release_cross_count": sum(
+ 1 for row in annual_cross_rows if row.get("annual_lord_matches_active_release_lord") is True
+ ),
+ "annual_lord_top_peak_cross_count": sum(
+ 1 for row in annual_cross_rows if row.get("annual_lord_matches_top_peak_lord") is True
+ ),
+ "matrix_snapshot_count": len(matrix_rows),
+ "matrix_support_count_total": sum(
+ int(row.get("support_count") or 0)
+ for row in matrix_rows
+ ),
+ "matrix_blocked_count_total": sum(
+ int(row.get("blocked_count") or 0)
+ for row in matrix_rows
+ ),
+ "matrix_support_count_max": max(
+ [int(row.get("support_count") or 0) for row in matrix_rows],
+ default=0,
+ ),
+ "matrix_promotion_blocked_count": sum(
+ 1 for row in matrix_rows if row.get("promotion_status") == "blocked"
+ ),
+ "top_peak_weight_max": max(
+ [row.get("top_peak_weight") for row in evidence_rows if isinstance(row.get("top_peak_weight"), int)],
+ default=None,
+ ),
+ "loosing_peak_replay_matrix": loosing_peak_matrix,
+ "blocked_event_prominence_gate_count": len(blocked_gates),
+ "score_inclusion": "not_included",
+ "boundary": "Aggregate ZR replay evidence only; it is not a validation score or prediction-confidence input.",
+ }
+
+
+def build_classical_real_case_calibration_packet(
+ *,
+ cases: list[dict[str, Any]] | None = None,
+ primary_theme: str = "general",
+ case_id: str | None = None,
+ manifest_path: Path | str | None = None,
+ negative_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ """Return the classical real-case calibration contract.
+
+ Empty input is still a useful executable state: it records that no case has
+ been admitted and keeps predictive confidence blocked.
+ """
+ source_path = Path(manifest_path) if manifest_path else DEFAULT_REAL_CASE_MANIFEST
+ source_cases = cases if cases is not None else _load_manifest_cases(source_path)
+ admitted_cases = []
+ rejected_cases = []
+ replay_results = []
+ for case in source_cases:
+ if case_id and case.get("case_id") != case_id:
+ continue
+ required = {"case_id", "subject", "event_outcomes", "source"}
+ missing = sorted(required - set(case))
+ row = {
+ "case_id": case.get("case_id") or "unlabeled_case",
+ "theme": case.get("theme") or primary_theme,
+ "source": case.get("source"),
+ "known_event_count": len(case.get("event_outcomes") or case.get("known_events") or []),
+ "required_missing": missing,
+ }
+ if missing:
+ rejected_cases.append({**row, "status": "blocked"})
+ else:
+ outcomes = case.get("event_outcomes") if isinstance(case.get("event_outcomes"), list) else []
+ for event in outcomes[:1]:
+ if isinstance(event, dict) and event.get("event_date"):
+ replay_results.append(_score_classical_event_replay(case, event, primary_theme))
+ admitted_cases.append({
+ **row,
+ "status": "replayed" if outcomes else "blocked",
+ "replay_status": "calculated_not_predictive_validation" if outcomes else "blocked",
+ "blocked_reason": None if outcomes else "case has no event_outcomes",
+ })
+ status = "blocked" if not admitted_cases else "partial_scored"
+ average_score = round(sum(row["score"] for row in replay_results) / len(replay_results), 3) if replay_results else None
+ zr_replay_summary = _summarize_zr_replay_evidence(replay_results)
+ candidate_negative_path = Path(negative_manifest_path) if negative_manifest_path else DEFAULT_CANDIDATE_NEGATIVE_MANIFEST
+ candidate_negative_manifest = (
+ _load_candidate_negative_control_cases(candidate_negative_path)
+ if negative_manifest_path or cases is None
+ else {
+ "status": "not_loaded_for_inline_cases",
+ "path": str(candidate_negative_path),
+ "case_count": 0,
+ "blocked_case_count": 0,
+ "claim_status": "not_loaded",
+ "truth_matrix_allowed": False,
+ "cases": [],
+ "blocked_rows": [],
+ "boundary": "Default candidate negative manifest is not mixed into inline caller-supplied cases.",
+ }
+ )
+ negative_replay_cases = list(source_cases)
+ negative_replay_cases.extend(candidate_negative_manifest.get("cases") or [])
+ negative_control_replay = _score_negative_control_cases(negative_replay_cases, primary_theme)
+ domain_specificity_table = _build_domain_specificity_table(replay_results, negative_control_replay)
+ case_corpus_coverage = _build_case_corpus_coverage(
+ source_cases=source_cases,
+ source_path=source_path,
+ admitted_cases=admitted_cases,
+ replay_results=replay_results,
+ candidate_negative_manifest=candidate_negative_manifest,
+ )
+ specificity_gate = _build_calibration_summary(primary_theme)
+ false_positive_audit = specificity_gate.get("false_positive_audit") if isinstance(specificity_gate, dict) else {}
+ frozen_negative_holdout = (
+ false_positive_audit.get("frozen_negative_holdout")
+ if isinstance(false_positive_audit, dict) and isinstance(false_positive_audit.get("frozen_negative_holdout"), dict)
+ else {}
+ )
+ negative_template_theme = (
+ replay_results[0]["event"].get("domain")
+ if replay_results and isinstance(replay_results[0].get("event"), dict) and replay_results[0]["event"].get("domain")
+ else primary_theme
+ )
+ negative_source_audit = _negative_holdout_source_audit_summary()
+ negative_control_readiness = {
+ "status": (
+ "partial"
+ if isinstance(frozen_negative_holdout, dict) and frozen_negative_holdout.get("status") in {"partial", "pass"}
+ else "blocked"
+ ),
+ "required_inputs": [
+ "independent frozen negative labels",
+ "blind scoring run before outcome reveal",
+ "domain-matched non-event windows",
+ "false-positive threshold notes",
+ ],
+ "available_status": frozen_negative_holdout.get("status", "blocked") if isinstance(frozen_negative_holdout, dict) else "blocked",
+ "case_count": frozen_negative_holdout.get("case_count", 0) if isinstance(frozen_negative_holdout, dict) else 0,
+ "runnable_replay_status": negative_control_replay["status"],
+ "runnable_window_count": negative_control_replay["scored_window_count"],
+ "false_positive_candidate_count": negative_control_replay["false_positive_candidate_count"],
+ "false_positive_rate": negative_control_replay["false_positive_rate"],
+ "candidate_negative_manifest": {
+ "status": candidate_negative_manifest["status"],
+ "path": candidate_negative_manifest["path"],
+ "case_count": candidate_negative_manifest["case_count"],
+ "blocked_case_count": candidate_negative_manifest["blocked_case_count"],
+ "claim_status": candidate_negative_manifest.get("claim_status"),
+ "truth_matrix_allowed": candidate_negative_manifest.get("truth_matrix_allowed"),
+ },
+ "source_audit_summary": negative_source_audit,
+ "evidence_refs": {
+ "negative_holdout_source_audit": {
+ "path": str(DEFAULT_NEGATIVE_HOLDOUT_AUDIT),
+ "exists": DEFAULT_NEGATIVE_HOLDOUT_AUDIT.exists(),
+ "status": "policy_audit_only",
+ },
+ "positive_holdout_manifest": {
+ "path": str(DEFAULT_POSITIVE_HOLDOUT_MANIFEST),
+ "exists": DEFAULT_POSITIVE_HOLDOUT_MANIFEST.exists(),
+ "status": "positive_holdout_only",
+ },
+ },
+ "case_template": {
+ "case_id": "negative_control_case_id",
+ "subject": "same schema as positive replay subject",
+ "non_event_windows": [
+ {
+ "start_date": "YYYY-MM-DD",
+ "end_date": "YYYY-MM-DD",
+ "domain": negative_template_theme,
+ "expected_label": f"no_{negative_template_theme}_event",
+ "source": {"source_grade": "independent_label"},
+ }
+ ],
+ "scoring_contract": "run the same classical replay scorer before outcome reveal and count theme hits as false-positive candidates",
+ },
+ "blocked_reason": (
+ negative_source_audit.get("blockers") or frozen_negative_holdout.get("blocked_reason")
+ if isinstance(frozen_negative_holdout, dict)
+ else "false_positive_audit did not expose a frozen_negative_holdout packet"
+ ),
+ "boundary": "Readiness plus diagnostic replay packet only; it does not create prediction confidence without independent frozen labels.",
+ }
+ prediction_promotion_status = (
+ "guarded"
+ if replay_results and isinstance(false_positive_audit, dict) and false_positive_audit.get("status") == "used"
+ else "blocked"
+ )
+ report_layers = {
+ "executive_summary": {
+ "status": status,
+ "replayed_event_count": len(replay_results),
+ "average_replay_evidence_score": average_score,
+ "prediction_confidence": prediction_promotion_status,
+ "promotion_status": "blocked" if prediction_promotion_status == "blocked" else "guarded",
+ "boundary": "Replay score is evidence only; prediction confidence remains blocked unless promotion gates close.",
+ },
+ "positive_replay_holdout": {
+ "status": "partial" if replay_results else "blocked",
+ "positive_replay": specificity_gate.get("positive_replay") if isinstance(specificity_gate, dict) else {},
+ "positive_holdout": specificity_gate.get("positive_holdout") if isinstance(specificity_gate, dict) else {},
+ "replayed_event_count": len(replay_results),
+ "average_replay_evidence_score": average_score,
+ "zr_replay_cross_counts": {
+ "active_lord_theme_cross_count": zr_replay_summary.get("active_lord_theme_cross_count"),
+ "top_peak_lord_theme_cross_count": zr_replay_summary.get("top_peak_lord_theme_cross_count"),
+ "annual_lord_theme_cross_count": zr_replay_summary.get("annual_lord_theme_cross_count"),
+ "annual_lord_release_cross_count": zr_replay_summary.get("annual_lord_release_cross_count"),
+ "annual_lord_top_peak_cross_count": zr_replay_summary.get("annual_lord_top_peak_cross_count"),
+ "matrix_snapshot_count": zr_replay_summary.get("matrix_snapshot_count"),
+ "matrix_support_count_total": zr_replay_summary.get("matrix_support_count_total"),
+ "matrix_blocked_count_total": zr_replay_summary.get("matrix_blocked_count_total"),
+ "matrix_promotion_blocked_count": zr_replay_summary.get("matrix_promotion_blocked_count"),
+ },
+ "loosing_peak_replay_matrix": zr_replay_summary.get("loosing_peak_replay_matrix"),
+ "boundary": "Positive replay/holdout rows are display evidence only; they do not close specificity or promote prediction confidence.",
+ },
+ "calibration_boundary": {
+ "status": "blocked" if prediction_promotion_status == "blocked" else "guarded",
+ "negative_control_status": negative_control_readiness["status"],
+ "negative_control_replay_status": negative_control_replay["status"],
+ "negative_control_blockers": negative_control_readiness.get("blocked_reason"),
+ "false_positive_candidate_count": negative_control_replay["false_positive_candidate_count"],
+ "false_positive_rate": negative_control_replay["false_positive_rate"],
+ "zr_event_prominence_blocked_count": zr_replay_summary.get("blocked_event_prominence_gate_count"),
+ "promotion_blockers": [
+ "negative_control_specificity_not_closed",
+ "event_prominence_gate_blocked",
+ "blind_evaluation_not_closed",
+ ],
+ "blocker_detail_rows": [
+ {
+ "gate": "negative_control_specificity",
+ "status": negative_control_readiness["status"],
+ "details": negative_control_readiness.get("blocked_reason"),
+ },
+ {
+ "gate": "negative_control_replay",
+ "status": negative_control_replay["status"],
+ "details": {
+ "scored_window_count": negative_control_replay["scored_window_count"],
+ "false_positive_candidate_count": negative_control_replay["false_positive_candidate_count"],
+ "promotion_eligible": negative_control_replay["promotion_eligible"],
+ },
+ },
+ {
+ "gate": "zr_event_prominence",
+ "status": "blocked" if zr_replay_summary.get("blocked_event_prominence_gate_count") else "partial",
+ "details": zr_replay_summary.get("blocked_event_prominence_gate_count"),
+ },
+ {
+ "gate": "blind_evaluation",
+ "status": "blocked",
+ "details": "blind evaluation closure is not present in this classical replay packet",
+ },
+ ],
+ },
+ "negative_control_replay": {
+ "status": negative_control_replay["status"],
+ "scored_window_count": negative_control_replay["scored_window_count"],
+ "false_positive_candidate_count": negative_control_replay["false_positive_candidate_count"],
+ "false_positive_rate": negative_control_replay["false_positive_rate"],
+ "promotion_eligible": negative_control_replay["promotion_eligible"],
+ "candidate_negative_manifest": {
+ "status": candidate_negative_manifest["status"],
+ "case_count": candidate_negative_manifest["case_count"],
+ "path": candidate_negative_manifest["path"],
+ "truth_matrix_allowed": candidate_negative_manifest.get("truth_matrix_allowed"),
+ },
+ "boundary": negative_control_replay["boundary"],
+ },
+ "domain_specificity_table": domain_specificity_table,
+ "case_corpus_coverage": case_corpus_coverage,
+ "zr_replay_summary": zr_replay_summary,
+ "evidence_appendix": {
+ "admitted_case_count": len(admitted_cases),
+ "rejected_case_count": len(rejected_cases),
+ "manifest_path": str(source_path),
+ "candidate_negative_manifest_path": candidate_negative_manifest["path"],
+ "case_id_filter": case_id,
+ },
+ }
+ return {
+ "system": "classical_astrology",
+ "layer": "real_case_calibration",
+ "status": status,
+ "primary_theme": primary_theme,
+ "manifest_path": str(source_path),
+ "case_id_filter": case_id,
+ "admitted_case_count": len(admitted_cases),
+ "rejected_case_count": len(rejected_cases),
+ "admitted_cases": admitted_cases,
+ "rejected_cases": rejected_cases,
+ "replay_results": replay_results,
+ "report_layers": report_layers,
+ "summary": {
+ "replayed_event_count": len(replay_results),
+ "average_replay_evidence_score": average_score,
+ "scoring_mode": "annual_profection_zodiacal_release_theme_lord_overlap",
+ "zodiacal_release_replay_summary": zr_replay_summary,
+ "negative_control_replay_summary": {
+ "status": negative_control_replay["status"],
+ "scored_window_count": negative_control_replay["scored_window_count"],
+ "false_positive_candidate_count": negative_control_replay["false_positive_candidate_count"],
+ "false_positive_rate": negative_control_replay["false_positive_rate"],
+ "score_inclusion": negative_control_replay["score_inclusion"],
+ },
+ "domain_specificity_table": {
+ "status": domain_specificity_table["status"],
+ "row_count": domain_specificity_table["row_count"],
+ "promotion_status": domain_specificity_table["promotion_status"],
+ },
+ "case_corpus_coverage": {
+ "status": case_corpus_coverage["status"],
+ "primary_case_count": case_corpus_coverage["primary_manifest"]["case_count"],
+ "positive_holdout_case_count": case_corpus_coverage["positive_holdout_manifest"]["case_count"],
+ "candidate_negative_case_count": case_corpus_coverage["candidate_negative_manifest"]["case_count"],
+ "candidate_negative_window_count": case_corpus_coverage["candidate_negative_manifest"]["non_event_window_count"],
+ "promotion_status": case_corpus_coverage["promotion_status"],
+ },
+ },
+ "specificity_gate": {
+ "status": specificity_gate.get("status", "blocked") if isinstance(specificity_gate, dict) else "blocked",
+ "primary_theme": primary_theme,
+ "positive_replay": specificity_gate.get("positive_replay") if isinstance(specificity_gate, dict) else {},
+ "positive_holdout": specificity_gate.get("positive_holdout") if isinstance(specificity_gate, dict) else {},
+ "false_positive_audit": false_positive_audit,
+ "negative_control_readiness": negative_control_readiness,
+ "negative_control_replay": negative_control_replay,
+ "domain_specificity_table": domain_specificity_table,
+ "case_corpus_coverage": case_corpus_coverage,
+ "domain_replay_scoring": specificity_gate.get("domain_replay_scoring") if isinstance(specificity_gate, dict) else {},
+ "review_pack": specificity_gate.get("review_pack") if isinstance(specificity_gate, dict) else {},
+ "claim_boundary": (
+ "Classical replay can show positive-case evidence, but prediction confidence stays blocked until "
+ "negative-control specificity and blind evaluation are closed."
+ ),
+ },
+ "required_artifacts": [
+ "public or user-supplied source path",
+ "birth data with timezone and location",
+ "dated known events",
+ "blind classical calculation packet",
+ "post-hoc replay comparison notes",
+ ],
+ "audit": {
+ "techniques": {
+ "real_case_intake": {
+ "status": "executed",
+ "source": "scripts/classical_astrology.py",
+ },
+ "blind_replay_comparison": {
+ "status": "partial_scored" if replay_results else "blocked",
+ "reason": None if replay_results else "No admitted events were available for scoring.",
+ },
+ "specificity_gate": {
+ "status": false_positive_audit.get("status", "blocked") if isinstance(false_positive_audit, dict) else "blocked",
+ "reason": (
+ false_positive_audit.get("blocked_reason")
+ if isinstance(false_positive_audit, dict) and false_positive_audit.get("status") != "used"
+ else None
+ ),
+ },
+ "negative_control_readiness": {
+ "status": negative_control_readiness["status"],
+ "reason": negative_control_readiness.get("blocked_reason"),
+ },
+ "negative_control_replay": {
+ "status": negative_control_replay["status"],
+ "reason": (
+ negative_control_replay.get("blocked_reason")
+ if negative_control_replay["status"] == "blocked"
+ else "Diagnostic negative-control windows scored, but promotion remains blocked without independent frozen labels."
+ ),
+ "promotion_eligible": negative_control_replay["promotion_eligible"],
+ },
+ "domain_specificity_table": {
+ "status": domain_specificity_table["status"],
+ "row_count": domain_specificity_table["row_count"],
+ "reason": "Diagnostic per-domain comparison only; promotion remains blocked.",
+ },
+ "case_corpus_coverage": {
+ "status": case_corpus_coverage["status"],
+ "primary_case_count": case_corpus_coverage["primary_manifest"]["case_count"],
+ "positive_holdout_case_count": case_corpus_coverage["positive_holdout_manifest"]["case_count"],
+ "candidate_negative_window_count": case_corpus_coverage["candidate_negative_manifest"]["non_event_window_count"],
+ "reason": "Corpus coverage is evidence inventory only; promotion remains blocked.",
+ },
+ "zodiacal_release_replay_aggregate": {
+ "status": zr_replay_summary["status"],
+ "reason": (
+ "ZR loosing/peak replay evidence is not included in score until event-prominence gate closes."
+ if zr_replay_summary["status"] == "partial"
+ else "No ZR replay evidence rows were available."
+ ),
+ },
+ "zodiacal_release_loosing_peak_matrix": {
+ "status": (zr_replay_summary.get("loosing_peak_replay_matrix") or {}).get("status", "blocked"),
+ "row_count": (zr_replay_summary.get("loosing_peak_replay_matrix") or {}).get("row_count", 0),
+ "promotion_status": (zr_replay_summary.get("loosing_peak_replay_matrix") or {}).get("promotion_status", "blocked"),
+ "reason": "Loosing/peak replay matrix is not a validation score; event-prominence promotion remains blocked.",
+ },
+ "promotion_gate_blockers": {
+ "status": "blocked",
+ "blockers": [
+ "negative_control_specificity_not_closed",
+ "event_prominence_gate_blocked",
+ "blind_evaluation_not_closed",
+ ],
+ "source": "negative_control_readiness + zodiacal_release_replay_summary",
+ },
+ "prediction_confidence_promotion": {
+ "status": prediction_promotion_status,
+ "reason": (
+ "Negative-control specificity gate is not closed."
+ if prediction_promotion_status == "blocked"
+ else "Positive replay and specificity gate are present, but interpretive promotion still requires doctrine review."
+ ),
+ },
+ },
+ "boundary": "Calibration packet only; it does not validate predictive claims by itself.",
+ },
+ "boundary": "Real-case replay is calculated for audit only; prediction promotion remains blocked until negative controls and blind scoring are closed.",
+ }
+
+
+def _audit(reference_date: str | None, calibration: dict | None = None) -> dict[str, Any]:
+ calibration = calibration or {}
+ replay = calibration.get('audit', {}).get('techniques', {}).get('blind_replay_comparison', {})
+ return {
+ "techniques": {
+ "tropical_natal_chart": {"status": "executed", "source": "scripts/western_chart_engine.py"},
+ "traditional_seven_planets": {"status": "executed", "source": "scripts/classical_astrology.py"},
+ "sect": {"status": "executed", "source": "scripts/classical_astrology.py"},
+ "essential_dignity": {"status": "executed", "source": "scripts/classical_astrology.py"},
+ "triplicity": {"status": "executed", "source": "Dorothean-style triplicity table"},
+ "bounds": {"status": "executed", "source": "Egyptian bounds table"},
+ "faces": {"status": "executed", "source": "Chaldean decan order"},
+ "lots_fortune_spirit": {"status": "executed", "source": "sect-dependent formulas"},
+ "expanded_lots": {"status": "executed", "source": "fortune/spirit/eros/necessity/basis/exaltation formulas"},
+ "house_rulers": {"status": "executed", "source": "tropical house cusps plus traditional rulers"},
+ "planetary_condition": {"status": "executed", "source": "house motion, speed, and sect flags"},
+ "planetary_doctrine_condition_verdicts": {
+ "status": "executed",
+ "source": "essential dignity + sect + house motion + speed review verdicts",
+ "promotion_allowed": False,
+ },
+ "aspect_doctrine_verdicts": {
+ "status": "partial",
+ "source": "degree aspects + whole-sign aspects + orb hierarchy + dexter/sinister + approximate applying testimony",
+ "promotion_allowed": False,
+ },
+ "accidental_dignity_verdicts": {
+ "status": "partial",
+ "source": "house motion + planetary joys + solar visibility + under-beams/combustion/cazimi checks",
+ "promotion_allowed": False,
+ },
+ "bonification_maltreatment_verdicts": {
+ "status": "partial",
+ "source": "close Ptolemaic aspects to Venus/Jupiter and Mars/Saturn with sect-aware labels",
+ "promotion_allowed": False,
+ },
+ "almuten": {"status": "executed", "source": "key-point essential dignity scoring"},
+ "annual_profection": {
+ "status": "executed" if reference_date else "blocked",
+ "required_input": None if reference_date else "reference_date",
+ },
+ "zodiacal_release": {
+ "status": "guarded" if reference_date else "blocked",
+ "reason": None if reference_date else "reference_date is required",
+ "guarded_boundary": "L1/L2 scaffold, active-period condition, sign-level loosing detector, and angular-sign peaks only.",
+ },
+ "horary_classical": {"status": "partial", "reason": "significator/reception/Moon scan available; final judgment remains blocked"},
+ "real_case_calibration": {
+ "status": replay.get('status', calibration.get('status', 'blocked')),
+ "reason": replay.get('reason') or calibration.get('reason'),
+ "replayed_event_count": len(calibration.get('replay_results') or []),
+ "prediction_promotion": calibration.get('audit', {}).get('techniques', {}).get('prediction_confidence_promotion', {}),
+ "boundary": "Replay execution is separate from predictive validation and negative-control admission.",
+ },
+ },
+ "boundary": "Execution audit only; blocked rows must not produce deterministic interpretive claims.",
+ }
+
+
+def build_classical_astrology_packet(
+ *,
+ reference_date: str | None = None,
+ include_real_case_calibration: bool = True,
+ **birth: Any,
+) -> dict[str, Any]:
+ """Build an auditable minimum viable classical astrology packet."""
+ chart = build_tropical_natal_chart(**birth)
+ sect = _sect(chart)
+ dignities = _dignities(chart, sect)
+ lots = _lots(chart, sect)
+ condition = _planetary_condition(chart, sect, dignities)
+ doctrine_condition_verdicts = _planetary_doctrine_condition_verdicts(condition, dignities, sect)
+ aspect_doctrine_verdicts = _aspect_doctrine_verdicts(chart)
+ accidental_dignity_verdicts = _accidental_dignity_verdicts(chart, sect)
+ benefic_malefic_verdicts = _benefic_malefic_condition_verdicts(chart, sect)
+ source_loci_manifest = _source_loci_manifest()
+ condition_summary = _planetary_condition_natural_language_summary(
+ doctrine_condition_verdicts,
+ benefic_malefic_verdicts,
+ )
+ aspect_summary = _aspect_doctrine_natural_language_summary(aspect_doctrine_verdicts)
+ accidental_summary = _accidental_dignity_natural_language_summary(accidental_dignity_verdicts)
+ profection = (
+ {"status": "executed", "raw_result": calculate_annual_profection(target_date=reference_date, **birth)}
+ if reference_date else {"status": "blocked", "reason": "reference_date is required"}
+ )
+ zodiacal_release_adjudication = _release_adjudication(
+ birth=dict(birth),
+ reference_date=reference_date,
+ chart=chart,
+ lots=lots,
+ condition=condition,
+ )
+ doctrine_explanation_panel = _core_doctrine_explanation_panel(
+ planetary_verdicts=doctrine_condition_verdicts,
+ aspect_verdicts=aspect_doctrine_verdicts,
+ accidental_verdicts=accidental_dignity_verdicts,
+ bonification_verdicts=benefic_malefic_verdicts,
+ zr=zodiacal_release_adjudication,
+ )
+ calibration = (build_classical_real_case_calibration_packet()
+ if include_real_case_calibration
+ else {"status": "not_applicable", "reason": "disabled for internal replay scoring"})
+ return {
+ "system": "classical_astrology",
+ "status": "partial",
+ "chart": chart,
+ "traditional_planets": list(TRADITIONAL_PLANETS),
+ "doctrine_source_loci_manifest": source_loci_manifest,
+ "sect": sect,
+ "dignities": dignities,
+ "house_rulers": _house_rulers(chart),
+ "planetary_condition": condition,
+ "planetary_doctrine_condition_verdicts": doctrine_condition_verdicts,
+ "aspect_doctrine_verdicts": aspect_doctrine_verdicts,
+ "accidental_dignity_verdicts": accidental_dignity_verdicts,
+ "bonification_maltreatment_verdicts": benefic_malefic_verdicts,
+ "lots": lots,
+ "almutens": _almutens(chart, lots, sect),
+ "annual_profection": profection,
+ "zodiacal_release_adjudication": zodiacal_release_adjudication,
+ "real_case_calibration": calibration,
+ "report_layers": {
+ "executive_summary": [
+ "Classical astrology minimum chain is available as calculation evidence.",
+ "Traditional seven planets, sect, dignities, house rulers, Egyptian bounds, faces, Lots, almutens, planetary condition verdicts, bonification/maltreatment testimony, and annual profection are preserved as raw layers.",
+ "Predictive and horary claims remain blocked until a dedicated classical adjudication layer is added.",
+ ],
+ "planetary_condition_natural_language_summary": condition_summary,
+ "aspect_doctrine_natural_language_summary": aspect_summary,
+ "accidental_dignity_natural_language_summary": accidental_summary,
+ "doctrine_explanation_panel": doctrine_explanation_panel,
+ "doctrine_source_loci_manifest": source_loci_manifest,
+ "evidence_appendix": [
+ "Tropical natal chart is sourced from Swiss Ephemeris via the existing western chart engine.",
+ "Dignity and almuten scoring are compact audit scores, not traditional judgments by themselves.",
+ "Planetary condition verdicts combine essential dignity, sect, angularity, and speed into review-only doctrine rows.",
+ "Aspect doctrine verdicts distinguish degree aspect, whole-sign aspect, orb band, dexter/sinister direction, and approximate applying status.",
+ "Accidental dignity verdicts include angularity, planetary joy, solar visibility, under-beams, combustion, and cazimi checks.",
+ "Bonification/maltreatment verdicts scan close testimony from Venus/Jupiter and Mars/Saturn; they are not outcome judgments.",
+ ],
+ },
+ "audit": _audit(reference_date, calibration),
+ "boundary": "Classical calculation/report scaffold only; no deterministic prediction or horary judgment is inferred.",
+ }
+
+
+def _classical_event_report_layers(
+ *,
+ event_type: str,
+ moon_condition: dict[str, Any],
+ horary: dict[str, Any] | None,
+ electional: dict[str, Any] | None,
+) -> dict[str, Any]:
+ if event_type == "horary" and isinstance(horary, dict):
+ matrix = horary.get("adjudication_matrix") if isinstance(horary.get("adjudication_matrix"), dict) else {}
+ evidence = horary.get("guarded_evidence_score") if isinstance(horary.get("guarded_evidence_score"), dict) else {}
+ guarded_timing = (
+ horary.get("guarded_yes_no_timing_verdict")
+ if isinstance(horary.get("guarded_yes_no_timing_verdict"), dict)
+ else {}
+ )
+ timing_doctrine_review = (
+ guarded_timing.get("timing_doctrine_review")
+ if isinstance(guarded_timing.get("timing_doctrine_review"), dict)
+ else {
+ "schema": "classical.horary_timing_doctrine_review.v1",
+ "status": "blocked",
+ "row_count": 0,
+ "rows": [],
+ "final_yes_no_allowed": False,
+ "promotion_allowed": False,
+ }
+ )
+ rows = [
+ {
+ "id": "radicality",
+ "status": horary.get("radicality", {}).get("status", "blocked"),
+ "signal": horary.get("radicality", {}).get("readability_flag"),
+ },
+ {
+ "id": "planetary_hour_radicality",
+ "status": horary.get("planetary_hour_radicality", {}).get("status", "blocked"),
+ "signal": horary.get("planetary_hour_radicality", {}).get("agreement"),
+ },
+ {
+ "id": "perfection_candidate",
+ "status": horary.get("perfection_candidate", {}).get("status", "blocked"),
+ "signal": horary.get("perfection_candidate", {}).get("aspect"),
+ },
+ {
+ "id": "application_separation",
+ "status": horary.get("application_separation_detail", {}).get("status", "blocked"),
+ "signal": horary.get("application_separation_detail", {}).get("motion_label"),
+ },
+ {
+ "id": "prohibition",
+ "status": horary.get("prohibition_candidate", {}).get("status", "blocked"),
+ "signal": horary.get("prohibition_candidate", {}).get("intervening_planet"),
+ },
+ {
+ "id": "translation_collection",
+ "status": horary.get("translation_collection_candidate", {}).get("status", "blocked"),
+ "signal": horary.get("translation_collection_candidate", {}).get("mediator"),
+ },
+ {
+ "id": "moon_bridge",
+ "status": horary.get("moon_significator_bridge", {}).get("status", "blocked"),
+ "signal": horary.get("moon_significator_bridge", {}).get("target"),
+ },
+ {
+ "id": "moon_void_of_course",
+ "status": horary.get("moon_void_of_course", {}).get("status", "blocked"),
+ "signal": horary.get("moon_void_of_course", {}).get("void_like_until_sign_exit"),
+ },
+ ]
+ horary_breakdown_rows = [
+ {
+ "id": "direct_perfection",
+ "status": horary.get("perfection_candidate", {}).get("status", "blocked"),
+ "aspect": horary.get("perfection_candidate", {}).get("aspect"),
+ "travel_degrees": horary.get("perfection_candidate", {}).get("travel_degrees"),
+ "within_six_degree_orb": horary.get("perfection_candidate", {}).get("within_six_degree_orb"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "application_separation",
+ "status": horary.get("application_separation_detail", {}).get("status", "blocked"),
+ "motion_label": horary.get("application_separation_detail", {}).get("motion_label"),
+ "closest_current_aspect": horary.get("application_separation_detail", {}).get("closest_current_aspect"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "direct_reception",
+ "status": horary.get("reception", {}).get("doctrine_verdict", {}).get("status", "blocked"),
+ "verdict": horary.get("reception", {}).get("doctrine_verdict", {}).get("verdict"),
+ "support_count": horary.get("reception", {}).get("doctrine_verdict", {}).get("support_count", 0),
+ "doctrine_verdict": horary.get("reception", {}).get("doctrine_verdict"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "prohibition_screen",
+ "status": horary.get("prohibition_candidate", {}).get("status", "blocked"),
+ "detected": horary.get("prohibition_candidate", {}).get("detected"),
+ "candidate": horary.get("prohibition_candidate", {}).get("candidate"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "translation_collection",
+ "status": horary.get("translation_collection_candidate", {}).get("status", "blocked"),
+ "mediator": horary.get("translation_collection_candidate", {}).get("mediator"),
+ "span_degrees": horary.get("translation_collection_candidate", {}).get("span_degrees"),
+ "strength_snapshot": horary.get("translation_collection_candidate", {}).get("strength_snapshot"),
+ "reception_snapshot": horary.get("translation_collection_candidate", {}).get("reception_snapshot"),
+ "reception_doctrine_verdict": horary.get("translation_collection_candidate", {}).get("reception_snapshot", {}).get("doctrine_verdict"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "moon_significator_bridge",
+ "status": horary.get("moon_significator_bridge", {}).get("status", "blocked"),
+ "target": horary.get("moon_significator_bridge", {}).get("to"),
+ "supports": horary.get("moon_significator_bridge", {}).get("supports"),
+ "moon_condition_snapshot": horary.get("moon_significator_bridge", {}).get("moon_condition_snapshot"),
+ "reception_snapshot": horary.get("moon_significator_bridge", {}).get("reception_snapshot"),
+ "reception_doctrine_verdict": horary.get("moon_significator_bridge", {}).get("reception_snapshot", {}).get("doctrine_verdict"),
+ "judgment_status": "blocked",
+ },
+ {
+ "id": "moon_void_of_course",
+ "status": horary.get("moon_void_of_course", {}).get("status", "blocked"),
+ "void_like_until_sign_exit": horary.get("moon_void_of_course", {}).get("void_like_until_sign_exit"),
+ "degrees_until_sign_exit": horary.get("moon_void_of_course", {}).get("degrees_until_sign_exit"),
+ "candidate_count_before_sign_exit": horary.get("moon_void_of_course", {}).get("candidate_count_before_sign_exit"),
+ "judgment_status": "blocked",
+ },
+ ]
+ return {
+ "executive_summary": {
+ "status": "guarded",
+ "event_type": "horary",
+ "judgment_status": matrix.get("judgment_status", "blocked"),
+ "support_count": matrix.get("support_count", 0),
+ "blocked_count": matrix.get("blocked_count", 0),
+ "guarded_score": evidence.get("score"),
+ "score_inclusion": "audit_only",
+ "boundary": "Horary evidence is organized for review only; no yes/no judgment is produced.",
+ },
+ "evidence_matrix": {
+ "status": "partial",
+ "rows": rows,
+ "support_count": matrix.get("support_count", 0),
+ "blocked_count": matrix.get("blocked_count", 0),
+ "judgment_status": matrix.get("judgment_status", "blocked"),
+ },
+ "moon_condition_summary": {
+ "status": "partial",
+ "sign": moon_condition.get("sign"),
+ "house": moon_condition.get("house"),
+ "void_of_course": moon_condition.get("void_of_course_scan", {}).get("void_like_until_sign_exit"),
+ "next_aspect_status": moon_condition.get("next_aspect_scan", {}).get("status"),
+ },
+ "horary_breakdown": {
+ "status": "partial",
+ "rows": horary_breakdown_rows,
+ "row_count": len(horary_breakdown_rows),
+ "blocked_judgment_row_count": sum(1 for row in horary_breakdown_rows if row["judgment_status"] == "blocked"),
+ "prohibition_detected": horary.get("prohibition_candidate", {}).get("detected"),
+ "moon_void_like": horary.get("moon_void_of_course", {}).get("void_like_until_sign_exit"),
+ "all_breakdown_judgments_blocked": all(row["judgment_status"] == "blocked" for row in horary_breakdown_rows),
+ "promotion_status": "blocked",
+ "boundary": "Horary breakdown explains partial evidence only; no row is a yes/no answer or outcome claim.",
+ },
+ "horary_timing_doctrine_review": timing_doctrine_review,
+ "boundary_table": [
+ {"gate": "horary_judgment", "status": "blocked", "reason": "full doctrine adjudicator is not complete"},
+ {"gate": "prediction_confidence", "status": "blocked", "reason": "event chart evidence is not calibrated for outcome claims"},
+ ],
+ }
+ if event_type == "electional" and isinstance(electional, dict):
+ matrix = electional.get("adjudication_matrix") if isinstance(electional.get("adjudication_matrix"), dict) else {}
+ evidence = electional.get("guarded_evidence_score") if isinstance(electional.get("guarded_evidence_score"), dict) else {}
+ window = electional.get("window_search") if isinstance(electional.get("window_search"), dict) else {}
+ top_candidates = window.get("top_candidates") if isinstance(window.get("top_candidates"), list) else []
+ approval_gate = (
+ electional.get("approval_doctrine_gate")
+ if isinstance(electional.get("approval_doctrine_gate"), dict)
+ else {}
+ )
+ approval_doctrine_review = (
+ approval_gate.get("approval_doctrine_review")
+ if isinstance(approval_gate.get("approval_doctrine_review"), dict)
+ else {
+ "schema": "classical.electional_approval_doctrine_review.v1",
+ "status": "blocked",
+ "row_count": 0,
+ "rows": [],
+ "approval_allowed": False,
+ "best_time_allowed": False,
+ }
+ )
+ candidate_breakdown_rows = [
+ {
+ "rank": rank,
+ "offset_hours": candidate.get("offset_hours"),
+ "datetime": candidate.get("datetime"),
+ "weighted_score": candidate.get("weighted_score"),
+ "max_weighted_score": candidate.get("max_weighted_score"),
+ "weighted_caution_score": candidate.get("weighted_caution_score"),
+ "adjudication_support_count": candidate.get("adjudication_support_count"),
+ "adjudication_caution_count": candidate.get("adjudication_caution_count"),
+ "goal_lord": candidate.get("goal_lord"),
+ "goal_lord_house_motion": candidate.get("goal_lord_house_motion"),
+ "next_aspect_class": candidate.get("next_aspect_class"),
+ "hit_count": len(candidate.get("hits") or []),
+ "required_hit_count": len((candidate.get("goal_weighting") or {}).get("required_met") or []),
+ "preferred_hit_count": len((candidate.get("goal_weighting") or {}).get("preferred_met") or []),
+ "weighted_caution_flags": (
+ (candidate.get("malefic_avoidance") or {})
+ .get("topic_caution_weighting", {})
+ .get("weighted_flags", {})
+ ),
+ "judgment_status": (
+ (candidate.get("adjudication_matrix") or {}).get("judgment_status", "blocked")
+ if isinstance(candidate.get("adjudication_matrix"), dict)
+ else "blocked"
+ ),
+ "promotion_status": "blocked",
+ }
+ for rank, candidate in enumerate(top_candidates, start=1)
+ if isinstance(candidate, dict)
+ ]
+ candidate_caution_values = [
+ row.get("weighted_caution_score")
+ for row in candidate_breakdown_rows
+ if isinstance(row.get("weighted_caution_score"), int)
+ ]
+ candidate_score_values = [
+ row.get("weighted_score")
+ for row in candidate_breakdown_rows
+ if isinstance(row.get("weighted_score"), int)
+ ]
+ rows = [
+ {
+ "id": "goal_lord",
+ "status": "partial",
+ "signal": electional.get("goal_lord"),
+ },
+ {
+ "id": "goal_weighting",
+ "status": electional.get("goal_weighting", {}).get("status", "blocked"),
+ "signal": electional.get("goal_weighting", {}).get("weighted_score"),
+ },
+ {
+ "id": "moon_next_aspect",
+ "status": moon_condition.get("next_aspect_scan", {}).get("status", "blocked"),
+ "signal": moon_condition.get("next_aspect_scan", {}).get("aspect"),
+ },
+ {
+ "id": "moon_void_of_course",
+ "status": electional.get("moon_void_of_course", {}).get("status", "blocked"),
+ "signal": electional.get("moon_void_of_course", {}).get("void_like_until_sign_exit"),
+ },
+ {
+ "id": "malefic_avoidance",
+ "status": electional.get("malefic_avoidance", {}).get("status", "blocked"),
+ "signal": electional.get("malefic_avoidance", {}).get("caution_count"),
+ },
+ {
+ "id": "topic_caution_weighting",
+ "status": electional.get("malefic_avoidance", {}).get("topic_caution_weighting", {}).get("status", "blocked"),
+ "signal": electional.get("malefic_avoidance", {}).get("topic_caution_weighting", {}).get("weighted_caution_score"),
+ },
+ {
+ "id": "window_search",
+ "status": window.get("status", "blocked"),
+ "signal": window.get("candidate_count", 0),
+ },
+ ]
+ return {
+ "executive_summary": {
+ "status": "guarded",
+ "event_type": "electional",
+ "judgment_status": matrix.get("judgment_status", "blocked"),
+ "support_count": matrix.get("support_count", 0),
+ "caution_count": matrix.get("caution_count", 0),
+ "guarded_score": evidence.get("score"),
+ "candidate_count": window.get("candidate_count", 0),
+ "score_inclusion": "audit_only",
+ "boundary": "Electional evidence is ranked for review only; no approval or best-time claim is produced.",
+ },
+ "evidence_matrix": {
+ "status": "partial",
+ "rows": rows,
+ "support_count": matrix.get("support_count", 0),
+ "caution_count": matrix.get("caution_count", 0),
+ "judgment_status": matrix.get("judgment_status", "blocked"),
+ },
+ "window_summary": {
+ "status": window.get("status", "blocked"),
+ "candidate_count": window.get("candidate_count", 0),
+ "top_candidate": top_candidates[0] if top_candidates else None,
+ "top_candidate_review": window.get("top_candidate_review"),
+ "promotion_status": "blocked",
+ },
+ "candidate_breakdown": {
+ "status": "partial" if candidate_breakdown_rows else "blocked",
+ "rows": candidate_breakdown_rows,
+ "row_count": len(candidate_breakdown_rows),
+ "best_ranked_offset_hours": candidate_breakdown_rows[0]["offset_hours"] if candidate_breakdown_rows else None,
+ "lowest_weighted_caution_score": min(candidate_caution_values, default=None),
+ "highest_weighted_score": max(candidate_score_values, default=None),
+ "all_candidate_judgments_blocked": all(
+ row.get("judgment_status") == "blocked"
+ for row in candidate_breakdown_rows
+ ) if candidate_breakdown_rows else True,
+ "promotion_status": "blocked",
+ "boundary": "Candidate breakdown explains ranking evidence only; no row is an approval or best-time claim.",
+ },
+ "electional_approval_doctrine_review": approval_doctrine_review,
+ "boundary_table": [
+ {"gate": "electional_judgment", "status": "blocked", "reason": "full electional doctrine adjudicator is not complete"},
+ {"gate": "best_time_claim", "status": "blocked", "reason": "ranked candidates are audit evidence, not approval"},
+ ],
+ }
+ return {
+ "executive_summary": {"status": "blocked", "event_type": event_type, "reason": "event evidence unavailable"},
+ "evidence_matrix": {"status": "blocked", "rows": []},
+ "boundary_table": [{"gate": "event_report_layers", "status": "blocked", "reason": "event evidence unavailable"}],
+ }
+
+
+def build_classical_event_chart_packet(
+ *,
+ event_type: str,
+ question: str | None = None,
+ election_goal: str | None = None,
+ election_window_hours: int = 0,
+ election_step_hours: int = 1,
+ **event: Any,
+) -> dict[str, Any]:
+ """Build a guarded horary/electional event-chart packet without judgment."""
+ normalized_type = str(event_type).lower().replace("-", "_")
+ if normalized_type not in {"horary", "electional"}:
+ raise ValueError("event_type must be horary or electional")
+ event_chart = build_classical_astrology_packet(
+ **event,
+ reference_date=None,
+ include_real_case_calibration=False,
+ )
+ moon = event_chart["chart"]["natal"]["planets"]["moon"]
+ moon_condition = {
+ "sign": moon["sign"],
+ "house": moon["house"],
+ "house_motion": _house_motion(int(moon["house"])),
+ "speed_status": "retrograde" if moon.get("retrograde") else "direct",
+ "next_aspect_scan": _moon_next_aspect(event_chart["chart"]),
+ "void_of_course_scan": _moon_void_of_course(event_chart["chart"]),
+ }
+ horary = _horary_partial_judgment(event_chart, question, dict(event)) if normalized_type == "horary" else None
+ electional = (
+ _electional_partial_judgment(
+ event_chart,
+ election_goal,
+ base_event=dict(event),
+ window_hours=int(election_window_hours),
+ step_hours=int(election_step_hours),
+ )
+ if normalized_type == "electional" else None
+ )
+ report_layers = _classical_event_report_layers(
+ event_type=normalized_type,
+ moon_condition=moon_condition,
+ horary=horary,
+ electional=electional,
+ )
+ techniques = {
+ "event_chart": {"status": "executed", "source": "scripts/classical_astrology.py"},
+ "moon_condition": {"status": "executed", "source": "event chart Moon position"},
+ "angular_house_focus": {"status": "executed", "source": "event chart house motion flags"},
+ "moon_applying_aspect_scan": {"status": "partial", "source": "simple zodiacal longitude scan"},
+ "moon_void_of_course_scan": {"status": "partial", "source": "Moon aspects before sign exit by longitude scan"},
+ "significator_assignment": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "1st house ruler plus keyword-selected quesited house ruler",
+ },
+ "reception_scan": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "rulership/exaltation reception only",
+ "verdict_layer": "direct significator, translation/collection, and Moon-bridge reception verdicts",
+ "promotion_allowed": False,
+ },
+ "horary_radicality": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "ascendant degree readability flag plus clock-hour planetary ruler agreement",
+ },
+ "horary_perfection_candidate": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "direct applying Ptolemaic longitude scan",
+ },
+ "horary_application_separation_detail": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "longitude-only nearest applying/separating aspect geometry",
+ },
+ "horary_prohibition_candidate": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "intervening applying aspect scan from querent significator",
+ },
+ "horary_translation_collection_candidate": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "mediator applying-aspect scan toward both significators",
+ },
+ "horary_moon_significator_bridge": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "Moon applying-aspect scan toward querent/quesited significators",
+ },
+ "horary_adjudication_matrix": {
+ "status": "partial" if normalized_type == "horary" else "not_applicable",
+ "source": "radicality/hour/perfection/prohibition/mediation/Moon bridge/Moon VOC evidence matrix",
+ },
+ "horary_timing_doctrine_review": {
+ "status": (
+ horary.get("guarded_yes_no_timing_verdict", {})
+ .get("timing_doctrine_review", {})
+ .get("status", "blocked")
+ if normalized_type == "horary" and isinstance(horary, dict)
+ else "not_applicable"
+ ),
+ "source": "direct perfection, prohibition timing, translation/collection span, Moon bridge timing, final yes/no promotion gate",
+ },
+ "electional_goal_lord": {
+ "status": "partial" if normalized_type == "electional" else "not_applicable",
+ "source": "keyword-selected goal house ruler plus condition flags and topic weighting",
+ },
+ "electional_goal_weighting": {
+ "status": "partial" if normalized_type == "electional" else "not_applicable",
+ "source": "goal-house required/preferred evidence weights",
+ },
+ "electional_malefic_avoidance": {
+ "status": "partial" if normalized_type == "electional" else "not_applicable",
+ "source": "Moon next-aspect, goal-lord sect/combustion, void-like Moon, and angular malefic caution screen",
+ },
+ "electional_topic_caution_weighting": {
+ "status": "partial" if normalized_type == "electional" else "not_applicable",
+ "source": "goal-house weighted caution flags",
+ },
+ "electional_approval_doctrine_review": {
+ "status": (
+ electional.get("approval_doctrine_gate", {})
+ .get("approval_doctrine_review", {})
+ .get("status", "blocked")
+ if normalized_type == "electional" and isinstance(electional, dict)
+ else "not_applicable"
+ ),
+ "source": "goal required hits, Moon condition hierarchy, hard prohibitions, candidate thresholds, window coverage blocker",
+ },
+ "electional_adjudication_matrix": {
+ "status": "partial" if normalized_type == "electional" else "not_applicable",
+ "source": "goal lord/Moon/combustion/angular malefic/topic weighting evidence matrix",
+ },
+ "electional_window_search": {
+ "status": (
+ "partial"
+ if normalized_type == "electional" and int(election_window_hours) > 0
+ else "not_applicable"
+ ),
+ "source": "hour-step candidate scan from event time",
+ },
+ "horary_judgment": {
+ "status": "blocked" if normalized_type == "horary" else "not_applicable",
+ "reason": "significators/reception/radicality/planetary-hour/perfection/application-separation/prohibition/mediation/Moon bridge are partial, but outcome doctrine is not complete",
+ },
+ "electional_judgment": {
+ "status": "blocked" if normalized_type == "electional" else "not_applicable",
+ "reason": "goal lord, Moon application, malefic screen, and window search are partial, but full election doctrine is not complete",
+ },
+ "event_report_layers": {
+ "status": "partial",
+ "source": "guarded evidence matrix plus boundary table",
+ },
+ }
+ return {
+ "system": "classical_astrology",
+ "event_type": normalized_type,
+ "status": "guarded",
+ "question": question,
+ "election_goal": election_goal,
+ "event_chart": event_chart,
+ "moon_condition": moon_condition,
+ "horary": horary,
+ "electional": electional,
+ "report_layers": report_layers,
+ "audit": {
+ "techniques": techniques,
+ "boundary": "Event chart evidence only; horary/electional final judgments remain blocked.",
+ },
+ "boundary": "Guarded classical event chart evidence only; no yes/no answer, election approval, or timing claim is inferred.",
+ }
+
+
+def _technique_status(packet: dict[str, Any], key: str, default: str = "blocked") -> str:
+ techniques = packet.get("audit", {}).get("techniques", {})
+ row = techniques.get(key) if isinstance(techniques, dict) else None
+ return row.get("status", default) if isinstance(row, dict) else default
+
+
+def _event_chain_summary(horary: dict[str, Any], electional: dict[str, Any]) -> dict[str, Any]:
+ horary_layers = horary.get("report_layers") if isinstance(horary.get("report_layers"), dict) else {}
+ electional_layers = electional.get("report_layers") if isinstance(electional.get("report_layers"), dict) else {}
+ horary_summary = horary_layers.get("executive_summary") if isinstance(horary_layers.get("executive_summary"), dict) else {}
+ electional_summary = (
+ electional_layers.get("executive_summary")
+ if isinstance(electional_layers.get("executive_summary"), dict)
+ else {}
+ )
+ horary_matrix = horary_layers.get("evidence_matrix") if isinstance(horary_layers.get("evidence_matrix"), dict) else {}
+ horary_breakdown = (
+ horary_layers.get("horary_breakdown")
+ if isinstance(horary_layers.get("horary_breakdown"), dict)
+ else {}
+ )
+ electional_matrix = (
+ electional_layers.get("evidence_matrix")
+ if isinstance(electional_layers.get("evidence_matrix"), dict)
+ else {}
+ )
+ window_summary = (
+ electional_layers.get("window_summary")
+ if isinstance(electional_layers.get("window_summary"), dict)
+ else {}
+ )
+ candidate_breakdown = (
+ electional_layers.get("candidate_breakdown")
+ if isinstance(electional_layers.get("candidate_breakdown"), dict)
+ else {}
+ )
+ boundary_rows = []
+ for packet in (horary_layers, electional_layers):
+ rows = packet.get("boundary_table") if isinstance(packet.get("boundary_table"), list) else []
+ boundary_rows.extend(row for row in rows if isinstance(row, dict))
+ return {
+ "status": "partial",
+ "rows": [
+ {
+ "id": "horary_chain",
+ "status": horary_summary.get("status", "blocked"),
+ "judgment_status": horary_summary.get("judgment_status", "blocked"),
+ "support_count": horary_summary.get("support_count", 0),
+ "blocked_count": horary_summary.get("blocked_count", 0),
+ "horary_breakdown_row_count": horary_breakdown.get("row_count", 0),
+ "all_breakdown_judgments_blocked": horary_breakdown.get("all_breakdown_judgments_blocked", True),
+ "evidence_row_count": len(horary_matrix.get("rows") or []),
+ "score_inclusion": horary_summary.get("score_inclusion", "audit_only"),
+ },
+ {
+ "id": "electional_chain",
+ "status": electional_summary.get("status", "blocked"),
+ "judgment_status": electional_summary.get("judgment_status", "blocked"),
+ "support_count": electional_summary.get("support_count", 0),
+ "caution_count": electional_summary.get("caution_count", 0),
+ "candidate_count": window_summary.get("candidate_count", 0),
+ "candidate_breakdown_row_count": candidate_breakdown.get("row_count", 0),
+ "all_candidate_judgments_blocked": candidate_breakdown.get("all_candidate_judgments_blocked", True),
+ "promotion_status": window_summary.get("promotion_status", "blocked"),
+ "evidence_row_count": len(electional_matrix.get("rows") or []),
+ "score_inclusion": electional_summary.get("score_inclusion", "audit_only"),
+ },
+ ],
+ "boundary_rows": boundary_rows,
+ "blocked_gate_count": sum(1 for row in boundary_rows if row.get("status") == "blocked"),
+ "promotion_status": "blocked",
+ "boundary": "Event-chain summary lifts horary/electional report evidence to the usable-loop level without creating judgment claims.",
+ }
+
+
+def _closure_requirement_matrix(
+ *,
+ core: dict[str, Any],
+ horary: dict[str, Any],
+ electional: dict[str, Any],
+ calibration: dict[str, Any],
+ calibration_corpus: dict[str, Any],
+ event_summary: dict[str, Any],
+) -> dict[str, Any]:
+ zr = core.get("zodiacal_release_adjudication") if isinstance(core.get("zodiacal_release_adjudication"), dict) else {}
+ zr_matrix = zr.get("adjudication_matrix") if isinstance(zr.get("adjudication_matrix"), dict) else {}
+ corpus_summary = calibration_corpus.get("summary", {}).get("case_corpus_coverage", {})
+ rows = [
+ {
+ "id": "calculation_layer",
+ "requirement": "Core classical astrology calculation layer",
+ "status": "usable" if _technique_status(core, "sect") == "executed" else "blocked",
+ "evidence": ["traditional planets", "sect", "dignities", "bounds", "faces", "Lots", "annual profection"],
+ },
+ {
+ "id": "zr_loosing_peak",
+ "requirement": "ZR loosing-of-the-bond and peak-period audit chain",
+ "status": "partial",
+ "evidence": [
+ "loosing_of_bond",
+ "rule_variants",
+ "peak_periods",
+ "loosing_peak_replay_matrix",
+ ],
+ "blocked_gate": zr_matrix.get("event_prominence_gate", {}).get("status", "blocked"),
+ },
+ {
+ "id": "horary_chain",
+ "requirement": "Horary independent guarded evidence chain",
+ "status": "partial" if horary.get("status") == "guarded" else "blocked",
+ "evidence": ["horary_breakdown", "evidence_matrix", "boundary_table"],
+ "judgment_status": horary.get("report_layers", {}).get("executive_summary", {}).get("judgment_status", "blocked"),
+ },
+ {
+ "id": "electional_chain",
+ "requirement": "Electional independent guarded evidence chain",
+ "status": "partial" if electional.get("status") == "guarded" else "blocked",
+ "evidence": ["candidate_breakdown", "window_summary", "evidence_matrix", "boundary_table"],
+ "judgment_status": electional.get("report_layers", {}).get("executive_summary", {}).get("judgment_status", "blocked"),
+ },
+ {
+ "id": "real_case_calibration",
+ "requirement": "Real-case replay, corpus coverage, and negative-control boundary",
+ "status": "partial" if calibration.get("status") == "partial_scored" else "blocked",
+ "evidence": ["replay_results", "case_corpus_coverage", "negative_control_replay", "domain_specificity_table"],
+ "prediction_confidence": calibration.get("report_layers", {}).get("executive_summary", {}).get("prediction_confidence", "blocked"),
+ "primary_case_count": corpus_summary.get("primary_case_count"),
+ "positive_holdout_case_count": corpus_summary.get("positive_holdout_case_count"),
+ "candidate_negative_window_count": corpus_summary.get("candidate_negative_window_count"),
+ },
+ {
+ "id": "report_layer",
+ "requirement": "Report-facing executive summaries, evidence matrices, and boundary tables",
+ "status": "usable" if isinstance(event_summary.get("rows"), list) and event_summary.get("rows") else "blocked",
+ "evidence": ["report_layers.executive_summary", "event_chain_summary", "readiness_table", "boundary_table"],
+ },
+ {
+ "id": "api_frontend_entry",
+ "requirement": "CLI/API/front-end entrypoint visibility",
+ "status": "usable",
+ "evidence": [
+ "classical-usable-loop-audit",
+ "/api/unified_western_reading classical_mode=usable_loop",
+ "Technique Explorer classicalAstrology action",
+ ],
+ },
+ {
+ "id": "tests_and_audit_docs",
+ "requirement": "Focused tests plus audit documentation",
+ "status": "usable",
+ "evidence": [
+ "tests/test_classical_astrology.py",
+ "tests/test_cli_smoke.py",
+ "tests/test_unified_western_reading.py",
+ "tests/test_api_server_security.py",
+ "docs/research/classical_astrology_mvp_audit_2026_08_25.md",
+ ],
+ },
+ ]
+ blocked_rows = [
+ row for row in rows
+ if row.get("status") == "blocked"
+ or row.get("judgment_status") == "blocked"
+ or row.get("prediction_confidence") == "blocked"
+ or row.get("blocked_gate") == "blocked"
+ ]
+ usable_rows = [row for row in rows if row.get("status") in {"usable", "partial"}]
+ return {
+ "status": "usable_guarded" if len(usable_rows) == len(rows) else "blocked",
+ "row_count": len(rows),
+ "usable_or_partial_count": len(usable_rows),
+ "blocked_or_guarded_gate_count": len(blocked_rows),
+ "rows": rows,
+ "blocked_rows": blocked_rows,
+ "completion_claim": "usable_auditable_loop_ready",
+ "prediction_promotion_status": "blocked",
+ "boundary": "Completion means a guarded auditable MVP loop is available; it does not mean predictive, horary, or electional judgment doctrine is complete.",
+ }
+
+
+def _doctrine_prediction_promotion_gate(
+ *,
+ core: dict[str, Any],
+ horary: dict[str, Any],
+ electional: dict[str, Any],
+ calibration: dict[str, Any],
+ calibration_corpus: dict[str, Any],
+ event_summary: dict[str, Any],
+ closure_matrix: dict[str, Any],
+ blind_scoring_manifest_path: Path | str | None = None,
+ reviewer_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ zr = core.get("zodiacal_release_adjudication") if isinstance(core.get("zodiacal_release_adjudication"), dict) else {}
+ zr_matrix = zr.get("adjudication_matrix") if isinstance(zr.get("adjudication_matrix"), dict) else {}
+ horary_matrix = horary.get("horary", {}).get("adjudication_matrix", {})
+ electional_matrix = electional.get("electional", {}).get("adjudication_matrix", {})
+ corpus_summary = calibration_corpus.get("summary", {}).get("case_corpus_coverage", {})
+ negative_readiness = calibration_corpus.get("specificity_gate", {}).get("negative_control_readiness", {})
+ source_summary = negative_readiness.get("source_audit_summary", {})
+ zr_rule_table = _evaluate_zr_event_prominence_rule_table(
+ zr_matrix,
+ release=zr,
+ profection=core.get("annual_profection"),
+ )
+ horary_timing_thresholds = _evaluate_horary_perfection_timing_thresholds(horary.get("horary", {}))
+ electional_goal_tables = _evaluate_electional_goal_rule_tables(electional.get("electional", {}))
+ blind_validation_manifest = _load_classical_blind_validation_manifest()
+ doctrine_verdict_summary = _doctrine_verdict_readable_summary(
+ zr_rule_table=zr_rule_table,
+ horary_timing_thresholds=horary_timing_thresholds,
+ electional_goal_tables=electional_goal_tables,
+ blind_validation_manifest=blind_validation_manifest,
+ )
+ promotion_state_view = _build_upper_promotion_state_view(
+ blind_scoring_manifest_path=blind_scoring_manifest_path,
+ reviewer_manifest_path=reviewer_manifest_path,
+ )
+ doctrine_gap_closure_panel = _doctrine_gap_closure_panel(
+ zr_rule_table=zr_rule_table,
+ horary_timing_thresholds=horary_timing_thresholds,
+ electional_goal_tables=electional_goal_tables,
+ blind_validation_manifest=blind_validation_manifest,
+ promotion_state_view=promotion_state_view,
+ )
+ rows = [
+ {
+ "id": "zr_event_prominence_doctrine",
+ "requirement": "Promote ZR from event evidence to event-prominence doctrine",
+ "status": zr_rule_table["review_status"],
+ "current_signal": {
+ "zr_status": _technique_status(core, "zodiacal_release", "blocked"),
+ "event_prominence_gate": zr_matrix.get("event_prominence_gate", {}).get("status", "blocked"),
+ "promotion_status": zr_matrix.get("promotion_status", "blocked"),
+ "rule_table_review_status": zr_rule_table["review_status"],
+ },
+ "rule_table": zr_rule_table,
+ "evidence": ["loosing_of_bond", "peak_periods", "rule_variants", "loosing_peak_replay_matrix"],
+ "required_to_promote": [
+ "freeze event-prominence thresholds by domain",
+ "separate positive replay from negative controls",
+ "document peak-lord and profection arbitration rules",
+ ],
+ },
+ {
+ "id": "horary_yes_no_doctrine",
+ "requirement": "Promote horary evidence into yes/no and timing judgment doctrine",
+ "status": horary_timing_thresholds["review_status"],
+ "current_signal": {
+ "chain_status": "partial" if horary.get("status") == "guarded" else "blocked",
+ "judgment_status": horary_matrix.get("judgment_status", "blocked"),
+ "breakdown_row_count": len(horary.get("report_layers", {}).get("horary_breakdown", {}).get("rows", []) or []),
+ "threshold_review_status": horary_timing_thresholds["review_status"],
+ },
+ "timing_thresholds": horary_timing_thresholds,
+ "evidence": ["radicality", "perfection", "prohibition", "translation_collection", "moon_bridge", "moon_void_of_course"],
+ "required_to_promote": [
+ "house/topic-specific significator tables",
+ "reception and debility weighting",
+ "perfection timing thresholds",
+ "case-calibrated false-positive screen",
+ ],
+ },
+ {
+ "id": "electional_approval_doctrine",
+ "requirement": "Promote electional ranking into approval and best-time doctrine",
+ "status": electional_goal_tables["review_status"],
+ "current_signal": {
+ "chain_status": "partial" if electional.get("status") == "guarded" else "blocked",
+ "judgment_status": electional_matrix.get("judgment_status", "blocked"),
+ "candidate_count": len(electional.get("report_layers", {}).get("candidate_breakdown", {}).get("rows", []) or []),
+ "goal_table_review_status": electional_goal_tables["review_status"],
+ },
+ "goal_rule_tables": electional_goal_tables,
+ "evidence": ["goal_lord", "moon_condition", "malefic_screen", "topic_weights", "window_search"],
+ "required_to_promote": [
+ "goal-specific rule tables",
+ "avoidance hierarchy for Moon and malefics",
+ "benefic/malefic dignity strength thresholds",
+ "minimum acceptable candidate criteria",
+ ],
+ },
+ {
+ "id": "real_case_validation",
+ "requirement": "Promote replay evidence through independent blind validation",
+ "status": "blocked" if blind_validation_manifest["promotion_status"] == "blocked" else blind_validation_manifest["status"],
+ "current_signal": {
+ "single_case_status": calibration.get("status", "blocked"),
+ "primary_case_count": corpus_summary.get("primary_case_count"),
+ "positive_holdout_case_count": corpus_summary.get("positive_holdout_case_count"),
+ "candidate_negative_window_count": corpus_summary.get("candidate_negative_window_count"),
+ "usable_negative_source_count": source_summary.get("usable_for_promotion_count", 0),
+ "blind_validation_manifest_status": blind_validation_manifest["status"],
+ "blind_label_count": blind_validation_manifest["blind_label_count"],
+ },
+ "blind_validation_manifest": blind_validation_manifest,
+ "evidence": ["case_corpus_coverage", "negative_control_replay", "domain_specificity_table"],
+ "required_to_promote": [
+ "independent frozen negative labels",
+ "blind scoring protocol",
+ "domain-specific pass/fail thresholds",
+ "holdout separation from rule tuning",
+ ],
+ },
+ {
+ "id": "api_report_contract",
+ "requirement": "Expose promotion boundary through report, CLI, API, and front-end entry points",
+ "status": "usable",
+ "current_signal": {
+ "closure_status": closure_matrix.get("status"),
+ "event_summary_status": event_summary.get("status"),
+ "prediction_promotion_status": closure_matrix.get("prediction_promotion_status"),
+ },
+ "evidence": ["classical-usable-loop-audit", "unified_western_reading", "Technique Explorer classicalAstrology"],
+ "required_to_promote": ["keep blocked rows visible whenever prediction/doctrine gates are not closed"],
+ },
+ ]
+ blocked_rows = [row for row in rows if row.get("status") == "blocked"]
+ return {
+ "status": "review_blocked" if blocked_rows else "review_ready",
+ "promotion_target": "prediction_ready_doctrine_ready",
+ "current_level": "guarded_mvp",
+ "candidate_review_level": "doctrine_review_ready",
+ "promotion_allowed": False,
+ "doctrine_assets": {
+ "zr_event_prominence_rule_table": zr_rule_table,
+ "horary_perfection_timing_thresholds": horary_timing_thresholds,
+ "electional_goal_rule_tables": electional_goal_tables,
+ "blind_case_validation_manifest": blind_validation_manifest,
+ },
+ "readable_summary": doctrine_verdict_summary,
+ "doctrine_gap_closure_panel": doctrine_gap_closure_panel,
+ "promotion_state_view": promotion_state_view,
+ "row_count": len(rows),
+ "blocked_row_count": len(blocked_rows),
+ "review_ready_rows": [row["id"] for row in rows if row.get("status") == "usable"],
+ "blocked_rows": blocked_rows,
+ "rows": rows,
+ "next_doctrine_work_queue": [
+ {"priority": 1, "id": "zr_event_prominence_rule_table", "scope": "domain thresholds plus peak/profection arbitration"},
+ {"priority": 2, "id": "horary_perfection_timing_thresholds", "scope": "significator, reception, perfection, and mediation doctrine"},
+ {"priority": 3, "id": "electional_goal_rule_tables", "scope": "goal-specific approval and rejection criteria"},
+ {"priority": 4, "id": "blind_case_validation_manifest", "scope": "frozen positives, independent negatives, and holdout scoring"},
+ ],
+ "boundary": "The classical lane is ready for doctrine review, but prediction-ready promotion is blocked until the doctrine rows and blind validation row close.",
+ }
+
+
+def _build_upper_promotion_state_view(
+ *,
+ blind_scoring_manifest_path: Path | str | None,
+ reviewer_manifest_path: Path | str | None,
+) -> dict[str, Any]:
+ if not blind_scoring_manifest_path:
+ return {
+ "status": "not_unsealed",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "scoring_manifest_status": "not_provided",
+ "unseal_status": "not_run",
+ "candidate_gate_status": "not_applicable",
+ "boundary": "No blind scoring manifest was provided to usable-loop; upper gate reports the pre-unseal state only.",
+ }
+ scoring_path = Path(blind_scoring_manifest_path)
+ if not scoring_path.exists():
+ return {
+ "status": "blocked",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "scoring_manifest_path": str(scoring_path),
+ "scoring_manifest_status": "missing",
+ "unseal_status": "blocked",
+ "candidate_gate_status": "not_applicable",
+ "boundary": "Blind scoring manifest path was provided but is missing; usable-loop does not recompute scoring.",
+ }
+ unsealed = build_classical_blind_unseal_packet(
+ scoring_manifest_path=scoring_path,
+ reviewer_manifest_path=reviewer_manifest_path,
+ )
+ candidate_gate = (
+ unsealed.get("statistics_shell", {})
+ .get("prediction_promotion_candidate_gate", {})
+ if isinstance(unsealed.get("statistics_shell"), dict)
+ else {}
+ )
+ candidate_status = candidate_gate.get("status", "blocked")
+ status = (
+ "prediction_promotion_candidate"
+ if candidate_status == "prediction_promotion_candidate"
+ else "unsealed_review_blocked"
+ if unsealed.get("status") == "partial"
+ else "blocked"
+ )
+ return {
+ "status": status,
+ "promotion_status": "candidate" if status == "prediction_promotion_candidate" else "blocked",
+ "promotion_allowed": False,
+ "scoring_manifest_path": str(scoring_path),
+ "reviewer_manifest_path": str(reviewer_manifest_path) if reviewer_manifest_path else str(DEFAULT_CLASSICAL_REVIEWER_UNSEAL_MANIFEST),
+ "scoring_manifest_status": "loaded",
+ "unseal_status": unsealed.get("status", "blocked"),
+ "unsealed_row_count": unsealed.get("unsealed_row_count", 0),
+ "statistics_shell_status": (unsealed.get("statistics_shell") or {}).get("status"),
+ "candidate_gate_status": candidate_status,
+ "candidate_gate": candidate_gate,
+ "boundary": "Usable-loop reports the unseal/promotion candidate state only; it does not recompute scoring or grant final prediction readiness.",
+ }
+
+
+def _summarize_doctrine_asset(asset_id: str, asset: dict[str, Any]) -> dict[str, Any]:
+ verdicts = asset.get("doctrine_verdicts") if isinstance(asset.get("doctrine_verdicts"), list) else []
+ blocking = [row for row in verdicts if isinstance(row, dict) and row.get("blocks_promotion") is True]
+ supporting = [row for row in verdicts if isinstance(row, dict) and row.get("blocks_promotion") is False]
+ scorecard = asset.get("component_scorecard") if isinstance(asset.get("component_scorecard"), dict) else {}
+ return {
+ "asset_id": asset_id,
+ "status": asset.get("review_status") or asset.get("status", "blocked"),
+ "promotion_status": asset.get("promotion_status", "blocked"),
+ "verdict_count": len(verdicts),
+ "supporting_verdict_count": len(supporting),
+ "blocking_verdict_count": len(blocking),
+ "blocking_rule_ids": [row.get("rule_id") for row in blocking],
+ "component_review_score": scorecard.get("review_score"),
+ "component_supported_count": scorecard.get("supported_component_count"),
+ "component_scorecard": scorecard or None,
+ "summary": (
+ f"{asset_id}: {len(supporting)} supporting verdicts, {len(blocking)} blocking verdicts; "
+ f"promotion_status={asset.get('promotion_status', 'blocked')}"
+ ),
+ }
+
+
+def _doctrine_verdict_trace_row(asset_id: str, row: dict[str, Any]) -> dict[str, Any]:
+ blocks = bool(row.get("blocks_promotion"))
+ reason = str(row.get("reason") or "")
+ source_loci = row.get("source_loci") if isinstance(row.get("source_loci"), list) else []
+ rule_locus_ids = [
+ str(anchor.get("rule_locus_id"))
+ for locus in source_loci
+ for anchor in (locus.get("page_anchors") or [])
+ if isinstance(anchor, dict) and anchor.get("rule_locus_id")
+ ]
+ rule_locus_catalog_ids = [
+ str(catalog_id)
+ for locus in source_loci
+ for anchor in (locus.get("page_anchors") or [])
+ if isinstance(anchor, dict)
+ for catalog_id in (anchor.get("rule_locus_catalog_ids") or [])
+ if catalog_id
+ ]
+ rule_locus_labels = [
+ str(label)
+ for locus in source_loci
+ for anchor in (locus.get("page_anchors") or [])
+ if isinstance(anchor, dict)
+ for label in (anchor.get("rule_locus_labels") or [])
+ if label
+ ]
+ return {
+ "asset_id": asset_id,
+ "rule_id": row.get("rule_id"),
+ "doctrine_rule": row.get("doctrine_rule") or row.get("rule_id"),
+ "rule_family": row.get("rule_family"),
+ "status": row.get("status", "blocked"),
+ "verdict": row.get("verdict") or ("blocks_promotion" if blocks else "supports_review"),
+ "raw_input": row.get("raw_input", row.get("observed")),
+ "source_loci": source_loci,
+ "source_loci_count": len(source_loci),
+ "rule_locus_ids": rule_locus_ids,
+ "rule_locus_id_count": len(rule_locus_ids),
+ "rule_locus_catalog_ids": rule_locus_catalog_ids,
+ "rule_locus_catalog_id_count": len(rule_locus_catalog_ids),
+ "rule_locus_labels": rule_locus_labels,
+ "rule_locus_label_count": len(rule_locus_labels),
+ "blocks_promotion": blocks,
+ "cannot_promote_reason": row.get("cannot_promote_reason") or (reason if blocks else ""),
+ "promotion_boundary": row.get("promotion_boundary") or (
+ "This doctrine verdict blocks prediction-ready promotion until its rule table and blind validation gates close."
+ if blocks else
+ "This doctrine verdict supports review only; it does not independently establish prediction-ready promotion."
+ ),
+ }
+
+
+def _doctrine_verdict_trace(
+ *,
+ zr_rule_table: dict[str, Any],
+ horary_timing_thresholds: dict[str, Any],
+ electional_goal_tables: dict[str, Any],
+) -> dict[str, Any]:
+ assets = [
+ ("zr_event_prominence_rule_table", zr_rule_table),
+ ("horary_perfection_timing_thresholds", horary_timing_thresholds),
+ ("electional_goal_rule_tables", electional_goal_tables),
+ ]
+ rows = [
+ _doctrine_verdict_trace_row(asset_id, verdict)
+ for asset_id, asset in assets
+ for verdict in (asset.get("doctrine_verdicts") if isinstance(asset.get("doctrine_verdicts"), list) else [])
+ if isinstance(verdict, dict)
+ ]
+ blocking_rows = [row for row in rows if row["blocks_promotion"]]
+ return {
+ "status": "review_blocked" if blocking_rows else "partial_review_ready",
+ "row_count": len(rows),
+ "blocking_row_count": len(blocking_rows),
+ "rows": rows,
+ "locus_coverage_gate": _doctrine_locus_coverage_gate(rows),
+ "promotion_allowed": False,
+ "boundary": "Trace rows explain doctrine-review evidence and blockers; candidate support must not be written as final prediction-ready capability.",
+ }
+
+
+def _trace_asset_rows(trace: dict[str, Any], asset_id: str) -> list[dict[str, Any]]:
+ rows = trace.get("rows") if isinstance(trace.get("rows"), list) else []
+ return [row for row in rows if isinstance(row, dict) and row.get("asset_id") == asset_id]
+
+
+def _doctrine_locus_coverage_gate(rows: list[dict[str, Any]]) -> dict[str, Any]:
+ coverage_rows = []
+ for row in rows:
+ loci = row.get("source_loci") if isinstance(row.get("source_loci"), list) else []
+ page_anchor_count = sum(
+ int(locus.get("page_anchor_count") or 0)
+ for locus in loci
+ if isinstance(locus, dict)
+ )
+ rule_locus_id_count = len(row.get("rule_locus_ids") or [])
+ ocr_blocked_sources = [
+ str(locus.get("source_id"))
+ for locus in loci
+ if isinstance(locus, dict)
+ and not locus.get("page_anchor_count")
+ and locus.get("source_status") == "ocr_required"
+ ]
+ status = (
+ "text_anchor_present" if page_anchor_count > 0
+ else "source_only_ocr_blocked" if ocr_blocked_sources
+ else "source_only_partial" if loci
+ else "blocked_missing_source_loci"
+ )
+ coverage_rows.append({
+ "asset_id": row.get("asset_id"),
+ "rule_id": row.get("rule_id"),
+ "rule_family": row.get("rule_family"),
+ "status": status,
+ "source_loci_count": len(loci),
+ "page_anchor_count": page_anchor_count,
+ "rule_locus_id_count": rule_locus_id_count,
+ "ocr_blocked_sources": ocr_blocked_sources,
+ "blocks_locus_freeze": status != "text_anchor_present" or rule_locus_id_count == 0,
+ "promotion_allowed": False,
+ })
+ blocked_rows = [row for row in coverage_rows if row["blocks_locus_freeze"]]
+ page_manifest = _load_doctrine_loci_manifest()
+ source_blockers = []
+ for source in page_manifest.get("source_loci", []) if isinstance(page_manifest.get("source_loci"), list) else []:
+ if not isinstance(source, dict) or source.get("status") != "blocked":
+ continue
+ source_blockers.append({
+ "source_id": source.get("source_id"),
+ "status": source.get("status"),
+ "reason": source.get("reason"),
+ "ocr_status": source.get("ocr_status"),
+ "families_requested": source.get("families_requested"),
+ "path": source.get("path"),
+ "blocks_full_locus_freeze": True,
+ })
+ return {
+ "schema": "classical.doctrine_locus_coverage_gate.v1",
+ "status": "partial" if coverage_rows and not blocked_rows else "blocked",
+ "row_count": len(coverage_rows),
+ "text_anchor_row_count": sum(1 for row in coverage_rows if row["status"] == "text_anchor_present"),
+ "blocked_row_count": len(blocked_rows),
+ "source_blocker_count": len(source_blockers),
+ "source_blockers": source_blockers,
+ "rows": coverage_rows,
+ "promotion_allowed": False,
+ "boundary": "Doctrine rows require page anchors before source-locus freeze; this gate never promotes prediction readiness.",
+ }
+
+
+def _trace_blocking_rule_ids(rows: list[dict[str, Any]]) -> list[str]:
+ return [
+ str(row.get("rule_id"))
+ for row in rows
+ if row.get("blocks_promotion") is True and row.get("rule_id")
+ ]
+
+
+def _trace_source_loci_summary(rows: list[dict[str, Any]], *fallback_families: str) -> dict[str, Any]:
+ loci_by_id: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ if isinstance(row, dict) and row.get("source_id"):
+ loci_by_id[str(row["source_id"])] = row
+ row_loci = row.get("source_loci") if isinstance(row.get("source_loci"), list) else []
+ for locus in row_loci:
+ if isinstance(locus, dict) and locus.get("source_id"):
+ loci_by_id[str(locus["source_id"])] = locus
+ if not loci_by_id and fallback_families:
+ for locus in _source_loci(*fallback_families):
+ loci_by_id[str(locus["source_id"])] = locus
+ loci = list(loci_by_id.values())
+ return {
+ "source_loci_count": len(loci),
+ "source_ids": [str(row.get("source_id")) for row in loci],
+ "doctrine_families": sorted({
+ str(row.get("doctrine_family"))
+ for row in loci
+ if row.get("doctrine_family")
+ }),
+ "extractable_count": sum(1 for row in loci if row.get("source_status") == "extractable"),
+ "ocr_required_count": sum(1 for row in loci if row.get("source_status") == "ocr_required"),
+ "page_anchor_count": sum(int(row.get("page_anchor_count") or 0) for row in loci),
+ "rule_locus_ids": [
+ str(anchor.get("rule_locus_id"))
+ for row in loci
+ for anchor in (row.get("page_anchors") or [])
+ if isinstance(anchor, dict) and anchor.get("rule_locus_id")
+ ],
+ "rule_locus_catalog_ids": [
+ str(catalog_id)
+ for row in loci
+ for anchor in (row.get("page_anchors") or [])
+ if isinstance(anchor, dict)
+ for catalog_id in (anchor.get("rule_locus_catalog_ids") or [])
+ if catalog_id
+ ],
+ "rule_locus_labels": [
+ str(label)
+ for row in loci
+ for anchor in (row.get("page_anchors") or [])
+ if isinstance(anchor, dict)
+ for label in (anchor.get("rule_locus_labels") or [])
+ if label
+ ],
+ }
+
+
+def _classical_doctrine_natural_language_summary(
+ *,
+ trace: dict[str, Any],
+ blind_validation_manifest: dict[str, Any],
+) -> dict[str, Any]:
+ zr_rows = _trace_asset_rows(trace, "zr_event_prominence_rule_table")
+ horary_rows = _trace_asset_rows(trace, "horary_perfection_timing_thresholds")
+ electional_rows = _trace_asset_rows(trace, "electional_goal_rule_tables")
+ blind_count = int(blind_validation_manifest.get("blind_label_count") or 0)
+ frozen_count = int(blind_validation_manifest.get("frozen_sample_queue_count") or 0)
+ calibration_needed = (
+ f"Needs calibration with independent blind labels and domain false-positive review; frozen_samples={frozen_count}, blind_labels={blind_count}."
+ )
+ return {
+ "status": "review_blocked",
+ "items": [
+ {
+ "id": "zodiacal_release",
+ "status": "review_only" if zr_rows else "blocked",
+ "zh": "ZR 已能并列展示 Spirit/Fortune 释放、时期主星、峰值主星与年主之间的审计关系,但它现在只能提示事件显著性线索,不能单独断具体事件。",
+ "en": "ZR can now show side-by-side Spirit/Fortune release snapshots and audit links among period rulers, peak lords, release rulers, and the annual profection lord, but it only marks event-prominence evidence rather than predicting concrete events.",
+ "why_review_only": "Spirit/Fortune cross-lot review, peak-lord and profection arbitration are visible, but event-prominence thresholds still require frozen replay and negative-control calibration.",
+ "blocked_reason": "; ".join(_trace_blocking_rule_ids(zr_rows)) or "none",
+ "calibration_needed": calibration_needed,
+ "source_loci_summary": _trace_source_loci_summary(zr_rows, "zr", "lots", "dignity"),
+ },
+ {
+ "id": "horary",
+ "status": "review_only" if horary_rows else "blocked",
+ "zh": "Horary 已能展示直接成相、禁止、传递/收集与月亮桥接的判断证据,但它仍只是审查材料,不给最终是/否答案。",
+ "en": "Horary now surfaces direct perfection, prohibition, translation/collection, and Moon-bridge evidence, while staying review-only instead of issuing a final yes/no judgment.",
+ "why_review_only": "Perfection timing order, prohibition handling, mediation strength, and reception snapshots are partial and not yet calibrated against blind horary cases.",
+ "blocked_reason": "; ".join(_trace_blocking_rule_ids(horary_rows)) or "none",
+ "calibration_needed": "Needs independent horary cases with frozen question time, outcome labels, timing units, and false-positive review.",
+ "source_loci_summary": _trace_source_loci_summary(horary_rows, "horary", "reception"),
+ },
+ {
+ "id": "electional",
+ "status": "review_only" if electional_rows else "blocked",
+ "zh": "Electional 已能解释目标必需项、偏好项、月亮状态与凶星/燃烧等禁忌权重,但它只能排序候选窗口,不能批准最佳时间。",
+ "en": "Electional can now explain goal-required hits, preferred hits, Moon condition, and weighted cautions such as malefic contact or combustion, but it only ranks candidate windows and does not approve a best time.",
+ "why_review_only": "Goal-specific caution limits, Moon condition hierarchy, and acceptable-window thresholds still need domain-separated validation.",
+ "blocked_reason": "; ".join(_trace_blocking_rule_ids(electional_rows)) or "none",
+ "calibration_needed": "Needs independent electional samples by goal domain, with accepted windows, rejected windows, and outcome follow-up kept separate from tuning.",
+ "source_loci_summary": _trace_source_loci_summary(electional_rows, "electional"),
+ },
+ ],
+ "source_loci_manifest": _source_loci_manifest(),
+ "reader_boundary": "Natural-language summaries explain guarded doctrine verdicts only; they must not be rendered as final prediction-ready, yes/no, or best-time claims.",
+ }
+
+
+def _doctrine_verdict_readable_summary(
+ *,
+ zr_rule_table: dict[str, Any],
+ horary_timing_thresholds: dict[str, Any],
+ electional_goal_tables: dict[str, Any],
+ blind_validation_manifest: dict[str, Any],
+) -> dict[str, Any]:
+ rows = [
+ _summarize_doctrine_asset("zr_event_prominence_rule_table", zr_rule_table),
+ _summarize_doctrine_asset("horary_perfection_timing_thresholds", horary_timing_thresholds),
+ _summarize_doctrine_asset("electional_goal_rule_tables", electional_goal_tables),
+ ]
+ trace = _doctrine_verdict_trace(
+ zr_rule_table=zr_rule_table,
+ horary_timing_thresholds=horary_timing_thresholds,
+ electional_goal_tables=electional_goal_tables,
+ )
+ natural_language_summary = _classical_doctrine_natural_language_summary(
+ trace=trace,
+ blind_validation_manifest=blind_validation_manifest,
+ )
+ return {
+ "status": "review_blocked",
+ "rows": rows,
+ "doctrine_trace": trace,
+ "doctrine_locus_coverage_gate": trace["locus_coverage_gate"],
+ "natural_language_summary": natural_language_summary,
+ "source_loci_manifest": _source_loci_manifest(),
+ "total_verdict_count": sum(row["verdict_count"] for row in rows),
+ "total_blocking_verdict_count": sum(row["blocking_verdict_count"] for row in rows),
+ "blind_sample_summary": {
+ "manifest_id": blind_validation_manifest.get("manifest_id"),
+ "frozen_sample_queue_count": blind_validation_manifest.get("frozen_sample_queue_count", 0),
+ "blind_label_count": blind_validation_manifest.get("blind_label_count", 0),
+ "promotion_status": blind_validation_manifest.get("promotion_status", "blocked"),
+ },
+ "reader_boundary": "Verdicts summarize doctrine-review blockers for readers; they do not render yes/no, best-time, or event-prediction judgments.",
+ }
+
+
+def _doctrine_gap_closure_panel(
+ *,
+ zr_rule_table: dict[str, Any],
+ horary_timing_thresholds: dict[str, Any],
+ electional_goal_tables: dict[str, Any],
+ blind_validation_manifest: dict[str, Any],
+ promotion_state_view: dict[str, Any],
+) -> dict[str, Any]:
+ def blocking_rule_ids(asset: dict[str, Any]) -> list[str]:
+ return [
+ str(row.get("rule_id"))
+ for row in (asset.get("doctrine_verdicts") if isinstance(asset.get("doctrine_verdicts"), list) else [])
+ if isinstance(row, dict) and row.get("blocks_promotion") is True and row.get("rule_id")
+ ]
+
+ blind_blockers = blind_validation_manifest.get("blockers") if isinstance(blind_validation_manifest.get("blockers"), list) else []
+ blind_intake_quality = _classical_blind_sample_intake_quality(blind_validation_manifest)
+ horary_sample_packet = _load_classical_domain_sample_packet_summary(DEFAULT_CLASSICAL_HORARY_SAMPLE_PACKET, "horary")
+ electional_sample_packet = _load_classical_domain_sample_packet_summary(DEFAULT_CLASSICAL_ELECTIONAL_SAMPLE_PACKET, "electional")
+ zr_sample_packet = _load_classical_domain_sample_packet_summary(
+ DEFAULT_CLASSICAL_ZR_SAMPLE_PACKET,
+ "zr",
+ DEFAULT_CLASSICAL_PUBLIC_REVIEWER_UNSEAL_MANIFEST,
+ )
+ horary_queue = _load_classical_case_acquisition_queue_summary(DEFAULT_CLASSICAL_HORARY_ACQUISITION_QUEUE, "horary")
+ electional_queue = _load_classical_case_acquisition_queue_summary(DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_QUEUE, "electional")
+ zr_queue = _load_classical_case_acquisition_queue_summary(DEFAULT_CLASSICAL_ZR_ACQUISITION_QUEUE, "zr")
+ horary_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_HORARY_ACQUISITION_RUNNER_PACKET, "horary")
+ electional_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_RUNNER_PACKET, "electional")
+ zr_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_ZR_ACQUISITION_RUNNER_PACKET, "zr")
+ horary_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_HORARY_FREEZE_CHECKLIST, "horary")
+ electional_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_CHECKLIST, "electional")
+ zr_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_ZR_FREEZE_CHECKLIST, "zr")
+ horary_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE, "horary")
+ electional_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE, "electional")
+ zr_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_ZR_DOMAIN_VALIDATION_BUNDLE, "zr")
+ horary_pipeline = _classical_domain_pipeline_summary(
+ "horary",
+ source_discovery_path=DEFAULT_CLASSICAL_HORARY_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_HORARY_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_HORARY_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_HORARY_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_HORARY_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_HORARY_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_HORARY_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=horary_bundle,
+ )
+ electional_pipeline = _classical_domain_pipeline_summary(
+ "electional",
+ source_discovery_path=DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_ELECTIONAL_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_ELECTIONAL_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_ELECTIONAL_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_ELECTIONAL_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=electional_bundle,
+ )
+ zr_pipeline = _classical_domain_pipeline_summary(
+ "zr",
+ source_discovery_path=DEFAULT_CLASSICAL_ZR_SOURCE_DISCOVERY,
+ source_freeze_conversion_path=DEFAULT_CLASSICAL_ZR_SOURCE_FREEZE_CONVERSION,
+ field_extraction_path=DEFAULT_CLASSICAL_ZR_CASE_FIELD_EXTRACTION,
+ chart_ocr_cache_path=DEFAULT_CLASSICAL_ZR_CHART_OCR_CACHE,
+ ocr_field_backfill_path=DEFAULT_CLASSICAL_ZR_OCR_FIELD_BACKFILL,
+ freeze_admission_path=DEFAULT_CLASSICAL_ZR_FREEZE_ADMISSION,
+ reviewer_freeze_packet_path=DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_PACKET,
+ reviewer_freeze_admission_path=DEFAULT_CLASSICAL_ZR_REVIEWER_FREEZE_ADMISSION,
+ reviewer_unseal_manifest_path=DEFAULT_CLASSICAL_ZR_REVIEWER_UNSEAL_MANIFEST,
+ reviewer_evidence_pipeline_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_PIPELINE,
+ reviewer_evidence_kit_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_KIT,
+ reviewer_evidence_assistant_path=DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_ASSISTANT,
+ reviewer_answer_apply_path=DEFAULT_CLASSICAL_ZR_REVIEWER_ANSWER_APPLY,
+ domain_validation_bundle=zr_bundle,
+ )
+ remaining_task_register = _load_classical_remaining_task_register(
+ horary_runner=horary_runner,
+ electional_runner=electional_runner,
+ horary_bundle=horary_bundle,
+ electional_bundle=electional_bundle,
+ )
+ productization_shell_summary = _load_classical_productization_shell_summary()
+ rows = [
+ {
+ "id": "horary_yes_no_final_judgment",
+ "status": "blocked",
+ "current_execution": "significators, direct perfection, application/separation, prohibition, translation/collection, Moon bridge, Moon VOC, reception snapshots",
+ "blocking_evidence": blocking_rule_ids(horary_timing_thresholds),
+ "required_to_close": [
+ "house-specific yes/no rule tables",
+ "perfection timing-unit thresholds by sign modality and house motion",
+ "calibrated prohibition and frustration handling",
+ "independent horary cases with frozen question time and outcome labels",
+ ],
+ "zh": "Horary 已有独立证据链,但最终是/否判断仍 blocked;成相、禁止、传递/收集、月亮桥接和 reception 只能给 review verdict。",
+ "en": "Horary has an independent evidence chain, but final yes/no judgment remains blocked; perfection, prohibition, mediation, Moon bridge, and reception only support review verdicts.",
+ "promotion_denial": {
+ "claim_type": "horary_final_yes_no",
+ "denied_phrase": "final yes/no judgment",
+ "allowed_phrase": "review-only horary evidence",
+ "reason": "House-specific outcome rules, calibrated prohibition/frustration handling, timing-unit thresholds, and independent horary cases are incomplete.",
+ },
+ "calibration_status": "blocked_missing_independent_horary_sample_packet",
+ "domain_sample_packet_summary": horary_sample_packet,
+ "case_acquisition_queue_summary": horary_queue,
+ "case_acquisition_runner_summary": horary_runner,
+ "freeze_checklist_summary": horary_freeze_checklist,
+ "domain_validation_bundle_summary": horary_bundle,
+ "domain_pipeline_summary": horary_pipeline,
+ "promotion_allowed": False,
+ },
+ {
+ "id": "electional_best_time_approval",
+ "status": "blocked",
+ "current_execution": "goal lord, required/preferred hits, Moon next aspect, Moon void-like scan, malefic/combustion cautions, weighted candidate ranking",
+ "blocking_evidence": blocking_rule_ids(electional_goal_tables),
+ "required_to_close": [
+ "goal-specific hard prohibition hierarchy",
+ "lunar condition weights by goal domain",
+ "comparison over a larger searched window",
+ "accepted/rejected electional samples with follow-up outcomes",
+ ],
+ "zh": "Electional 已能排序候选窗口和显示禁忌权重,但批准最佳时间仍 blocked。",
+ "en": "Electional can rank candidate windows and expose caution weights, but approving a best time remains blocked.",
+ "promotion_denial": {
+ "claim_type": "electional_best_time_approval",
+ "denied_phrase": "approved best time",
+ "allowed_phrase": "ranked candidate window for review",
+ "reason": "Goal-specific hard prohibitions, lunar-condition weights, wider-window comparison, and accepted/rejected electional outcomes are incomplete.",
+ },
+ "calibration_status": "blocked_missing_electional_outcome_sample_packet",
+ "domain_sample_packet_summary": electional_sample_packet,
+ "case_acquisition_queue_summary": electional_queue,
+ "case_acquisition_runner_summary": electional_runner,
+ "freeze_checklist_summary": electional_freeze_checklist,
+ "domain_validation_bundle_summary": electional_bundle,
+ "domain_pipeline_summary": electional_pipeline,
+ "promotion_allowed": False,
+ },
+ {
+ "id": "zr_cross_lot_cross_release_calibration",
+ "status": "partial",
+ "current_execution": "Spirit and Fortune releases side by side, active L1/L2 snapshots, active L2 opposition loosing candidates, angular peak candidates, release/peak/profection audit links",
+ "blocking_evidence": blocking_rule_ids(zr_rule_table),
+ "required_to_close": [
+ "cross-lot topic arbitration by domain with frozen positive and negative cases",
+ "level-specific loosing variants",
+ "cross-release replay against positive and negative cases",
+ ],
+ "zh": "ZR 已能并列说明 Spirit/Fortune 释放、峰值和 loosing 线索,但 cross-lot 与 cross-release 校准仍未闭合。",
+ "en": "ZR can now explain side-by-side Spirit/Fortune release, peak, and loosing testimony, but cross-lot and cross-release calibration remain unclosed.",
+ "promotion_denial": {
+ "claim_type": "zr_event_prediction_ready",
+ "denied_phrase": "prediction-ready ZR event judgment",
+ "allowed_phrase": "cross-lot/cross-release review weight",
+ "reason": "Domain-specific cross-lot arbitration, loosing variants, and cross-release replay against positive and negative frozen cases are incomplete.",
+ },
+ "calibration_status": "partial_cross_lot_cross_release_requires_frozen_case_replay",
+ "domain_sample_packet_summary": zr_sample_packet,
+ "case_acquisition_queue_summary": zr_queue,
+ "case_acquisition_runner_summary": zr_runner,
+ "freeze_checklist_summary": zr_freeze_checklist,
+ "domain_validation_bundle_summary": zr_bundle,
+ "domain_pipeline_summary": zr_pipeline,
+ "reviewer_unseal_status": zr_sample_packet.get("reviewer_evidence_status", "blocked"),
+ "statistics_gate_status": zr_sample_packet.get("statistics_gate_status", "blocked"),
+ "promotion_allowed": False,
+ },
+ {
+ "id": "blind_validation_reviewer_evidence",
+ "status": "blocked",
+ "current_execution": "blind scoring manifest, withheld labels, separate reviewer unseal, reviewer evidence gate, accuracy/false-positive shell",
+ "blocking_evidence": [str(item) for item in blind_blockers],
+ "intake_quality": {
+ "status": blind_intake_quality["status"],
+ "row_count": blind_intake_quality["row_count"],
+ "blocked_row_count": blind_intake_quality["blocked_row_count"],
+ "required_fields": blind_intake_quality["required_fields"],
+ },
+ "required_to_close": [
+ "more frozen positive samples",
+ "more frozen negative/non-event samples",
+ "independent reviewer source for each label",
+ "freeze timestamp before scoring and label independence",
+ "domain minimum sample counts and false-positive thresholds",
+ ],
+ "zh": "Blind validation 框架已经把评分和解盲分离,但真实冻结样本与独立 reviewer 证据不足,promotion 仍 blocked。",
+ "en": "Blind validation separates scoring from unseal, but frozen samples and independent reviewer evidence are insufficient, so promotion remains blocked.",
+ "promotion_denial": {
+ "claim_type": "doctrine_prediction_promotion",
+ "denied_phrase": "prediction-ready promotion",
+ "allowed_phrase": "blocked/partial review status",
+ "reason": "Frozen positive/negative samples, independent reviewer labels, freeze timestamps, label independence, and statistics thresholds are incomplete.",
+ },
+ "calibration_status": "blocked_missing_freeze_and_reviewer_evidence",
+ "promotion_allowed": False,
+ },
+ ]
+ sample_packet_status_table = [
+ {
+ "family": "horary",
+ "status": horary_sample_packet.get("reviewer_evidence_status", horary_sample_packet.get("status", "blocked")),
+ "sample_count": horary_sample_packet.get("sample_count", 0),
+ "reviewer_label_count": horary_sample_packet.get("reviewer_label_count", 0),
+ "reviewer_unseal_ready": horary_sample_packet.get("reviewer_unseal_ready", False),
+ "promotion_allowed": False,
+ },
+ {
+ "family": "electional",
+ "status": electional_sample_packet.get("reviewer_evidence_status", electional_sample_packet.get("status", "blocked")),
+ "sample_count": electional_sample_packet.get("sample_count", 0),
+ "reviewer_label_count": electional_sample_packet.get("reviewer_label_count", 0),
+ "reviewer_unseal_ready": electional_sample_packet.get("reviewer_unseal_ready", False),
+ "promotion_allowed": False,
+ },
+ {
+ "family": "zr",
+ "status": zr_sample_packet.get("reviewer_evidence_status", zr_sample_packet.get("status", "blocked")),
+ "sample_count": zr_sample_packet.get("sample_count", 0),
+ "reviewer_label_count": zr_sample_packet.get("reviewer_label_count", 0),
+ "reviewer_unseal_ready": zr_sample_packet.get("reviewer_unseal_ready", False),
+ "promotion_allowed": False,
+ },
+ ]
+ return {
+ "schema": "classical.doctrine_gap_closure_panel.v1",
+ "status": "blocked",
+ "promotion_allowed": False,
+ "promotion_state": promotion_state_view.get("status", "not_unsealed"),
+ "row_count": len(rows),
+ "blocked_row_count": sum(1 for row in rows if row["status"] == "blocked"),
+ "partial_row_count": sum(1 for row in rows if row["status"] == "partial"),
+ "sample_packet_status_table": sample_packet_status_table,
+ "case_acquisition_queue_summaries": {
+ "horary": horary_queue,
+ "electional": electional_queue,
+ },
+ "case_acquisition_runner_summaries": {
+ "horary": horary_runner,
+ "electional": electional_runner,
+ },
+ "freeze_checklist_summaries": {
+ "horary": horary_freeze_checklist,
+ "electional": electional_freeze_checklist,
+ },
+ "domain_validation_bundle_summaries": {
+ "horary": horary_bundle,
+ "electional": electional_bundle,
+ "zr": zr_bundle,
+ },
+ "domain_pipeline_summaries": {
+ "horary": horary_pipeline,
+ "electional": electional_pipeline,
+ "zr": zr_pipeline,
+ },
+ "remaining_task_register": remaining_task_register,
+ "productization_shell_summary": productization_shell_summary,
+ "domain_sample_packet_summaries": {
+ "horary": horary_sample_packet,
+ "electional": electional_sample_packet,
+ "zr": zr_sample_packet,
+ },
+ "rows": rows,
+ "reader_boundary": (
+ "This panel turns remaining doctrine gaps into executable report evidence. It does not grant horary final judgment, "
+ "electional best-time approval, ZR event prediction, reviewer unseal promotion, or prediction-ready promotion."
+ ),
+ }
+
+
+def build_classical_usable_loop_audit_packet(
+ *,
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int = 0,
+ latitude: float,
+ longitude: float,
+ timezone: str | float,
+ house_system: str = "P",
+ reference_date: str | None = None,
+ event_window_hours: int = 4,
+ event_step_hours: int = 2,
+ blind_scoring_manifest_path: Path | str | None = None,
+ reviewer_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ """Run the guarded classical lane end-to-end and expose one audit packet."""
+ birth = {
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": hour,
+ "minute": minute,
+ "second": second,
+ "latitude": latitude,
+ "longitude": longitude,
+ "timezone": timezone,
+ "house_system": house_system,
+ }
+ core = build_classical_astrology_packet(
+ **birth,
+ reference_date=reference_date,
+ include_real_case_calibration=False,
+ )
+ horary = build_classical_event_chart_packet(
+ **birth,
+ event_type="horary",
+ question="Can the guarded classical astrology loop be audited end-to-end?",
+ )
+ electional = build_classical_event_chart_packet(
+ **birth,
+ event_type="electional",
+ election_goal="launch",
+ election_window_hours=event_window_hours,
+ election_step_hours=event_step_hours,
+ )
+ calibration = build_classical_real_case_calibration_packet(
+ primary_theme="career",
+ case_id="jobs_iphone_2007",
+ )
+ calibration_corpus = build_classical_real_case_calibration_packet(primary_theme="career")
+ event_summary = _event_chain_summary(horary, electional)
+ closure_matrix = _closure_requirement_matrix(
+ core=core,
+ horary=horary,
+ electional=electional,
+ calibration=calibration,
+ calibration_corpus=calibration_corpus,
+ event_summary=event_summary,
+ )
+ promotion_gate = _doctrine_prediction_promotion_gate(
+ core=core,
+ horary=horary,
+ electional=electional,
+ calibration=calibration,
+ calibration_corpus=calibration_corpus,
+ event_summary=event_summary,
+ closure_matrix=closure_matrix,
+ blind_scoring_manifest_path=blind_scoring_manifest_path,
+ reviewer_manifest_path=reviewer_manifest_path,
+ )
+ zr_matrix = core.get("zodiacal_release_adjudication", {}).get("adjudication_matrix", {})
+ readiness_rows = [
+ {
+ "id": "core_calculation",
+ "status": "executed" if _technique_status(core, "sect") == "executed" else "blocked",
+ "entrypoint": "classical-astrology",
+ "evidence": "sect, dignity, bounds, faces, Lots, profection, and ZR raw layers",
+ },
+ {
+ "id": "planetary_doctrine_condition_verdicts",
+ "status": core.get("planetary_doctrine_condition_verdicts", {}).get("status", "blocked"),
+ "entrypoint": "classical-astrology",
+ "row_count": core.get("planetary_doctrine_condition_verdicts", {}).get("row_count", 0),
+ "promotion_allowed": core.get("planetary_doctrine_condition_verdicts", {}).get("promotion_allowed", False),
+ "evidence": "per-planet essential dignity, sect status, angularity, speed, support factors, and caution factors",
+ },
+ {
+ "id": "aspect_doctrine_verdicts",
+ "status": core.get("aspect_doctrine_verdicts", {}).get("status", "blocked"),
+ "entrypoint": "classical-astrology",
+ "row_count": core.get("aspect_doctrine_verdicts", {}).get("row_count", 0),
+ "promotion_allowed": core.get("aspect_doctrine_verdicts", {}).get("promotion_allowed", False),
+ "evidence": "degree aspects, whole-sign aspects, orb hierarchy, dexter/sinister direction, and approximate applying testimony",
+ },
+ {
+ "id": "accidental_dignity_verdicts",
+ "status": core.get("accidental_dignity_verdicts", {}).get("status", "blocked"),
+ "entrypoint": "classical-astrology",
+ "row_count": core.get("accidental_dignity_verdicts", {}).get("row_count", 0),
+ "promotion_allowed": core.get("accidental_dignity_verdicts", {}).get("promotion_allowed", False),
+ "evidence": "house motion, planetary joy, visibility, solar phase, under-beams, combustion, and cazimi checks",
+ },
+ {
+ "id": "bonification_maltreatment_verdicts",
+ "status": core.get("bonification_maltreatment_verdicts", {}).get("status", "blocked"),
+ "entrypoint": "classical-astrology",
+ "row_count": core.get("bonification_maltreatment_verdicts", {}).get("row_count", 0),
+ "promotion_allowed": core.get("bonification_maltreatment_verdicts", {}).get("promotion_allowed", False),
+ "evidence": "close Ptolemaic aspect testimony from benefics and malefics with sect-aware labels",
+ },
+ {
+ "id": "zodiacal_release",
+ "status": _technique_status(core, "zodiacal_release", "blocked"),
+ "entrypoint": "classical-astrology",
+ "promotion_status": zr_matrix.get("promotion_status", "blocked"),
+ "evidence": "active periods, loosing-of-the-bond variants, peak candidates, adjudication matrix",
+ },
+ {
+ "id": "horary_chain",
+ "status": "partial" if horary.get("status") == "guarded" else "blocked",
+ "entrypoint": "classical-event-chart --event-type horary",
+ "judgment_status": horary.get("horary", {}).get("adjudication_matrix", {}).get("judgment_status", "blocked"),
+ "evidence": "radicality, perfection, prohibition, translation/collection, Moon bridge, Moon VOC",
+ },
+ {
+ "id": "electional_chain",
+ "status": "partial" if electional.get("status") == "guarded" else "blocked",
+ "entrypoint": "classical-event-chart --event-type electional",
+ "judgment_status": electional.get("electional", {}).get("adjudication_matrix", {}).get("judgment_status", "blocked"),
+ "evidence": "goal lord, Moon condition, malefic screen, topic weights, window search",
+ },
+ {
+ "id": "real_case_calibration",
+ "status": calibration.get("status", "blocked"),
+ "entrypoint": "classical-real-case-calibration",
+ "prediction_confidence": calibration.get("report_layers", {}).get("executive_summary", {}).get("prediction_confidence", "blocked"),
+ "evidence": "public replay manifest plus diagnostic negative-control boundary",
+ },
+ {
+ "id": "real_case_corpus_coverage",
+ "status": calibration_corpus.get("report_layers", {}).get("case_corpus_coverage", {}).get("status", "blocked"),
+ "entrypoint": "classical-real-case-calibration --manifest default",
+ "primary_case_count": calibration_corpus.get("summary", {}).get("case_corpus_coverage", {}).get("primary_case_count"),
+ "positive_holdout_case_count": calibration_corpus.get("summary", {}).get("case_corpus_coverage", {}).get("positive_holdout_case_count"),
+ "candidate_negative_window_count": calibration_corpus.get("summary", {}).get("case_corpus_coverage", {}).get("candidate_negative_window_count"),
+ "promotion_status": calibration_corpus.get("summary", {}).get("case_corpus_coverage", {}).get("promotion_status", "blocked"),
+ "evidence": "corpus coverage inventory across primary replay, positive holdout, and candidate negative windows",
+ },
+ ]
+ blocked_rows = [
+ {
+ "id": "zr_event_prominence_gate",
+ "status": zr_matrix.get("event_prominence_gate", {}).get("status", "blocked"),
+ "reason": zr_matrix.get("event_prominence_gate", {}).get("reason", "event prominence adjudication is not closed"),
+ },
+ {
+ "id": "horary_judgment",
+ "status": horary.get("horary", {}).get("adjudication_matrix", {}).get("judgment_status", "blocked"),
+ "reason": "Horary evidence is organized, but yes/no doctrine is not complete.",
+ },
+ {
+ "id": "electional_judgment",
+ "status": electional.get("electional", {}).get("adjudication_matrix", {}).get("judgment_status", "blocked"),
+ "reason": "Electional evidence is ranked, but approval/best-time doctrine is not complete.",
+ },
+ {
+ "id": "prediction_confidence",
+ "status": calibration.get("report_layers", {}).get("executive_summary", {}).get("prediction_confidence", "blocked"),
+ "reason": calibration.get("report_layers", {}).get("calibration_boundary", {}).get("reason", "blind validation gate is not closed"),
+ },
+ ]
+ return {
+ "system": "classical_astrology",
+ "layer": "usable_loop_audit",
+ "status": "usable_guarded",
+ "entrypoints": [
+ "classical-astrology",
+ "classical-event-chart",
+ "classical-real-case-calibration",
+ "classical-usable-loop-audit",
+ ],
+ "readiness_rows": readiness_rows,
+ "blocked_rows": blocked_rows,
+ "report_layers": {
+ "executive_summary": {
+ "status": "usable_guarded",
+ "summary": "The minimum classical astrology loop is executable and auditable across core calculation, ZR adjudication evidence, horary evidence, electional evidence, and real-case calibration boundary checks.",
+ "prediction_confidence": "blocked",
+ },
+ "readiness_table": readiness_rows,
+ "event_chain_summary": event_summary,
+ "closure_requirement_matrix": closure_matrix,
+ "doctrine_prediction_promotion_gate": promotion_gate,
+ "doctrine_verdict_readable_summary": promotion_gate["readable_summary"],
+ "doctrine_verdict_trace": promotion_gate["readable_summary"]["doctrine_trace"],
+ "doctrine_locus_coverage_gate": promotion_gate["readable_summary"]["doctrine_locus_coverage_gate"],
+ "doctrine_natural_language_summary": promotion_gate["readable_summary"]["natural_language_summary"],
+ "doctrine_explanation_panel": core["report_layers"]["doctrine_explanation_panel"],
+ "doctrine_gap_closure_panel": promotion_gate["doctrine_gap_closure_panel"],
+ "promotion_state_view": promotion_gate["promotion_state_view"],
+ "boundary_table": blocked_rows,
+ "evidence_paths": [
+ "scripts/classical_astrology.py",
+ "scripts/jyotish_engine.py",
+ "references/technique_registry.json",
+ "docs/research/classical_astrology_mvp_audit_2026_08_25.md",
+ ],
+ },
+ "audit": {
+ "techniques": {
+ "classical_usable_loop_audit": {
+ "status": "guarded",
+ "source": "core + horary + electional + calibration entrypoint replay",
+ },
+ "classical_astrology_core": {"status": readiness_rows[0]["status"]},
+ "zodiacal_release_adjudication": {"status": readiness_rows[1]["status"]},
+ "classical_horary_chain": {"status": readiness_rows[2]["status"]},
+ "classical_electional_chain": {"status": readiness_rows[3]["status"]},
+ "classical_event_chain_summary": {"status": event_summary["status"]},
+ "classical_closure_requirement_matrix": {"status": closure_matrix["status"]},
+ "classical_doctrine_prediction_promotion_gate": {
+ "status": promotion_gate["status"],
+ "promotion_allowed": promotion_gate["promotion_allowed"],
+ },
+ "classical_doctrine_gap_closure_panel": {
+ "status": promotion_gate["doctrine_gap_closure_panel"]["status"],
+ "row_count": promotion_gate["doctrine_gap_closure_panel"]["row_count"],
+ "promotion_allowed": promotion_gate["doctrine_gap_closure_panel"]["promotion_allowed"],
+ },
+ "classical_real_case_calibration": {"status": readiness_rows[4]["status"]},
+ "classical_case_corpus_coverage": {"status": readiness_rows[5]["status"]},
+ },
+ "boundary": "Usable guarded loop only; predictive promotion, horary answers, election approval, and best-time claims remain blocked.",
+ },
+ "artifacts": {
+ "core": core,
+ "horary": horary,
+ "electional": electional,
+ "real_case_calibration": calibration,
+ "real_case_corpus_calibration": calibration_corpus,
+ },
+ "boundary": "This packet proves the guarded classical lane can run end-to-end; it does not promote any predictive judgment.",
+ }
+
+
+def build_classical_blind_scoring_packet(
+ *,
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int = 0,
+ latitude: float,
+ longitude: float,
+ timezone: str | float,
+ house_system: str = "P",
+ reference_date: str | None = None,
+ event_window_hours: int = 4,
+ event_step_hours: int = 2,
+ manifest_path: Path | str | None = None,
+ output_manifest_path: Path | str | None = None,
+) -> dict[str, Any]:
+ source_manifest_path = Path(manifest_path) if manifest_path else DEFAULT_CLASSICAL_BLIND_VALIDATION_MANIFEST
+ manifest = _load_classical_blind_validation_manifest(
+ source_manifest_path
+ )
+ intake_quality = _classical_blind_sample_intake_quality(manifest)
+
+ def score_context_for_sample(sample: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
+ birth_data = sample.get("birth_data") if isinstance(sample.get("birth_data"), dict) else {}
+ sample_inputs = {
+ "year": int(birth_data.get("year", year)),
+ "month": int(birth_data.get("month", month)),
+ "day": int(birth_data.get("day", day)),
+ "hour": int(birth_data.get("hour", hour)),
+ "minute": int(birth_data.get("minute", minute)),
+ "second": int(birth_data.get("second", second)),
+ "latitude": float(birth_data.get("latitude", latitude)),
+ "longitude": float(birth_data.get("longitude", longitude)),
+ "timezone": birth_data.get("timezone", timezone),
+ "house_system": sample.get("house_system", house_system),
+ "reference_date": sample.get("reference_date") or reference_date,
+ "event_window_hours": int(sample.get("event_window_hours", event_window_hours)),
+ "event_step_hours": int(sample.get("event_step_hours", event_step_hours)),
+ }
+ sample_audit_packet = build_classical_usable_loop_audit_packet(**sample_inputs)
+ sample_assets = sample_audit_packet["report_layers"]["doctrine_prediction_promotion_gate"]["doctrine_assets"]
+ all_asset_scores = {
+ "zr_event_prominence_rule_table": _summarize_doctrine_asset(
+ "zr_event_prominence_rule_table",
+ sample_assets["zr_event_prominence_rule_table"],
+ ),
+ "horary_perfection_timing_thresholds": _summarize_doctrine_asset(
+ "horary_perfection_timing_thresholds",
+ sample_assets["horary_perfection_timing_thresholds"],
+ ),
+ "electional_goal_rule_tables": _summarize_doctrine_asset(
+ "electional_goal_rule_tables",
+ sample_assets["electional_goal_rule_tables"],
+ ),
+ }
+ technique_scope = sample.get("technique_scope")
+ if isinstance(technique_scope, list) and technique_scope:
+ sample_asset_scores = [
+ all_asset_scores[asset_id]
+ for asset_id in technique_scope
+ if asset_id in all_asset_scores
+ ]
+ else:
+ sample_asset_scores = list(all_asset_scores.values())
+ return sample_asset_scores, sample_audit_packet, sample_inputs
+
+ fallback_asset_scores, fallback_audit_packet, fallback_inputs = score_context_for_sample({})
+ audit_packet = fallback_audit_packet
+ score_rows = []
+ sample_context_rows = []
+ for sample in manifest.get("frozen_sample_queue", []):
+ if not isinstance(sample, dict):
+ continue
+ asset_scores, sample_audit_packet, sample_inputs = score_context_for_sample(sample)
+ used_sample_birth_data = isinstance(sample.get("birth_data"), dict)
+ casebooks_packet = sample.get("casebooks_packet") if isinstance(sample.get("casebooks_packet"), dict) else {}
+ score_rows.append({
+ "sample_id": sample.get("case_id"),
+ "domain": sample.get("domain"),
+ "sample_kind": sample.get("sample_kind"),
+ "sample_status": sample.get("sample_status"),
+ "reference_date": sample_inputs["reference_date"],
+ "used_sample_birth_data": used_sample_birth_data,
+ "technique_scope": sample.get("technique_scope") if isinstance(sample.get("technique_scope"), list) else None,
+ "source_refs": sample.get("source_refs") if isinstance(sample.get("source_refs"), list) else [],
+ "label_visibility": "withheld",
+ "expected_label": None,
+ "before_outcome_written": bool(casebooks_packet.get("before_outcome_written", True)),
+ "after_the_fact_only": bool(casebooks_packet.get("after_the_fact_only", False)),
+ "asset_scores": asset_scores,
+ "total_verdict_count": sum(row["verdict_count"] for row in asset_scores),
+ "total_blocking_verdict_count": sum(row["blocking_verdict_count"] for row in asset_scores),
+ "scoring_status": "scored_blind_container",
+ "promotion_status": "blocked",
+ })
+ sample_context_rows.append({
+ "sample_id": sample.get("case_id"),
+ "used_sample_birth_data": used_sample_birth_data,
+ "reference_date": sample_inputs["reference_date"],
+ "technique_scope": sample.get("technique_scope") if isinstance(sample.get("technique_scope"), list) else None,
+ "promotion_state_status": (
+ sample_audit_packet["report_layers"]["doctrine_prediction_promotion_gate"]["promotion_state_view"]["status"]
+ ),
+ "before_outcome_written": bool(casebooks_packet.get("before_outcome_written", True)),
+ "after_the_fact_only": bool(casebooks_packet.get("after_the_fact_only", False)),
+ "label_visibility": "withheld",
+ })
+ packet = {
+ "system": "classical_astrology",
+ "layer": "blind_scoring_runner",
+ "status": "partial" if score_rows else "blocked",
+ "rerun_manifest": {
+ "schema": "classical_blind_scoring_rerun_manifest.v1",
+ "source_manifest_path": str(source_manifest_path),
+ "inputs": {
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": hour,
+ "minute": minute,
+ "second": second,
+ "latitude": latitude,
+ "longitude": longitude,
+ "timezone": timezone,
+ "house_system": house_system,
+ "reference_date": reference_date,
+ "event_window_hours": event_window_hours,
+ "event_step_hours": event_step_hours,
+ },
+ "output_manifest_path": str(output_manifest_path) if output_manifest_path else None,
+ "label_visibility": "withheld",
+ "sample_context_policy": "use per-sample birth_data/reference_date when present; fallback to runner inputs only when absent",
+ },
+ "manifest": {
+ "path": manifest.get("path"),
+ "manifest_id": manifest.get("manifest_id"),
+ "frozen_sample_queue_count": manifest.get("frozen_sample_queue_count", 0),
+ "blind_label_count": manifest.get("blind_label_count", 0),
+ "intake_quality_status": intake_quality["status"],
+ "intake_quality_blocked_row_count": intake_quality["blocked_row_count"],
+ "promotion_status": manifest.get("promotion_status", "blocked"),
+ },
+ "score_rows": score_rows,
+ "score_row_count": len(score_rows),
+ "label_policy": {
+ "expected_labels_exposed": False,
+ "blind_frozen_label_count": manifest.get("blind_label_count", 0),
+ "boundary": "Runner scores frozen containers without exposing expected labels; no label adjudication or accuracy calculation is performed.",
+ },
+ "report_layers": {
+ "executive_summary": {
+ "status": "partial" if score_rows else "blocked",
+ "summary": f"Blind scoring runner scored {len(score_rows)} frozen sample containers without exposing labels.",
+ "prediction_confidence": "blocked",
+ },
+ "doctrine_verdict_readable_summary": audit_packet["report_layers"]["doctrine_prediction_promotion_gate"]["readable_summary"],
+ "sample_context_table": {
+ "schema": "classical.blind_sample_context_table.v1",
+ "status": "partial" if sample_context_rows else "blocked",
+ "row_count": len(sample_context_rows),
+ "rows": sample_context_rows,
+ "fallback_inputs": fallback_inputs,
+ "label_visibility": "withheld",
+ "promotion_allowed": False,
+ },
+ "blind_sample_intake_quality": intake_quality,
+ "blind_score_table": {
+ "status": "partial" if score_rows else "blocked",
+ "row_count": len(score_rows),
+ "rows": score_rows,
+ "promotion_status": "blocked",
+ },
+ },
+ "audit": {
+ "techniques": {
+ "classical_blind_scoring_runner": {
+ "status": "partial" if score_rows else "blocked",
+ "label_visibility": "withheld",
+ "promotion_status": "blocked",
+ },
+ "classical_blind_sample_intake_quality": {
+ "status": intake_quality["status"],
+ "blocked_row_count": intake_quality["blocked_row_count"],
+ "promotion_allowed": False,
+ },
+ "classical_doctrine_verdict_readable_summary": {
+ "status": audit_packet["report_layers"]["doctrine_prediction_promotion_gate"]["readable_summary"]["status"],
+ },
+ },
+ "boundary": "Blind scoring runner is an audit pipeline only; it does not calculate accuracy or promote prediction readiness.",
+ },
+ "boundary": "Scoring is blind-container only; labels remain withheld and promotion remains blocked.",
+ }
+ if output_manifest_path:
+ output_path = Path(output_manifest_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ packet["rerun_manifest"]["output_manifest_written"] = True
+ output_path.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ return packet
+
+
+def _valid_iso_timestamp(value: Any) -> bool:
+ if not isinstance(value, str) or not value:
+ return False
+ try:
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return False
+ return True
+
+
+def _reviewer_evidence_gate(rows: list[dict[str, Any]], reviewer: dict[str, Any]) -> dict[str, Any]:
+ labels = reviewer.get("labels") if isinstance(reviewer.get("labels"), list) else []
+ labels_by_sample = {row.get("sample_id"): row for row in labels if isinstance(row, dict)}
+ protocol = reviewer.get("required_protocol") if isinstance(reviewer.get("required_protocol"), dict) else {}
+ minimums = protocol.get("domain_minimum_sample_counts") if isinstance(protocol.get("domain_minimum_sample_counts"), dict) else {}
+ min_positive = int(minimums.get("positive", 1))
+ min_negative = int(minimums.get("negative", 1))
+ domain_counts: dict[str, dict[str, int]] = {}
+ checks = [
+ {
+ "id": "reviewer_source",
+ "status": "pass",
+ "blocked_sample_ids": [],
+ "requirement": "Each label must cite a non-seed reviewer source.",
+ },
+ {
+ "id": "freeze_timestamp",
+ "status": "pass",
+ "blocked_sample_ids": [],
+ "requirement": "Each label must have a valid ISO freeze timestamp.",
+ },
+ {
+ "id": "label_independence",
+ "status": "pass",
+ "blocked_sample_ids": [],
+ "requirement": "Each label must assert independence from the scoring run.",
+ },
+ {
+ "id": "before_outcome_written",
+ "status": "pass",
+ "blocked_sample_ids": [],
+ "requirement": "Each scored sample must be written before the outcome/follow-up is known.",
+ },
+ {
+ "id": "after_the_fact_only",
+ "status": "pass",
+ "blocked_sample_ids": [],
+ "requirement": "Regression-only rows must be marked after_the_fact_only and cannot satisfy promotion evidence.",
+ },
+ {
+ "id": "domain_minimum_sample_counts",
+ "status": "pass",
+ "blocked_domains": [],
+ "requirement": "Each scored domain needs minimum positive and negative samples.",
+ },
+ ]
+ for row in rows:
+ sample_id = row.get("sample_id")
+ domain = str(row.get("domain") or "unknown")
+ kind = str(row.get("sample_kind") or "")
+ label = labels_by_sample.get(sample_id, {})
+ source = label.get("reviewer_source") or label.get("label_source")
+ if not source or source == "reviewer_seed_contract":
+ checks[0]["blocked_sample_ids"].append(sample_id)
+ if not _valid_iso_timestamp(label.get("freeze_timestamp")):
+ checks[1]["blocked_sample_ids"].append(sample_id)
+ if label.get("label_independence") is not True:
+ checks[2]["blocked_sample_ids"].append(sample_id)
+ before_outcome_written = (
+ row.get("before_outcome_written")
+ if "before_outcome_written" in row
+ else label.get("before_outcome_written")
+ )
+ after_the_fact_only = (
+ row.get("after_the_fact_only")
+ if "after_the_fact_only" in row
+ else label.get("after_the_fact_only")
+ )
+ if before_outcome_written is not True:
+ checks[3]["blocked_sample_ids"].append(sample_id)
+ if after_the_fact_only is not False:
+ checks[4]["blocked_sample_ids"].append(sample_id)
+ bucket = domain_counts.setdefault(domain, {"positive": 0, "negative": 0})
+ if kind.startswith("positive"):
+ bucket["positive"] += 1
+ if kind.startswith("negative"):
+ bucket["negative"] += 1
+ if not rows:
+ checks[5]["blocked_domains"].append({
+ "domain": "none",
+ "positive_count": 0,
+ "negative_count": 0,
+ "required_positive": min_positive,
+ "required_negative": min_negative,
+ "reason": "no_scored_rows",
+ })
+ for check in checks[:5]:
+ if check["blocked_sample_ids"]:
+ check["status"] = "blocked"
+ for domain, counts in sorted(domain_counts.items()):
+ if counts["positive"] < min_positive or counts["negative"] < min_negative:
+ checks[5]["blocked_domains"].append({
+ "domain": domain,
+ "positive_count": counts["positive"],
+ "negative_count": counts["negative"],
+ "required_positive": min_positive,
+ "required_negative": min_negative,
+ })
+ if checks[5]["blocked_domains"]:
+ checks[5]["status"] = "blocked"
+ blocked_checks = [check for check in checks if check["status"] == "blocked"]
+ return {
+ "status": "blocked" if blocked_checks else "partial_review_ready",
+ "check_count": len(checks),
+ "blocked_check_count": len(blocked_checks),
+ "checks": checks,
+ "domain_counts": domain_counts,
+ "required_minimums": {"positive": min_positive, "negative": min_negative},
+ "promotion_status": "blocked",
+ "boundary": "Reviewer evidence can make statistics review-ready, but promotion remains blocked until a separate promotion gate is closed.",
+ }
+
+
+def _build_classical_unseal_statistics_shell(rows: list[dict[str, Any]], reviewer: dict[str, Any]) -> dict[str, Any]:
+ positive_rows = [
+ row for row in rows
+ if str(row.get("expected_label") or "").startswith("positive")
+ ]
+ negative_rows = [
+ row for row in rows
+ if str(row.get("expected_label") or "").startswith("negative")
+ ]
+ false_positive_candidates = [
+ row for row in negative_rows
+ if int(row.get("total_blocking_verdict_count") or 0) == 0
+ ]
+ false_negative_candidates = [
+ row for row in positive_rows
+ if int(row.get("total_blocking_verdict_count") or 0) > 0
+ ]
+ accuracy_denominator = len(rows)
+ pseudo_correct_count = max(0, accuracy_denominator - len(false_positive_candidates) - len(false_negative_candidates))
+ reviewer_gate = _reviewer_evidence_gate(rows, reviewer)
+ stat_status = reviewer_gate["status"]
+ accuracy = round(pseudo_correct_count / accuracy_denominator, 6) if accuracy_denominator else None
+ false_positive_rate = round(len(false_positive_candidates) / len(negative_rows), 6) if negative_rows else None
+ false_negative_rate = round(len(false_negative_candidates) / len(positive_rows), 6) if positive_rows else None
+ shell = {
+ "status": stat_status,
+ "promotion_status": "blocked",
+ "sample_count": len(rows),
+ "positive_sample_count": len(positive_rows),
+ "negative_sample_count": len(negative_rows),
+ "accuracy_shell": {
+ "status": stat_status,
+ "denominator": accuracy_denominator,
+ "pseudo_correct_count": pseudo_correct_count,
+ "pseudo_accuracy": accuracy,
+ "blocked_reason": None if stat_status == "partial_review_ready" else "Reviewer evidence gate is not closed.",
+ },
+ "false_positive_shell": {
+ "status": stat_status,
+ "negative_denominator": len(negative_rows),
+ "candidate_count": len(false_positive_candidates),
+ "candidate_rate": false_positive_rate,
+ "candidate_sample_ids": [row.get("sample_id") for row in false_positive_candidates],
+ "blocked_reason": None if stat_status == "partial_review_ready" else "False-positive statistics are structural only until independent reviewer evidence is complete.",
+ },
+ "false_negative_shell": {
+ "status": stat_status,
+ "positive_denominator": len(positive_rows),
+ "candidate_count": len(false_negative_candidates),
+ "candidate_rate": false_negative_rate,
+ "candidate_sample_ids": [row.get("sample_id") for row in false_negative_candidates],
+ "blocked_reason": None if stat_status == "partial_review_ready" else "Positive labels are seed-contract labels and cannot validate misses.",
+ },
+ "reviewer_evidence_gate": {
+ **reviewer_gate,
+ "reviewer_manifest_status": reviewer.get("status", "blocked"),
+ "reviewer_label_count": reviewer.get("label_count", 0),
+ "blockers": reviewer.get("blockers", []),
+ "required_to_promote": [
+ "independent reviewer identity or process note",
+ "frozen label timestamp before scoring",
+ "domain-specific acceptance thresholds",
+ "minimum positive and negative sample counts",
+ ],
+ },
+ "boundary": "Statistics are audit shells only; they do not establish accuracy, false-positive rate, or prediction readiness.",
+ }
+ shell["prediction_promotion_candidate_gate"] = _prediction_promotion_candidate_gate(shell)
+ return shell
+
+
+def _prediction_promotion_candidate_gate(statistics_shell: dict[str, Any]) -> dict[str, Any]:
+ thresholds = {
+ "minimum_total_sample_count": 30,
+ "minimum_positive_sample_count": 10,
+ "minimum_negative_sample_count": 10,
+ "maximum_false_positive_rate": 0.2,
+ "minimum_accuracy": 0.7,
+ "minimum_domain_count": 3,
+ }
+ domain_counts = statistics_shell.get("reviewer_evidence_gate", {}).get("domain_counts", {})
+ domain_count = len(domain_counts) if isinstance(domain_counts, dict) else 0
+ false_positive_rate = statistics_shell.get("false_positive_shell", {}).get("candidate_rate")
+ accuracy = statistics_shell.get("accuracy_shell", {}).get("pseudo_accuracy")
+ checks = [
+ {
+ "id": "review_ready_required",
+ "status": "pass" if statistics_shell.get("status") == "partial_review_ready" else "blocked",
+ "observed": statistics_shell.get("status"),
+ "required": "partial_review_ready",
+ },
+ {
+ "id": "independent_total_sample_count",
+ "status": "pass" if int(statistics_shell.get("sample_count") or 0) >= thresholds["minimum_total_sample_count"] else "blocked",
+ "observed": statistics_shell.get("sample_count"),
+ "required": thresholds["minimum_total_sample_count"],
+ },
+ {
+ "id": "independent_positive_sample_count",
+ "status": "pass" if int(statistics_shell.get("positive_sample_count") or 0) >= thresholds["minimum_positive_sample_count"] else "blocked",
+ "observed": statistics_shell.get("positive_sample_count"),
+ "required": thresholds["minimum_positive_sample_count"],
+ },
+ {
+ "id": "independent_negative_sample_count",
+ "status": "pass" if int(statistics_shell.get("negative_sample_count") or 0) >= thresholds["minimum_negative_sample_count"] else "blocked",
+ "observed": statistics_shell.get("negative_sample_count"),
+ "required": thresholds["minimum_negative_sample_count"],
+ },
+ {
+ "id": "false_positive_rate_ceiling",
+ "status": "pass" if isinstance(false_positive_rate, (int, float)) and false_positive_rate <= thresholds["maximum_false_positive_rate"] else "blocked",
+ "observed": false_positive_rate,
+ "required": thresholds["maximum_false_positive_rate"],
+ },
+ {
+ "id": "accuracy_floor",
+ "status": "pass" if isinstance(accuracy, (int, float)) and accuracy >= thresholds["minimum_accuracy"] else "blocked",
+ "observed": accuracy,
+ "required": thresholds["minimum_accuracy"],
+ },
+ {
+ "id": "domain_coverage",
+ "status": "pass" if domain_count >= thresholds["minimum_domain_count"] else "blocked",
+ "observed": domain_count,
+ "required": thresholds["minimum_domain_count"],
+ },
+ ]
+ blocked_checks = [check for check in checks if check["status"] == "blocked"]
+ return {
+ "status": "prediction_promotion_candidate" if not blocked_checks else "blocked",
+ "promotion_status": "candidate" if not blocked_checks else "blocked",
+ "thresholds": thresholds,
+ "check_count": len(checks),
+ "blocked_check_count": len(blocked_checks),
+ "checks": checks,
+ "blocked_checks": blocked_checks,
+ "boundary": "This gate can only nominate a prediction promotion candidate; final prediction-ready promotion requires a separate human/governance decision.",
+ }
+
+
+def _build_classical_prediction_calibration_diagnostics(rows: list[dict[str, Any]]) -> dict[str, Any]:
+ positive_rows = [
+ row for row in rows
+ if str(row.get("expected_label") or "").startswith("positive")
+ ]
+ negative_rows = [
+ row for row in rows
+ if str(row.get("expected_label") or "").startswith("negative")
+ ]
+ false_negative_like_rows = [
+ row for row in positive_rows
+ if int(row.get("total_blocking_verdict_count") or 0) > 0
+ ]
+ false_positive_like_rows = [
+ row for row in negative_rows
+ if int(row.get("total_blocking_verdict_count") or 0) == 0
+ ]
+ domains: dict[str, dict[str, int]] = {}
+ assets: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ domain = str(row.get("domain") or "unknown")
+ domain_bucket = domains.setdefault(domain, {
+ "positive_count": 0,
+ "negative_count": 0,
+ "false_negative_like_count": 0,
+ "false_positive_like_count": 0,
+ })
+ is_positive = row in positive_rows
+ is_negative = row in negative_rows
+ if is_positive:
+ domain_bucket["positive_count"] += 1
+ if row in false_negative_like_rows:
+ domain_bucket["false_negative_like_count"] += 1
+ if is_negative:
+ domain_bucket["negative_count"] += 1
+ if row in false_positive_like_rows:
+ domain_bucket["false_positive_like_count"] += 1
+ for asset in row.get("asset_scores", []) or []:
+ if not isinstance(asset, dict):
+ continue
+ asset_id = str(asset.get("asset_id") or "unknown_asset")
+ asset_bucket = assets.setdefault(asset_id, {
+ "positive_count": 0,
+ "negative_count": 0,
+ "positive_blocked_count": 0,
+ "negative_unblocked_count": 0,
+ "blocking_rule_counts": {},
+ })
+ asset_blocked = int(asset.get("blocking_verdict_count") or 0) > 0
+ if is_positive:
+ asset_bucket["positive_count"] += 1
+ if asset_blocked:
+ asset_bucket["positive_blocked_count"] += 1
+ if is_negative:
+ asset_bucket["negative_count"] += 1
+ if not asset_blocked:
+ asset_bucket["negative_unblocked_count"] += 1
+ for rule_id in asset.get("blocking_rule_ids", []) or []:
+ rule_key = str(rule_id)
+ asset_bucket["blocking_rule_counts"][rule_key] = asset_bucket["blocking_rule_counts"].get(rule_key, 0) + 1
+ asset_rows = []
+ for asset_id, bucket in sorted(assets.items()):
+ positive_count = bucket["positive_count"]
+ negative_count = bucket["negative_count"]
+ positive_blocked_ratio = round(bucket["positive_blocked_count"] / positive_count, 6) if positive_count else None
+ negative_unblocked_ratio = round(bucket["negative_unblocked_count"] / negative_count, 6) if negative_count else None
+ top_blocking_rules = [
+ {"rule_id": rule_id, "count": count}
+ for rule_id, count in sorted(
+ bucket["blocking_rule_counts"].items(),
+ key=lambda item: (-item[1], item[0]),
+ )[:8]
+ ]
+ asset_rows.append({
+ "asset_id": asset_id,
+ "positive_count": positive_count,
+ "negative_count": negative_count,
+ "positive_blocked_count": bucket["positive_blocked_count"],
+ "positive_blocked_ratio": positive_blocked_ratio,
+ "negative_unblocked_count": bucket["negative_unblocked_count"],
+ "negative_unblocked_ratio": negative_unblocked_ratio,
+ "top_blocking_rules": top_blocking_rules,
+ "diagnostic": (
+ "overblocking_positive_seed_rows"
+ if isinstance(positive_blocked_ratio, float) and positive_blocked_ratio >= 0.7
+ else "needs_more_case_replay"
+ ),
+ })
+ max_support = 0
+ for row in rows:
+ row_support = sum(
+ int(asset.get("supporting_verdict_count") or 0)
+ for asset in row.get("asset_scores", []) or []
+ if isinstance(asset, dict)
+ )
+ max_support = max(max_support, row_support)
+ threshold_sweep = []
+ for threshold in range(max_support + 1):
+ true_positive = true_negative = false_positive = false_negative = 0
+ for row in rows:
+ support_count = sum(
+ int(asset.get("supporting_verdict_count") or 0)
+ for asset in row.get("asset_scores", []) or []
+ if isinstance(asset, dict)
+ )
+ predicted_positive = support_count >= threshold
+ expected_positive = str(row.get("expected_label") or "").startswith("positive")
+ if predicted_positive and expected_positive:
+ true_positive += 1
+ elif predicted_positive and not expected_positive:
+ false_positive += 1
+ elif not predicted_positive and expected_positive:
+ false_negative += 1
+ else:
+ true_negative += 1
+ denominator = true_positive + true_negative + false_positive + false_negative
+ negative_denominator = true_negative + false_positive
+ positive_denominator = true_positive + false_negative
+ threshold_sweep.append({
+ "support_threshold": threshold,
+ "accuracy": round((true_positive + true_negative) / denominator, 6) if denominator else None,
+ "false_positive_rate": round(false_positive / negative_denominator, 6) if negative_denominator else None,
+ "false_negative_rate": round(false_negative / positive_denominator, 6) if positive_denominator else None,
+ "true_positive": true_positive,
+ "true_negative": true_negative,
+ "false_positive": false_positive,
+ "false_negative": false_negative,
+ })
+ component_scores = [
+ max(
+ [
+ float(asset.get("component_review_score"))
+ for asset in row.get("asset_scores", []) or []
+ if isinstance(asset, dict) and isinstance(asset.get("component_review_score"), (int, float))
+ ],
+ default=None,
+ )
+ for row in rows
+ ]
+ family_domain_buckets: dict[str, dict[str, dict[str, int]]] = {}
+ for row in rows:
+ domain = str(row.get("domain") or "unknown")
+ expected_positive = str(row.get("expected_label") or "").startswith("positive")
+ for asset in row.get("asset_scores", []) or []:
+ if not isinstance(asset, dict):
+ continue
+ scorecard = asset.get("component_scorecard") if isinstance(asset.get("component_scorecard"), dict) else {}
+ for component in scorecard.get("rows", []) or []:
+ if not isinstance(component, dict):
+ continue
+ family = str(component.get("family") or component.get("id") or "unknown_family")
+ bucket = family_domain_buckets.setdefault(family, {}).setdefault(domain, {
+ "true_positive": 0,
+ "true_negative": 0,
+ "false_positive": 0,
+ "false_negative": 0,
+ "positive_count": 0,
+ "negative_count": 0,
+ })
+ predicted_positive = component.get("support") is True
+ if expected_positive:
+ bucket["positive_count"] += 1
+ else:
+ bucket["negative_count"] += 1
+ if predicted_positive and expected_positive:
+ bucket["true_positive"] += 1
+ elif predicted_positive and not expected_positive:
+ bucket["false_positive"] += 1
+ elif not predicted_positive and expected_positive:
+ bucket["false_negative"] += 1
+ else:
+ bucket["true_negative"] += 1
+ family_domain_rows = []
+ family_weight_rows = []
+ for family, domain_map in sorted(family_domain_buckets.items()):
+ total_tp = total_tn = total_fp = total_fn = 0
+ domain_rows = []
+ for domain, bucket in sorted(domain_map.items()):
+ denominator = bucket["true_positive"] + bucket["true_negative"] + bucket["false_positive"] + bucket["false_negative"]
+ negative_denominator = bucket["true_negative"] + bucket["false_positive"]
+ positive_denominator = bucket["true_positive"] + bucket["false_negative"]
+ row = {
+ "family": family,
+ "domain": domain,
+ **bucket,
+ "accuracy": round((bucket["true_positive"] + bucket["true_negative"]) / denominator, 6) if denominator else None,
+ "false_positive_rate": round(bucket["false_positive"] / negative_denominator, 6) if negative_denominator else None,
+ "false_negative_rate": round(bucket["false_negative"] / positive_denominator, 6) if positive_denominator else None,
+ }
+ domain_rows.append(row)
+ family_domain_rows.append(row)
+ total_tp += bucket["true_positive"]
+ total_tn += bucket["true_negative"]
+ total_fp += bucket["false_positive"]
+ total_fn += bucket["false_negative"]
+ total_denominator = total_tp + total_tn + total_fp + total_fn
+ total_negative = total_tn + total_fp
+ total_positive = total_tp + total_fn
+ accuracy = round((total_tp + total_tn) / total_denominator, 6) if total_denominator else None
+ false_positive_rate = round(total_fp / total_negative, 6) if total_negative else None
+ false_negative_rate = round(total_fn / total_positive, 6) if total_positive else None
+ if isinstance(false_positive_rate, float) and false_positive_rate <= 0.2 and isinstance(accuracy, float) and accuracy >= 0.6:
+ recommendation = "candidate_positive_weight"
+ suggested_weight = 1.0
+ elif isinstance(false_positive_rate, float) and false_positive_rate <= 0.2:
+ recommendation = "negative_control_safe_but_low_recall"
+ suggested_weight = 0.5
+ elif isinstance(false_positive_rate, float) and false_positive_rate >= 0.5:
+ recommendation = "downweight_until_domain_calibrated"
+ suggested_weight = 0.0
+ else:
+ recommendation = "partial_weight_requires_more_samples"
+ suggested_weight = 0.25
+ family_weight_rows.append({
+ "family": family,
+ "sample_count": total_denominator,
+ "true_positive": total_tp,
+ "true_negative": total_tn,
+ "false_positive": total_fp,
+ "false_negative": total_fn,
+ "accuracy": accuracy,
+ "false_positive_rate": false_positive_rate,
+ "false_negative_rate": false_negative_rate,
+ "domain_rows": domain_rows,
+ "suggested_relative_weight": suggested_weight,
+ "recommendation": recommendation,
+ "promotion_allowed": False,
+ })
+ component_threshold_sweep = []
+ for threshold in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]:
+ true_positive = true_negative = false_positive = false_negative = 0
+ for row, score in zip(rows, component_scores):
+ predicted_positive = isinstance(score, float) and score >= threshold
+ expected_positive = str(row.get("expected_label") or "").startswith("positive")
+ if predicted_positive and expected_positive:
+ true_positive += 1
+ elif predicted_positive and not expected_positive:
+ false_positive += 1
+ elif not predicted_positive and expected_positive:
+ false_negative += 1
+ else:
+ true_negative += 1
+ denominator = true_positive + true_negative + false_positive + false_negative
+ negative_denominator = true_negative + false_positive
+ positive_denominator = true_positive + false_negative
+ component_threshold_sweep.append({
+ "component_score_threshold": threshold,
+ "accuracy": round((true_positive + true_negative) / denominator, 6) if denominator else None,
+ "false_positive_rate": round(false_positive / negative_denominator, 6) if negative_denominator else None,
+ "false_negative_rate": round(false_negative / positive_denominator, 6) if positive_denominator else None,
+ "true_positive": true_positive,
+ "true_negative": true_negative,
+ "false_positive": false_positive,
+ "false_negative": false_negative,
+ })
+ best_threshold = max(
+ threshold_sweep,
+ key=lambda row: (
+ row["accuracy"] if isinstance(row.get("accuracy"), float) else -1,
+ -row["false_positive_rate"] if isinstance(row.get("false_positive_rate"), float) else -1,
+ ),
+ default=None,
+ )
+ return {
+ "schema": "classical.prediction_calibration_diagnostics.v1",
+ "status": "partial_review_ready" if rows else "blocked",
+ "row_count": len(rows),
+ "positive_count": len(positive_rows),
+ "negative_count": len(negative_rows),
+ "false_negative_like_count": len(false_negative_like_rows),
+ "false_positive_like_count": len(false_positive_like_rows),
+ "domain_diagnostics": domains,
+ "asset_diagnostics": asset_rows,
+ "support_threshold_sweep": {
+ "status": "partial_review_ready" if threshold_sweep else "blocked",
+ "rows": threshold_sweep,
+ "best_threshold": best_threshold,
+ "promotion_allowed": False,
+ "boundary": "Threshold sweep is reviewer-only diagnostics. It cannot be used to tune promotion thresholds without independent out-of-sample replay.",
+ },
+ "component_score_threshold_sweep": {
+ "status": "partial_review_ready" if component_threshold_sweep else "blocked",
+ "rows": component_threshold_sweep,
+ "best_threshold": max(
+ component_threshold_sweep,
+ key=lambda row: (
+ row["accuracy"] if isinstance(row.get("accuracy"), float) else -1,
+ -row["false_positive_rate"] if isinstance(row.get("false_positive_rate"), float) else -1,
+ ),
+ default=None,
+ ),
+ "promotion_allowed": False,
+ "boundary": "Component score sweep is diagnostic only and requires independent out-of-sample confirmation before promotion use.",
+ },
+ "family_domain_confusion_matrix": {
+ "schema": "classical.zr_family_domain_confusion_matrix.v1",
+ "status": "partial_review_ready" if family_domain_rows else "blocked",
+ "row_count": len(family_domain_rows),
+ "rows": family_domain_rows,
+ "promotion_allowed": False,
+ "boundary": "Family/domain confusion rows are reviewer-only diagnostics. They cannot promote ZR without independent holdout confirmation.",
+ },
+ "domain_weight_calibration_preview": {
+ "schema": "classical.zr_domain_weight_calibration_preview.v1",
+ "status": "partial_review_ready" if family_weight_rows else "blocked",
+ "row_count": len(family_weight_rows),
+ "rows": family_weight_rows,
+ "promotion_allowed": False,
+ "boundary": "Suggested weights are diagnostic priors only; production promotion gates must remain unchanged until out-of-sample validation passes.",
+ },
+ "reader_summary": {
+ "zh": (
+ "解盲后诊断显示,当前 public seed 主要问题不是误把负样本放行,"
+ "而是在正样本上过度 blocked;因此不能靠样本量直接晋级 prediction-ready。"
+ ),
+ "en": (
+ "Post-unseal diagnostics show the current public seed issue is mainly "
+ "over-blocking positive cases, not false-positive release. More doctrine "
+ "adjudication and independent replay are required before promotion."
+ ),
+ },
+ "promotion_allowed": False,
+ "boundary": "Diagnostics run only after reviewer unseal; they do not expose labels to scoring and cannot promote prediction readiness.",
+ }
+
+
+def build_classical_blind_unseal_packet(
+ *,
+ scoring_manifest_path: Path | str,
+ reviewer_manifest_path: Path | str | None = None,
+ diagnostics_output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ scoring_path = Path(scoring_manifest_path)
+ if not scoring_path.exists():
+ return {
+ "system": "classical_astrology",
+ "layer": "blind_unseal",
+ "schema": "classical.blind_unseal.v1",
+ "status": "blocked",
+ "reason": "scoring manifest is missing",
+ "scoring_manifest_path": str(scoring_path),
+ "promotion_allowed": False,
+ }
+ scoring = json.loads(scoring_path.read_text(encoding="utf-8"))
+ reviewer = _load_classical_reviewer_unseal_manifest(
+ Path(reviewer_manifest_path) if reviewer_manifest_path else DEFAULT_CLASSICAL_REVIEWER_UNSEAL_MANIFEST
+ )
+ labels_by_sample = {row["sample_id"]: row for row in reviewer.get("labels", []) if isinstance(row, dict)}
+ rows = []
+ missing_labels = []
+ for score in scoring.get("score_rows", []):
+ if not isinstance(score, dict):
+ continue
+ sample_id = score.get("sample_id")
+ label = labels_by_sample.get(sample_id)
+ if not label:
+ missing_labels.append(sample_id)
+ continue
+ rows.append({
+ "sample_id": sample_id,
+ "domain": score.get("domain"),
+ "sample_kind": score.get("sample_kind"),
+ "expected_label": label.get("expected_label"),
+ "label_source": label.get("label_source"),
+ "scoring_status": score.get("scoring_status"),
+ "asset_scores": score.get("asset_scores", []),
+ "total_verdict_count": score.get("total_verdict_count"),
+ "total_blocking_verdict_count": score.get("total_blocking_verdict_count"),
+ "before_outcome_written": score.get("before_outcome_written"),
+ "after_the_fact_only": score.get("after_the_fact_only"),
+ "promotion_status": "blocked",
+ })
+ statistics_shell = _build_classical_unseal_statistics_shell(rows, reviewer)
+ prediction_promotion_candidate_gate = statistics_shell.get("prediction_promotion_candidate_gate", {})
+ calibration_diagnostics = _build_classical_prediction_calibration_diagnostics(rows)
+ packet = {
+ "system": "classical_astrology",
+ "layer": "blind_unseal",
+ "schema": "classical.blind_unseal.v1",
+ "status": "partial" if rows else "blocked",
+ "scoring_manifest_path": str(scoring_path),
+ "reviewer_manifest": {
+ "path": reviewer.get("path"),
+ "manifest_id": reviewer.get("manifest_id"),
+ "label_count": reviewer.get("label_count", 0),
+ "promotion_status": reviewer.get("promotion_status", "blocked"),
+ },
+ "unsealed_rows": rows,
+ "unsealed_row_count": len(rows),
+ "missing_label_sample_ids": missing_labels,
+ "statistics_shell": statistics_shell,
+ "prediction_promotion_candidate_gate": prediction_promotion_candidate_gate,
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "separation_policy": {
+ "scoring_manifest_precomputed": True,
+ "scoring_recomputed_during_unseal": False,
+ "labels_read_by_scoring_runner": False,
+ "boundary": "Unseal only joins precomputed withheld scores to reviewer labels; it must not recompute doctrine verdicts.",
+ },
+ "report_layers": {
+ "executive_summary": {
+ "status": "partial" if rows else "blocked",
+ "summary": f"Unsealed {len(rows)} scored blind containers using an independent reviewer manifest.",
+ "prediction_confidence": "blocked",
+ },
+ "unseal_table": {
+ "status": "partial" if rows else "blocked",
+ "row_count": len(rows),
+ "rows": rows,
+ "promotion_status": "blocked",
+ },
+ "accuracy_false_positive_shell": statistics_shell,
+ "prediction_promotion_candidate_gate": prediction_promotion_candidate_gate,
+ "prediction_calibration_diagnostics": calibration_diagnostics,
+ },
+ "audit": {
+ "techniques": {
+ "classical_blind_unseal": {
+ "status": "partial" if rows else "blocked",
+ "scoring_recomputed_during_unseal": False,
+ "promotion_status": "blocked",
+ },
+ "classical_unseal_accuracy_false_positive_shell": {
+ "status": statistics_shell["status"],
+ "promotion_status": statistics_shell["promotion_status"],
+ },
+ "classical_prediction_calibration_diagnostics": {
+ "status": calibration_diagnostics["status"],
+ "promotion_allowed": calibration_diagnostics["promotion_allowed"],
+ },
+ },
+ "boundary": "Unseal is a reviewer-only step and does not promote prediction readiness.",
+ },
+ "boundary": "Labels are exposed only after scoring manifest exists; no accuracy or promotion is inferred.",
+ }
+ if diagnostics_output_path:
+ output_path = Path(diagnostics_output_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ diagnostics_packet = {
+ "system": "classical_astrology",
+ "schema": "classical.prediction_calibration_diagnostics_export.v1",
+ "source_scoring_manifest_path": str(scoring_path),
+ "source_reviewer_manifest_path": str(reviewer_manifest_path) if reviewer_manifest_path else str(DEFAULT_CLASSICAL_REVIEWER_UNSEAL_MANIFEST),
+ "label_visibility": "reviewer_unsealed_only",
+ "prediction_calibration_diagnostics": calibration_diagnostics,
+ "prediction_promotion_candidate_gate": prediction_promotion_candidate_gate,
+ "promotion_allowed": False,
+ "boundary": "Exported diagnostics are reviewer/debug assets only; they do not expose labels to blind scoring and do not promote prediction readiness.",
+ }
+ output_path.write_text(json.dumps(diagnostics_packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["report_layers"]["prediction_calibration_diagnostics_export"] = {
+ "status": "written",
+ "path": str(output_path),
+ "promotion_allowed": False,
+ }
+ return packet
+
+
+def _stable_split_bucket(sample_id: str, seed: int) -> int:
+ digest = hashlib.sha256(f"{seed}:{sample_id}".encode("utf-8")).hexdigest()
+ return int(digest[:8], 16) % 100
+
+
+def _extract_zr_feature_vector(score_row: dict[str, Any]) -> dict[str, Any]:
+ vector: dict[str, Any] = {
+ "schema": "classical.zr_feature_vector.v1",
+ "sample_id": score_row.get("sample_id"),
+ "domain": score_row.get("domain"),
+ "sample_kind": score_row.get("sample_kind"),
+ "technique_scope": score_row.get("technique_scope"),
+ }
+ for asset in score_row.get("asset_scores", []) or []:
+ if not isinstance(asset, dict) or asset.get("asset_id") != "zr_event_prominence_rule_table":
+ continue
+ vector["zr.total_verdict_count"] = asset.get("verdict_count")
+ vector["zr.blocking_verdict_count"] = asset.get("blocking_verdict_count")
+ vector["zr.supporting_verdict_count"] = asset.get("supporting_verdict_count")
+ vector["zr.component_review_score"] = asset.get("component_review_score")
+ scorecard = asset.get("component_scorecard") if isinstance(asset.get("component_scorecard"), dict) else {}
+ for component in scorecard.get("rows", []) or []:
+ if not isinstance(component, dict):
+ continue
+ family = component.get("family") or component.get("id")
+ if not family:
+ continue
+ prefix = f"zr.{family}"
+ vector[f"{prefix}.support"] = component.get("support") is True
+ vector[f"{prefix}.status"] = component.get("status")
+ vector[f"{prefix}.weight"] = component.get("weight")
+ vector[f"{prefix}.raw_input"] = component.get("raw_input")
+ return vector
+
+
+def _classical_public_sample_lineage_groups(sample_id: str, domain: str | None = None) -> dict[str, Any]:
+ cleaned = sample_id.removeprefix("public_")
+ tokens = cleaned.split("_")
+ year = next((token for token in tokens if re.fullmatch(r"\d{4}", token)), None)
+ subject_tokens = []
+ for token in tokens:
+ if re.fullmatch(r"\d{4}", token):
+ break
+ subject_tokens.append(token)
+ subject_group_id = "_".join(subject_tokens) if subject_tokens else sample_id
+ event_group_id = None
+ paired_negative_group_id = None
+ if year and "election" in tokens:
+ event_group_id = f"career_election_{year}"
+ paired_negative_group_id = event_group_id
+ elif year and any(token in tokens for token in ("marriage", "divorce")):
+ event_group_id = f"relationship_{year}_{'divorce' if 'divorce' in tokens else 'marriage'}"
+ if "brad" in tokens or "angelina" in tokens:
+ paired_negative_group_id = "relationship_brad_angelina"
+ elif year and any(token in tokens for token in ("death", "survived", "shooting")):
+ event_group_id = f"health_{year}_{'survival_or_death'}"
+ return {
+ "subject_group_id": subject_group_id,
+ "event_group_id": event_group_id,
+ "paired_negative_group_id": paired_negative_group_id,
+ "domain_group_id": domain or "unknown",
+ }
+
+
+def _build_classical_zr_split_leakage_checks(feature_rows: list[dict[str, Any]]) -> dict[str, Any]:
+ checks = []
+ for group_field, label in (
+ ("subject_group_id", "same_person_or_subject_group_screen"),
+ ("event_group_id", "same_event_family_screen"),
+ ("paired_negative_group_id", "paired_winner_loser_or_relationship_group_screen"),
+ ):
+ groups: dict[str, dict[str, Any]] = {}
+ for row in feature_rows:
+ lineage = row.get("lineage_groups") if isinstance(row.get("lineage_groups"), dict) else {}
+ group_id = lineage.get(group_field)
+ if not group_id:
+ continue
+ bucket = groups.setdefault(str(group_id), {"splits": set(), "sample_ids": []})
+ bucket["splits"].add(row.get("split"))
+ bucket["sample_ids"].append(row.get("sample_id"))
+ blocked_groups = [
+ {
+ "group_id": group_id,
+ "splits": sorted(bucket["splits"]),
+ "sample_ids": bucket["sample_ids"],
+ }
+ for group_id, bucket in sorted(groups.items())
+ if len(bucket["splits"]) > 1
+ ]
+ checks.append({
+ "id": label,
+ "status": "blocked" if blocked_groups else "pass",
+ "blocked_groups": blocked_groups,
+ "promotion_allowed": False,
+ })
+ blocked_checks = [check for check in checks if check["status"] == "blocked"]
+ return {
+ "status": "blocked" if blocked_checks else "partial",
+ "check_count": len(checks),
+ "blocked_check_count": len(blocked_checks),
+ "checks": checks,
+ "promotion_allowed": False,
+ "boundary": "Leakage checks are heuristic seed-split safeguards. Any blocked group prevents use as promotion evidence.",
+ }
+
+
+def build_classical_zr_calibration_split_packet(
+ *,
+ scoring_manifest_path: Path | str,
+ reviewer_manifest_path: Path | str,
+ output_path: Path | str | None = None,
+ split_seed: int = 20260827,
+) -> dict[str, Any]:
+ scoring_path = Path(scoring_manifest_path)
+ reviewer_path = Path(reviewer_manifest_path)
+ if not scoring_path.exists() or not reviewer_path.exists():
+ return {
+ "system": "classical_astrology",
+ "schema": "classical.zr_calibration_split_packet.v1",
+ "status": "blocked",
+ "reason": "scoring or reviewer manifest is missing",
+ "scoring_manifest_path": str(scoring_path),
+ "reviewer_manifest_path": str(reviewer_path),
+ "promotion_allowed": False,
+ }
+ scoring = json.loads(scoring_path.read_text(encoding="utf-8"))
+ reviewer = json.loads(reviewer_path.read_text(encoding="utf-8"))
+ labels_by_sample = {
+ row.get("sample_id"): row
+ for row in reviewer.get("labels", [])
+ if isinstance(row, dict)
+ }
+ split_contract = {
+ "split_id": f"zr_public_seed_split_{split_seed}",
+ "split_version": "v1",
+ "split_seed": split_seed,
+ "split_strategy": "stable_hash_by_sample_id_60_20_20",
+ "train_case_ids": [],
+ "validation_case_ids": [],
+ "holdout_case_ids": [],
+ "leakage_checks": {},
+ }
+ feature_rows = []
+ lineage_rows = []
+ split_counts: dict[str, int] = {"train": 0, "validation": 0, "holdout": 0}
+ for score in scoring.get("score_rows", []) or []:
+ if not isinstance(score, dict):
+ continue
+ sample_id = str(score.get("sample_id") or "")
+ if not sample_id.startswith("public_"):
+ continue
+ label = labels_by_sample.get(sample_id)
+ if not isinstance(label, dict):
+ continue
+ bucket = _stable_split_bucket(sample_id, split_seed)
+ split_name = "train" if bucket < 60 else "validation" if bucket < 80 else "holdout"
+ split_counts[split_name] += 1
+ split_contract[f"{split_name}_case_ids"].append(sample_id)
+ feature_vector = _extract_zr_feature_vector(score)
+ lineage_groups = _classical_public_sample_lineage_groups(sample_id, score.get("domain"))
+ feature_rows.append({
+ "sample_id": sample_id,
+ "split": split_name,
+ "domain": score.get("domain"),
+ "sample_kind": score.get("sample_kind"),
+ "expected_label_visible_to_scoring": False,
+ "expected_label": label.get("expected_label"),
+ "lineage_groups": lineage_groups,
+ "zr_feature_vector": feature_vector,
+ "promotion_allowed": False,
+ })
+ lineage_rows.append({
+ "sample_id": sample_id,
+ "source_packet_path": scoring.get("manifest", {}).get("path"),
+ "source_scoring_manifest_path": str(scoring_path),
+ "source_reviewer_manifest_path": str(reviewer_path),
+ "source_case_id": sample_id,
+ "source_refs": score.get("source_refs", []),
+ "subject_group_id": lineage_groups.get("subject_group_id"),
+ "event_group_id": lineage_groups.get("event_group_id"),
+ "paired_negative_group_id": lineage_groups.get("paired_negative_group_id"),
+ "public_source_grade": "seed_public_source",
+ "birth_time_accuracy": "public_source_unverified_in_this_packet",
+ "label_freeze_timestamp": label.get("freeze_timestamp"),
+ "scoring_timestamp": scoring.get("rerun_manifest", {}).get("output_manifest_path"),
+ "promotion_allowed": False,
+ })
+ leakage_checks = _build_classical_zr_split_leakage_checks(feature_rows)
+ split_contract["leakage_checks"] = leakage_checks
+ diagnostics = _build_classical_prediction_calibration_diagnostics([
+ {
+ "sample_id": row["sample_id"],
+ "domain": row["domain"],
+ "sample_kind": row["sample_kind"],
+ "expected_label": row["expected_label"],
+ "asset_scores": [
+ asset for asset in scoring_row.get("asset_scores", [])
+ if isinstance(asset, dict) and asset.get("asset_id") == "zr_event_prominence_rule_table"
+ ],
+ "total_blocking_verdict_count": next(
+ (
+ int(asset.get("blocking_verdict_count") or 0)
+ for asset in scoring_row.get("asset_scores", [])
+ if isinstance(asset, dict) and asset.get("asset_id") == "zr_event_prominence_rule_table"
+ ),
+ 0,
+ ),
+ }
+ for row in feature_rows
+ for scoring_row in scoring.get("score_rows", []) or []
+ if scoring_row.get("sample_id") == row["sample_id"]
+ ])
+ packet = {
+ "system": "classical_astrology",
+ "schema": "classical.zr_calibration_split_packet.v1",
+ "status": "partial",
+ "split_contract": split_contract,
+ "sample_lineage": {
+ "schema": "classical.zr_sample_lineage.v1",
+ "row_count": len(lineage_rows),
+ "rows": lineage_rows,
+ },
+ "calibration_version": {
+ "rule_table_version": "classical_zr_event_prominence_rule_table.v1",
+ "feature_schema_version": "classical.zr_feature_vector.v1",
+ "weight_model_version": "diagnostic_preview_unlocked_v1",
+ "trained_on_split_id": split_contract["split_id"],
+ "eligible_for_gate": False,
+ },
+ "feature_rows": feature_rows,
+ "feature_row_count": len(feature_rows),
+ "family_domain_metrics": diagnostics.get("family_domain_confusion_matrix"),
+ "weight_calibration_result": {
+ "schema": "classical.zr_weight_calibration_result.v1",
+ "method": "diagnostic_family_domain_preview",
+ "objective": "separate false-positive risk from positive over-blocking",
+ "regularization": "manual_locked_until_holdout",
+ "class_balance_policy": "reported_not_reweighted",
+ "family_weights": diagnostics.get("domain_weight_calibration_preview", {}).get("rows", []),
+ "thresholds_by_domain": [],
+ "locked": False,
+ "eligible_for_gate": False,
+ "promotion_allowed": False,
+ },
+ "promotion_gate_eligibility": {
+ "eligible": False,
+ "blocked_reasons": [
+ "public seed source is not final independent holdout",
+ "leakage checks are blocked or seed-only",
+ "weights are diagnostic preview only",
+ "production promotion gate remains blocked",
+ ],
+ "required_external_holdout_split": True,
+ "human_governance_required": True,
+ },
+ "promotion_allowed": False,
+ "boundary": "ZR split packet is a calibration/debug artifact only. It must not be used as production promotion evidence.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def _classical_artifact_entry(path: Path) -> dict[str, Any]:
+ try:
+ stat = path.stat()
+ raw = path.read_bytes()
+ digest = hashlib.sha256(raw).hexdigest()
+ except OSError as exc:
+ return {
+ "path": str(path),
+ "status": "blocked",
+ "reason": f"artifact_unreadable:{type(exc).__name__}",
+ }
+ json_status = "not_json"
+ schema = None
+ artifact_status = None
+ promotion_allowed = None
+ label_leakage_risk = "not_scoring_manifest"
+ try:
+ data = json.loads(raw.decode("utf-8"))
+ json_status = "valid_json"
+ if isinstance(data, dict):
+ schema = data.get("schema")
+ artifact_status = data.get("status") or data.get("promotion_status") or data.get("claim_status")
+ promotion_allowed = data.get("promotion_allowed")
+ text = json.dumps(data, ensure_ascii=False)
+ if "\"expected_label\"" in text and "scoring_manifest" in path.name:
+ label_leakage_risk = "review_required_expected_label_key_in_scoring_named_artifact"
+ elif "\"expected_label\"" in text:
+ label_leakage_risk = "expected_label_key_present_non_scoring_artifact"
+ else:
+ label_leakage_risk = "no_expected_label_key"
+ except Exception:
+ json_status = "invalid_json"
+ return {
+ "path": str(path),
+ "status": "present",
+ "bytes": stat.st_size,
+ "sha256": digest,
+ "json_status": json_status,
+ "schema": schema,
+ "artifact_status": artifact_status,
+ "promotion_allowed": promotion_allowed,
+ "label_leakage_risk": label_leakage_risk,
+ "label_visibility": "unknown_or_withheld",
+ }
+
+
+def build_classical_artifact_registry_packet(
+ *,
+ root: Path | str = Path("references/real_case_calibration"),
+ output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ root_path = Path(root)
+ entries = []
+ if root_path.exists():
+ entries = [
+ _classical_artifact_entry(path)
+ for path in sorted(root_path.glob("classical_*.json"))
+ if path.is_file()
+ ]
+ families: dict[str, int] = {}
+ status_counts: dict[str, int] = {}
+ schema_counts: dict[str, int] = {}
+ leakage_risk_counts: dict[str, int] = {}
+ for entry in entries:
+ name = Path(str(entry.get("path", ""))).name
+ family = "classical"
+ for candidate in ("horary", "electional", "zr", "public", "reviewer", "ocr"):
+ if candidate in name:
+ family = candidate
+ break
+ families[family] = families.get(family, 0) + 1
+ status_key = str(entry.get("artifact_status") or entry.get("json_status") or "unknown")
+ schema_key = str(entry.get("schema") or "schema_missing")
+ leakage_key = str(entry.get("label_leakage_risk") or "unknown")
+ status_counts[status_key] = status_counts.get(status_key, 0) + 1
+ schema_counts[schema_key] = schema_counts.get(schema_key, 0) + 1
+ leakage_risk_counts[leakage_key] = leakage_risk_counts.get(leakage_key, 0) + 1
+ expected_blocked_artifact_count = sum(
+ 1
+ for entry in entries
+ if str(entry.get("artifact_status") or "").startswith("blocked")
+ )
+ invalid_or_unreadable_artifact_count = sum(
+ 1
+ for entry in entries
+ if entry.get("json_status") == "invalid_json" or entry.get("status") == "blocked"
+ )
+ blocked_or_invalid_count = sum(
+ 1
+ for entry in entries
+ if entry.get("json_status") == "invalid_json" or str(entry.get("artifact_status") or "").startswith("blocked")
+ )
+ label_leakage_review_count = sum(
+ 1
+ for entry in entries
+ if str(entry.get("label_leakage_risk") or "").startswith("review_required")
+ )
+ packet = {
+ "schema": "classical.artifact_registry.v1",
+ "status": "ready" if entries else "blocked",
+ "root": str(root_path),
+ "artifact_count": len(entries),
+ "families": families,
+ "status_counts": status_counts,
+ "schema_counts": schema_counts,
+ "label_leakage_risk_counts": leakage_risk_counts,
+ "expected_blocked_artifact_count": expected_blocked_artifact_count,
+ "invalid_or_unreadable_artifact_count": invalid_or_unreadable_artifact_count,
+ "blocked_or_invalid_count": blocked_or_invalid_count,
+ "label_leakage_review_count": label_leakage_review_count,
+ "entries": entries,
+ "boundary": "Registry records artifact presence and hashes only; it does not reveal blind labels or promote prediction claims.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def _classical_technique_card(card_id: str, family: str, status: str, evidence: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "card_id": card_id,
+ "family": family,
+ "status": status,
+ "doctrine_scope": evidence.get("doctrine_scope", family),
+ "source_basis": evidence.get("source_basis", "local_rule_loci_and_runtime_audit"),
+ "execution_basis": evidence.get("execution_basis", "registered_cli_or_report_layer"),
+ "audit_fields": evidence.get("audit_fields", []),
+ "blocked_or_partial_reason": evidence.get("blocked_or_partial_reason", ""),
+ "allowed_claim": evidence.get("allowed_claim", "review-only doctrine support"),
+ "forbidden_claim": evidence.get("forbidden_claim", "prediction-ready final outcome"),
+ "promotion_allowed": False,
+ }
+
+
+def build_classical_productization_shell_packet(
+ *,
+ output_path: Path | str | None = None,
+ artifact_registry_output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ horary_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_HORARY_ACQUISITION_RUNNER_PACKET, "horary")
+ electional_runner = _load_classical_case_acquisition_runner_summary(DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_RUNNER_PACKET, "electional")
+ horary_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE, "horary")
+ electional_bundle = _load_classical_domain_validation_bundle_summary(DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE, "electional")
+ remaining = _load_classical_remaining_task_register(
+ horary_runner=horary_runner,
+ electional_runner=electional_runner,
+ horary_bundle=horary_bundle,
+ electional_bundle=electional_bundle,
+ )
+ horary_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_HORARY_FREEZE_CHECKLIST, "horary")
+ electional_freeze_checklist = _load_classical_freeze_checklist_summary(DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_CHECKLIST, "electional")
+ freeze_checklist_summaries = {
+ "horary": horary_freeze_checklist,
+ "electional": electional_freeze_checklist,
+ }
+ freeze_top_missing: dict[str, int] = {}
+ for checklist in freeze_checklist_summaries.values():
+ for row in checklist.get("missing_field_summary", []) if isinstance(checklist.get("missing_field_summary"), list) else []:
+ field = str(row.get("field") or "")
+ if field:
+ freeze_top_missing[field] = freeze_top_missing.get(field, 0) + int(row.get("missing_item_count") or 0)
+ freeze_checklist_summary = {
+ "schema": "classical.freeze_checklist_summary.v1",
+ "status": "ready" if all(row.get("status") == "ready" for row in freeze_checklist_summaries.values()) else "blocked",
+ "family_count": len(freeze_checklist_summaries),
+ "ready_to_freeze_total": sum(int(row.get("ready_to_freeze_count") or 0) for row in freeze_checklist_summaries.values()),
+ "blocked_item_total": sum(int(row.get("blocked_item_count") or 0) for row in freeze_checklist_summaries.values()),
+ "top_missing_fields": [
+ {"field": field, "missing_item_count": count}
+ for field, count in sorted(freeze_top_missing.items(), key=lambda item: (-item[1], item[0]))[:12]
+ ],
+ "promotion_allowed": False,
+ }
+ artifact_registry = build_classical_artifact_registry_packet(output_path=artifact_registry_output_path)
+ required_artifacts = [
+ DEFAULT_CLASSICAL_DOCTRINE_LOCI_MANIFEST,
+ DEFAULT_CLASSICAL_HORARY_ACQUISITION_QUEUE,
+ DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_QUEUE,
+ DEFAULT_CLASSICAL_HORARY_ACQUISITION_RUNNER_PACKET,
+ DEFAULT_CLASSICAL_ELECTIONAL_ACQUISITION_RUNNER_PACKET,
+ DEFAULT_CLASSICAL_HORARY_FREEZE_CHECKLIST,
+ DEFAULT_CLASSICAL_ELECTIONAL_FREEZE_CHECKLIST,
+ DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE,
+ DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE,
+ DEFAULT_CLASSICAL_HORARY_ELECTIONAL_REMAINING_TASK_REGISTER,
+ ]
+ missing_required = [str(path) for path in required_artifacts if not path.exists()]
+ technique_cards = [
+ _classical_technique_card("classical_zr_card", "zr", "partial", {
+ "doctrine_scope": "release_lord/peak_lord/profection/loosing_cross_lot",
+ "audit_fields": ["rule_locus_labels", "component_review_weights", "cross_lot_calibration"],
+ "blocked_or_partial_reason": "cross-lot/cross-release reviewer evidence remains insufficient",
+ }),
+ _classical_technique_card("classical_horary_card", "horary", horary_bundle.get("status", "blocked"), {
+ "doctrine_scope": "significator/perfection/prohibition/translation_collection/timing",
+ "audit_fields": ["case_acquisition_runner_summary", "domain_validation_bundle", "reviewer_template"],
+ "blocked_or_partial_reason": "independent horary question sample packet has zero frozen cases",
+ }),
+ _classical_technique_card("classical_electional_card", "electional", electional_bundle.get("status", "blocked"), {
+ "doctrine_scope": "goal_specific_rules/moon_condition/prohibition/window_ranking",
+ "audit_fields": ["case_acquisition_runner_summary", "domain_validation_bundle", "reviewer_template"],
+ "blocked_or_partial_reason": "accepted/rejected electional sample packet has zero frozen cases",
+ "allowed_claim": "review-only ranked candidate window",
+ "forbidden_claim": "approved best time",
+ }),
+ _classical_technique_card("classical_blind_validation_card", "validation", remaining.get("status", "blocked"), {
+ "doctrine_scope": "blind_scoring/reviewer_unseal/statistics_gate/promotion_thresholds",
+ "audit_fields": ["remaining_task_register", "runtime_counts", "next_commands"],
+ "blocked_or_partial_reason": "domain statistics and promotion threshold gates are not satisfied",
+ }),
+ ]
+ registry_blocked_or_invalid = int(artifact_registry.get("blocked_or_invalid_count") or 0)
+ expected_blocked_artifact_count = int(artifact_registry.get("expected_blocked_artifact_count") or 0)
+ invalid_or_unreadable_artifact_count = int(artifact_registry.get("invalid_or_unreadable_artifact_count") or 0)
+ label_leakage_review_count = int(artifact_registry.get("label_leakage_review_count") or 0)
+ if missing_required or artifact_registry.get("artifact_count", 0) <= 0:
+ doctor_status = "degraded"
+ elif invalid_or_unreadable_artifact_count or label_leakage_review_count:
+ doctor_status = "warning"
+ else:
+ doctor_status = "ready"
+ packet = {
+ "schema": "classical.productization_shell.v1",
+ "status": doctor_status,
+ "generated_at": datetime.now(fixed_timezone.utc).isoformat(),
+ "doctor": {
+ "schema": "classical.doctor.v1",
+ "status": doctor_status,
+ "required_artifacts_present": not missing_required,
+ "missing_required_artifacts": missing_required,
+ "artifact_registry_status": artifact_registry.get("status", "blocked"),
+ "blocked_or_invalid_artifact_count": registry_blocked_or_invalid,
+ "expected_blocked_artifact_count": expected_blocked_artifact_count,
+ "invalid_or_unreadable_artifact_count": invalid_or_unreadable_artifact_count,
+ "label_leakage_review_count": label_leakage_review_count,
+ "freeze_checklist_summary": freeze_checklist_summary,
+ "cli_entrypoints": {
+ "classical-usable-loop-audit": True,
+ "classical-case-acquisition": True,
+ "classical-domain-validation-bundle": True,
+ "classical-blind-scoring": True,
+ "classical-blind-unseal": True,
+ "classical-productization-shell": True,
+ "classical-promotion-threshold-calibration": True,
+ "classical-remaining-task-register": True,
+ "classical-freeze-checklist": True,
+ "classical-source-freeze-conversion": True,
+ "classical-case-field-extraction": True,
+ "classical-freeze-admission": True,
+ "classical-astrolog-witness": True,
+ "classical-astrolog-source-intake": True,
+ "classical-astrolog-parity": True,
+ },
+ "promotion_allowed": False,
+ },
+ "technique_cards": technique_cards,
+ "artifact_registry": artifact_registry,
+ "freeze_checklist_summaries": freeze_checklist_summaries,
+ "remaining_task_register": remaining,
+ "promotion_allowed": False,
+ "boundary": "Productization shell makes classical audit assets easier to inspect; candidate/partial/blocked states remain non-final.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def build_classical_astrolog_cli_witness_packet(
+ *,
+ binary: str = "astrolog",
+ output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ """Record Astrolog CLI availability without importing or vendoring GPL code."""
+ resolved_binary = shutil.which(binary)
+ probe = {
+ "schema": "classical.astrolog_cli_witness.v1",
+ "source_id": "astrolog_cli_fixed_star_witness",
+ "source_name": "Astrolog",
+ "source_url": "https://www.astrolog.org/astrolog.htm",
+ "generated_at": datetime.now(fixed_timezone.utc).isoformat(),
+ "status": "blocked_not_installed" if not resolved_binary else "partial_cli_available",
+ "install_status": "not_found_on_path" if not resolved_binary else "found_on_path",
+ "binary_requested": binary,
+ "binary_path": resolved_binary,
+ "use_mode": "external_subprocess_only",
+ "doctrine_scope": [
+ "fixed_stars",
+ "chart_position_parity",
+ "aspect_parity",
+ "electional_visibility_review",
+ ],
+ "allowed_use": [
+ "compare fixed-star and chart-position output against the local engine",
+ "record calculation discrepancies as external witness evidence",
+ "keep results in audit/report appendix as parity context",
+ ],
+ "forbidden_use": [
+ "do not vendor, import, or link Astrolog source into this runtime without license review",
+ "do not treat Astrolog output as real-case labels",
+ "do not promote horary yes/no, electional best-time, or ZR prediction readiness from this witness alone",
+ ],
+ "license_boundary": "Astrolog is treated as a separately installed GPL/free command-line program; Swiss Ephemeris or other data-file terms remain separate.",
+ "promotion_allowed": False,
+ }
+ if resolved_binary:
+ try:
+ completed = subprocess.run(
+ [resolved_binary, "-H"],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=8,
+ )
+ probe["help_probe"] = {
+ "exit_code": completed.returncode,
+ "stdout_head": completed.stdout[:800],
+ "stderr_head": completed.stderr[:800],
+ "status": "executed",
+ }
+ except Exception as exc: # pragma: no cover - depends on local install
+ probe["status"] = "blocked_probe_failed"
+ probe["help_probe"] = {
+ "status": "blocked",
+ "error": f"{type(exc).__name__}: {exc}",
+ }
+ else:
+ probe["help_probe"] = {
+ "status": "blocked",
+ "reason": "Astrolog binary was not found on PATH during this run.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(probe, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ probe["output_path"] = str(out)
+ return probe
+
+
+def build_classical_astrolog_source_intake_packet(
+ *,
+ source_url: str = DEFAULT_ASTROLOG_SOURCE_URL,
+ license_url: str = DEFAULT_ASTROLOG_LICENSE_URL,
+ download_dir: Path | str = DEFAULT_ASTROLOG_SOURCE_DIR,
+ fetch: bool = True,
+ output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ target_dir = Path(download_dir)
+ archive_name = source_url.rstrip("/").split("/")[-1] or "astrolog_source.zip"
+ archive_path = target_dir / archive_name
+ packet: dict[str, Any] = {
+ "schema": "classical.astrolog_source_intake.v1",
+ "source_id": "astrolog_cli_fixed_star_witness",
+ "source_name": "Astrolog",
+ "source_url": source_url,
+ "license_url": license_url,
+ "generated_at": datetime.now(fixed_timezone.utc).isoformat(),
+ "status": "blocked_fetch_disabled",
+ "download_dir": str(target_dir),
+ "archive_path": str(archive_path),
+ "fetch_requested": bool(fetch),
+ "use_mode": "source_archive_for_manual_build_or_external_cli_only",
+ "license_boundary": "GPL v2 source archive; do not vendor, import, or link into production runtime without license review.",
+ "allowed_use": [
+ "preserve official source archive hash for reviewer/debug provenance",
+ "build a separate local CLI outside the Python package when license review permits",
+ "compare command-line fixed-star/chart/aspect output against local calculations",
+ ],
+ "forbidden_use": [
+ "do not redistribute extracted source through generated reports",
+ "do not make Astrolog output a validation label",
+ "do not upgrade candidate/partial/blocked doctrine status from this source intake alone",
+ ],
+ "promotion_allowed": False,
+ }
+ if fetch:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ request = urllib.request.Request(
+ source_url,
+ headers={"User-Agent": "jyotish-classical-audit/1.0"},
+ )
+ with urllib.request.urlopen(request, timeout=30) as response:
+ data = response.read()
+ archive_path.write_bytes(data)
+ packet.update({
+ "status": "downloaded",
+ "bytes": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "archive_present": archive_path.exists(),
+ })
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ packet.update({
+ "status": "blocked_download_failed",
+ "archive_present": archive_path.exists(),
+ "error": f"{type(exc).__name__}: {exc}",
+ })
+ else:
+ packet["archive_present"] = archive_path.exists()
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+_ASTROLOG_SIGN_OFFSETS = {
+ "Ari": 0,
+ "Tau": 30,
+ "Gem": 60,
+ "Can": 90,
+ "Leo": 120,
+ "Vir": 150,
+ "Lib": 180,
+ "Sco": 210,
+ "Sag": 240,
+ "Cap": 270,
+ "Aqu": 300,
+ "Pis": 330,
+}
+_ASTROLOG_OBJECT_ALIASES = {
+ "Sun": "sun",
+ "Moon": "moon",
+ "Merc": "mercury",
+ "Venu": "venus",
+ "Mars": "mars",
+ "Jupi": "jupiter",
+ "Satu": "saturn",
+ "Siri": "Sirius",
+ "Cano": "Canopus",
+ "Arct": "Arcturus",
+ "Vega": "Vega",
+ "Cape": "Capella",
+ "Rige": "Rigel",
+ "Proc": "Procyon",
+ "Bete": "Betelgeuse",
+ "Ache": "Achernar",
+ "Agen": "Agena",
+ "Alta": "Altair",
+ "Acru": "Acrux",
+ "Alde": "Aldebaran",
+ "Anta": "Antares",
+ "Spic": "Spica",
+ "Poll": "Pollux",
+ "Foma": "Fomalhaut",
+ "Dene": "Deneb",
+ "Regu": "Regulus",
+ "Cast": "Castor",
+ "Shau": "Shaula",
+ "Bell": "Bellatrix",
+ "Alni": "Alnilam",
+ "Alka": "Alkaid",
+ "Algo": "Algol",
+ "Alcy": "Alcyone",
+ "Alph": "Alphard",
+}
+_ASTROLOG_ASPECT_ALIASES = {
+ "Con": "conjunction",
+ "Opp": "opposition",
+ "Squ": "square",
+ "Tri": "trine",
+ "Sex": "sextile",
+}
+_ASTROLOG_ASPECT_BODY_ALIASES = {
+ "Sun": "sun",
+ "Moon": "moon",
+ "Mercury": "mercury",
+ "Venus": "venus",
+ "Mars": "mars",
+ "Jupiter": "jupiter",
+ "Saturn": "saturn",
+}
+
+
+def _astrolog_angle(value: float, *, latitude: bool = False) -> str:
+ direction_positive = "N" if latitude else "E"
+ direction_negative = "S" if latitude else "W"
+ direction = direction_positive if value >= 0 else direction_negative
+ absolute = abs(float(value))
+ degrees = int(absolute)
+ minutes = int(round((absolute - degrees) * 60))
+ if minutes == 60:
+ degrees += 1
+ minutes = 0
+ return f"{degrees}:{minutes:02d}{direction}"
+
+
+def _astrolog_timezone_arg(offset_hours: float) -> str:
+ """Astrolog's -qa zone uses positive west / negative east of GMT."""
+ zone = -float(offset_hours)
+ if zone.is_integer():
+ return str(int(zone))
+ return f"{zone:.2f}".rstrip("0").rstrip(".")
+
+
+def _run_astrolog_capture(
+ command: list[str],
+ *,
+ output_path: Path | None = None,
+ timeout: int = 12,
+) -> dict[str, Any]:
+ try:
+ completed = subprocess.run(
+ command,
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=timeout,
+ )
+ stdout = completed.stdout
+ stderr = completed.stderr
+ if output_path is not None:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(stdout, encoding="utf-8", newline="\n")
+ return {
+ "status": "executed" if completed.returncode == 0 else "blocked_nonzero_exit",
+ "command": command,
+ "exit_code": completed.returncode,
+ "stdout_path": str(output_path) if output_path is not None else None,
+ "stdout_sha256": hashlib.sha256(stdout.encode("utf-8")).hexdigest(),
+ "stdout_bytes": len(stdout.encode("utf-8")),
+ "stdout_head": stdout[:1200],
+ "stderr_head": stderr[:1200],
+ }
+ except Exception as exc:
+ return {
+ "status": "blocked_exception",
+ "command": command,
+ "error": f"{type(exc).__name__}: {exc}",
+ }
+
+
+def _parse_astrolog_positions(stdout: str) -> dict[str, float]:
+ positions: dict[str, float] = {}
+ pattern = re.compile(r"^(?P[A-Za-z]{3,4})\s*:\s*(?P\d{1,2})(?P[A-Z][a-z]{2})(?P\d{2})")
+ for line in stdout.splitlines():
+ match = pattern.match(line.strip())
+ if not match:
+ continue
+ sign = match.group("sign")
+ if sign not in _ASTROLOG_SIGN_OFFSETS:
+ continue
+ body = _ASTROLOG_OBJECT_ALIASES.get(match.group("body"), match.group("body"))
+ positions[body] = round(
+ _ASTROLOG_SIGN_OFFSETS[sign] + int(match.group("deg")) + int(match.group("min")) / 60.0,
+ 6,
+ )
+ return positions
+
+
+def _parse_astrolog_aspect_list(stdout: str) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ pattern = re.compile(
+ r"^\s*(?P\d+):\s+"
+ r"(?P[A-Za-z]+)\s+[\(\[][A-Z][a-z]{2}[\)\]]\s+"
+ r"(?P[A-Za-z]{3})\s+[\(\[][A-Z][a-z]{2}[\)\]]\s+"
+ r"(?P[A-Za-z]+)\s+-\s+orb:\s+"
+ r"(?P[as])(?P\d+):(?P\d{2})'\s+-\s+power:\s+(?P[-\d.]+)"
+ )
+ for line in stdout.splitlines():
+ match = pattern.match(line)
+ if not match:
+ continue
+ first = _ASTROLOG_ASPECT_BODY_ALIASES.get(match.group("body1"))
+ second = _ASTROLOG_ASPECT_BODY_ALIASES.get(match.group("body2"))
+ aspect = _ASTROLOG_ASPECT_ALIASES.get(match.group("aspect"))
+ if not first or not second or not aspect:
+ continue
+ orb = round(int(match.group("deg")) + int(match.group("min")) / 60.0, 6)
+ rows.append({
+ "index": int(match.group("index")),
+ "from_planet": first,
+ "to_planet": second,
+ "pair_key": "|".join(sorted([first, second])),
+ "aspect": aspect,
+ "orb": orb,
+ "applying_separating": "applying" if match.group("motion") == "a" else "separating",
+ "power": float(match.group("power")),
+ })
+ return rows
+
+
+def _local_traditional_aspect_parity_rows(chart_packet: dict[str, Any]) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ verdict_rows = chart_packet.get("aspect_doctrine_verdicts", {}).get("rows", [])
+ for left_index, first_planet in enumerate(TRADITIONAL_PLANETS):
+ for second_planet in TRADITIONAL_PLANETS[left_index + 1:]:
+ matches = [
+ row for row in verdict_rows
+ if {row.get("from_planet"), row.get("to_planet")} == {first_planet, second_planet}
+ ]
+ if not matches:
+ continue
+ best = min(matches, key=lambda row: float(row.get("degree_aspect", {}).get("orb", 999)))
+ motions = {str(row.get("applying_separating")) for row in matches}
+ if "applying" in motions:
+ motion = "applying"
+ elif float(best.get("degree_aspect", {}).get("orb", 999)) <= 12:
+ motion = "separating"
+ else:
+ motion = "out_of_orb"
+ rows.append({
+ "from_planet": first_planet,
+ "to_planet": second_planet,
+ "pair_key": "|".join(sorted([first_planet, second_planet])),
+ "aspect": best.get("degree_aspect", {}).get("aspect"),
+ "orb": best.get("degree_aspect", {}).get("orb"),
+ "applying_separating": motion,
+ "orb_band": best.get("degree_aspect", {}).get("orb_band"),
+ "verdict": best.get("verdict"),
+ })
+ return rows
+
+
+def _aspect_parity_rows(
+ *,
+ local_rows: list[dict[str, Any]],
+ astrolog_rows: list[dict[str, Any]],
+ max_orb_delta_degrees: float = 0.15,
+) -> list[dict[str, Any]]:
+ by_key = {
+ (row["pair_key"], row["aspect"]): row
+ for row in astrolog_rows
+ }
+ compared: list[dict[str, Any]] = []
+ for local in local_rows:
+ key = (local["pair_key"], local["aspect"])
+ astrolog = by_key.get(key)
+ if not astrolog:
+ if local.get("orb_band") in {"out_of_orb", "wide_background"}:
+ status = "pass_astrolog_omits_wide_local_background"
+ else:
+ status = "warning_missing_astrolog_aspect"
+ compared.append({
+ "pair_key": local["pair_key"],
+ "aspect": local["aspect"],
+ "local": local,
+ "astrolog": None,
+ "orb_delta_degrees": None,
+ "motion_match": None,
+ "status": status,
+ })
+ continue
+ orb_delta = round(abs(float(local["orb"]) - float(astrolog["orb"])), 6)
+ local_motion = local.get("applying_separating")
+ astrolog_motion = astrolog.get("applying_separating")
+ motion_match = (
+ local_motion == astrolog_motion
+ or (local_motion == "stationary_or_static_unresolved" and astrolog_motion == "separating")
+ )
+ status = "pass" if orb_delta <= max_orb_delta_degrees and motion_match else "warning_mismatch"
+ compared.append({
+ "pair_key": local["pair_key"],
+ "aspect": local["aspect"],
+ "local": local,
+ "astrolog": astrolog,
+ "orb_delta_degrees": orb_delta,
+ "motion_match": motion_match,
+ "status": status,
+ })
+ local_keys = {(row["pair_key"], row["aspect"]) for row in local_rows}
+ for astrolog in astrolog_rows:
+ if (astrolog["pair_key"], astrolog["aspect"]) in local_keys:
+ continue
+ compared.append({
+ "pair_key": astrolog["pair_key"],
+ "aspect": astrolog["aspect"],
+ "local": None,
+ "astrolog": astrolog,
+ "orb_delta_degrees": None,
+ "motion_match": None,
+ "status": "warning_astrolog_only",
+ })
+ return compared
+
+
+def _load_swiss_fixed_star_names(
+ star_catalog_path: Path | str = Path("references/open_source_sources/swiss_ephemeris/sefstars.txt"),
+) -> dict[str, str]:
+ catalog = Path(star_catalog_path)
+ names: dict[str, str] = {}
+ if not catalog.exists():
+ return names
+ wanted = {name.lower(): name for name in CLASSICAL_FIXED_STAR_PRIORITY}
+ for line in catalog.read_text(encoding="utf-8", errors="ignore").splitlines():
+ if not line or line.startswith("#") or "," not in line:
+ continue
+ parts = [part.strip() for part in line.split(",")]
+ traditional = parts[0]
+ nomenclature = parts[1] if len(parts) > 1 else ""
+ canonical = wanted.get(traditional.lower())
+ if canonical and canonical not in names:
+ names[canonical] = f"{traditional},{nomenclature}" if nomenclature else traditional
+ return names
+
+
+def _swiss_fixed_star_positions(jd_ut: float) -> dict[str, float]:
+ try:
+ import swisseph as swe # type: ignore
+ except Exception:
+ return {}
+ star_catalog_dir = Path("references/open_source_sources/swiss_ephemeris")
+ if star_catalog_dir.exists():
+ swe.set_ephe_path(str(star_catalog_dir.resolve()))
+ swiss_names = _load_swiss_fixed_star_names(star_catalog_dir / "sefstars.txt")
+ rows: dict[str, float] = {}
+ for star in CLASSICAL_FIXED_STAR_PRIORITY:
+ swiss_star_name = swiss_names.get(star, star)
+ try:
+ result = swe.fixstar2_ut(swiss_star_name, jd_ut)
+ rows[star] = round(float(result[0][0]) % 360, 6)
+ except Exception:
+ continue
+ return rows
+
+
+def _position_parity_rows(
+ *,
+ swiss_positions: dict[str, float],
+ astrolog_positions: dict[str, float],
+ max_delta_degrees: float,
+) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ for name in sorted(set(swiss_positions) | set(astrolog_positions), key=str.lower):
+ swiss_value = swiss_positions.get(name)
+ astrolog_value = astrolog_positions.get(name)
+ if swiss_value is None or astrolog_value is None:
+ status = "blocked_missing_counterpart"
+ delta = None
+ else:
+ delta = round(_angular_distance(swiss_value, astrolog_value), 6)
+ status = "pass" if delta <= max_delta_degrees else "warning_delta_exceeds_threshold"
+ rows.append({
+ "object": name,
+ "swiss_longitude": swiss_value,
+ "astrolog_longitude": astrolog_value,
+ "delta_degrees": delta,
+ "status": status,
+ })
+ return rows
+
+
+def build_classical_astrolog_parity_packet(
+ *,
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ latitude: float,
+ longitude: float,
+ timezone: str = "Asia/Shanghai",
+ tz_offset_hours: float = 8.0,
+ binary: str | Path = DEFAULT_ASTROLOG_BINARY,
+ output_dir: Path | str = DEFAULT_ASTROLOG_PARITY_DIR,
+ output_path: Path | str | None = None,
+ max_delta_degrees: float = 0.25,
+) -> dict[str, Any]:
+ resolved_binary = shutil.which(str(binary)) or (str(binary) if Path(binary).exists() else None)
+ packet: dict[str, Any] = {
+ "schema": "classical.astrolog_parity.v1",
+ "source_id": "astrolog_cli_fixed_star_aspect_parity",
+ "source_name": "Astrolog",
+ "generated_at": datetime.now(fixed_timezone.utc).isoformat(),
+ "status": "blocked_missing_astrolog_binary" if not resolved_binary else "partial",
+ "binary_requested": str(binary),
+ "binary_path": resolved_binary,
+ "use_mode": "external_subprocess_only",
+ "input": {
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": hour,
+ "minute": minute,
+ "latitude": latitude,
+ "longitude": longitude,
+ "timezone": timezone,
+ "tz_offset_hours": tz_offset_hours,
+ },
+ "max_delta_degrees": max_delta_degrees,
+ "promotion_allowed": False,
+ "boundary": "Astrolog parity is calculation witness evidence only; it cannot promote horary yes/no, electional best-time, or ZR prediction readiness.",
+ }
+ if not resolved_binary:
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+ local_dt = datetime(year, month, day, hour, minute, tzinfo=ZoneInfo(timezone))
+ utc_dt = local_dt.astimezone(fixed_timezone.utc)
+ try:
+ import swisseph as swe # type: ignore
+ jd_ut = swe.julday(
+ utc_dt.year,
+ utc_dt.month,
+ utc_dt.day,
+ utc_dt.hour + utc_dt.minute / 60 + utc_dt.second / 3600,
+ )
+ except Exception:
+ jd_ut = None
+ birth = {
+ "year": year,
+ "month": month,
+ "day": day,
+ "hour": hour,
+ "minute": minute,
+ "latitude": latitude,
+ "longitude": longitude,
+ "timezone": timezone,
+ "house_system": "W",
+ }
+ chart_packet = build_classical_astrology_packet(include_real_case_calibration=False, **birth)
+ swiss_planets = {
+ planet: round(float(chart_packet["chart"]["natal"]["planets"][planet]["longitude"]) % 360, 6)
+ for planet in TRADITIONAL_PLANETS
+ }
+ swiss_stars = _swiss_fixed_star_positions(float(jd_ut)) if jd_ut is not None else {}
+
+ out_dir = Path(output_dir)
+ stamp = f"{year:04d}_{month:02d}_{day:02d}_{hour:02d}{minute:02d}"
+ base = [str(resolved_binary), "-qa", str(month), str(day), str(year), f"{hour}:{minute:02d}", _astrolog_timezone_arg(tz_offset_hours), _astrolog_angle(longitude), _astrolog_angle(latitude, latitude=True)]
+ position_output = out_dir / f"astrolog_positions_{stamp}.txt"
+ star_output = out_dir / f"astrolog_fixed_stars_{stamp}.txt"
+ aspect_output = out_dir / f"astrolog_aspect_grid_applying_{stamp}.txt"
+ aspect_list_output = out_dir / f"astrolog_aspect_list_applying_{stamp}.txt"
+ command_outputs = {
+ "positions": _run_astrolog_capture(base + ["-v"], output_path=position_output),
+ "fixed_stars": _run_astrolog_capture(base + ["-U", "-v"], output_path=star_output),
+ "aspect_grid_applying": _run_astrolog_capture(base + ["-ga"], output_path=aspect_output),
+ "aspect_list_applying": _run_astrolog_capture(base + ["-aa"], output_path=aspect_list_output),
+ }
+ astrolog_planets = {
+ key: value for key, value in _parse_astrolog_positions(position_output.read_text(encoding="utf-8")).items()
+ if key in TRADITIONAL_PLANETS
+ } if position_output.exists() else {}
+ astrolog_stars = {
+ key: value for key, value in _parse_astrolog_positions(star_output.read_text(encoding="utf-8")).items()
+ if key in CLASSICAL_FIXED_STAR_PRIORITY
+ } if star_output.exists() else {}
+ planet_rows = _position_parity_rows(
+ swiss_positions=swiss_planets,
+ astrolog_positions=astrolog_planets,
+ max_delta_degrees=max_delta_degrees,
+ )
+ star_rows = _position_parity_rows(
+ swiss_positions=swiss_stars,
+ astrolog_positions=astrolog_stars,
+ max_delta_degrees=max_delta_degrees,
+ )
+ local_aspect_rows = _local_traditional_aspect_parity_rows(chart_packet)
+ astrolog_aspect_rows = (
+ _parse_astrolog_aspect_list(aspect_list_output.read_text(encoding="utf-8"))
+ if aspect_list_output.exists()
+ else []
+ )
+ aspect_rows = _aspect_parity_rows(
+ local_rows=local_aspect_rows,
+ astrolog_rows=astrolog_aspect_rows,
+ )
+ warning_count = sum(1 for row in planet_rows + star_rows if row["status"] != "pass")
+ aspect_warning_count = sum(1 for row in aspect_rows if not str(row["status"]).startswith("pass"))
+ packet.update({
+ "status": "partial" if warning_count == 0 and aspect_warning_count == 0 else "partial_with_warnings",
+ "julian_day_ut": jd_ut,
+ "utc_datetime": utc_dt.isoformat(),
+ "command_outputs": command_outputs,
+ "parsed_position_parity": {
+ "schema": "classical.astrolog_position_parity.v1",
+ "status": "partial" if warning_count == 0 else "partial_with_warnings",
+ "planet_rows": planet_rows,
+ "fixed_star_rows": star_rows,
+ "summary": {
+ "planet_compared_count": sum(1 for row in planet_rows if row["delta_degrees"] is not None),
+ "fixed_star_compared_count": sum(1 for row in star_rows if row["delta_degrees"] is not None),
+ "warning_or_missing_count": warning_count,
+ },
+ },
+ "aspect_grid_parity": {
+ "schema": "classical.astrolog_aspect_grid_parity.v1",
+ "status": "partial" if aspect_warning_count == 0 else "partial_with_warnings",
+ "astrolog_stdout_path": str(aspect_output),
+ "astrolog_aspect_list_stdout_path": str(aspect_list_output),
+ "local_aspect_verdict_row_count": chart_packet.get("aspect_doctrine_verdicts", {}).get("row_count", 0),
+ "local_aspect_summary": chart_packet.get("aspect_doctrine_verdicts", {}).get("summary", {}),
+ "parsed_astrolog_aspect_count": len(astrolog_aspect_rows),
+ "traditional_pair_parity_rows": aspect_rows,
+ "summary": {
+ "local_traditional_pair_count": len(local_aspect_rows),
+ "astrolog_traditional_aspect_count": len(astrolog_aspect_rows),
+ "compared_or_reviewed_count": len(aspect_rows),
+ "pass_count": sum(1 for row in aspect_rows if str(row["status"]).startswith("pass")),
+ "warning_count": aspect_warning_count,
+ "aspect_type_compared": True,
+ "orb_compared": True,
+ "applying_separating_compared": True,
+ },
+ "boundary": "Astrolog applying/separating aspect list is parsed for traditional planets; grid stdout remains captured as reviewer-visible source text.",
+ },
+ })
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def build_classical_promotion_threshold_calibration_packet(
+ *,
+ families: str | list[str] = "horary,electional,zr",
+ minimum_independent_samples: int = 30,
+ output_path: Path | str | None = None,
+) -> dict[str, Any]:
+ if isinstance(families, str):
+ family_list = [item.strip() for item in families.split(",") if item.strip()]
+ else:
+ family_list = [str(item) for item in families if str(item)]
+ bundle_paths = {
+ "horary": DEFAULT_CLASSICAL_HORARY_DOMAIN_VALIDATION_BUNDLE,
+ "electional": DEFAULT_CLASSICAL_ELECTIONAL_DOMAIN_VALIDATION_BUNDLE,
+ "zr": DEFAULT_CLASSICAL_ZR_DOMAIN_VALIDATION_BUNDLE,
+ }
+ pipeline_paths = {
+ "horary": DEFAULT_CLASSICAL_HORARY_REVIEWER_EVIDENCE_PIPELINE,
+ "electional": DEFAULT_CLASSICAL_ELECTIONAL_REVIEWER_EVIDENCE_PIPELINE,
+ "zr": DEFAULT_CLASSICAL_ZR_REVIEWER_EVIDENCE_PIPELINE,
+ }
+
+ def _unsealed_rows_from_artifact(data: dict[str, Any]) -> list[dict[str, Any]]:
+ artifacts = data.get("artifacts") if isinstance(data.get("artifacts"), dict) else {}
+ unseal = artifacts.get("unseal") if isinstance(artifacts.get("unseal"), dict) else {}
+ return [row for row in unseal.get("unsealed_rows", []) if isinstance(row, dict)]
+
+ def _matrix_from_unsealed_rows(family: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
+ matrix = {
+ "schema": "classical.family_domain_confusion_matrix.v1",
+ "family": family,
+ "status": "partial_review_ready" if rows else "blocked_no_independent_unsealed_statistics",
+ "row_count": len(rows),
+ "true_positive": 0,
+ "false_positive": 0,
+ "true_negative": 0,
+ "false_negative": 0,
+ "accuracy": None,
+ "false_positive_rate": None,
+ "domain_rows": {},
+ "promotion_allowed": False,
+ "boundary": "Confusion rows are reviewer diagnostics only; promotion still requires the separate threshold gate.",
+ }
+ for row in rows:
+ domain = str(row.get("domain") or "unknown")
+ domain_bucket = matrix["domain_rows"].setdefault(domain, {
+ "true_positive": 0,
+ "false_positive": 0,
+ "true_negative": 0,
+ "false_negative": 0,
+ })
+ expected_positive = str(row.get("expected_label") or "").startswith("positive")
+ predicted_positive = int(row.get("total_blocking_verdict_count") or 0) == 0
+ if predicted_positive and expected_positive:
+ key = "true_positive"
+ elif predicted_positive and not expected_positive:
+ key = "false_positive"
+ elif not predicted_positive and expected_positive:
+ key = "false_negative"
+ else:
+ key = "true_negative"
+ matrix[key] += 1
+ domain_bucket[key] += 1
+ denominator = matrix["true_positive"] + matrix["false_positive"] + matrix["true_negative"] + matrix["false_negative"]
+ negative_denominator = matrix["true_negative"] + matrix["false_positive"]
+ if denominator:
+ matrix["accuracy"] = round((matrix["true_positive"] + matrix["true_negative"]) / denominator, 6)
+ if negative_denominator:
+ matrix["false_positive_rate"] = round(matrix["false_positive"] / negative_denominator, 6)
+ return matrix
+
+ def _weight_preview_from_matrix(matrix: dict[str, Any]) -> dict[str, Any]:
+ accuracy = matrix.get("accuracy")
+ false_positive_rate = matrix.get("false_positive_rate")
+ if matrix.get("row_count", 0) == 0:
+ recommendation = "blocked_until_unsealed_rows_exist"
+ suggested_weight = 0.0
+ elif isinstance(false_positive_rate, float) and false_positive_rate <= 0.2 and isinstance(accuracy, float) and accuracy >= 0.7:
+ recommendation = "candidate_weight_requires_holdout_confirmation"
+ suggested_weight = 1.0
+ elif isinstance(false_positive_rate, float) and false_positive_rate <= 0.2:
+ recommendation = "partial_weight_low_false_positive"
+ suggested_weight = 0.5
+ else:
+ recommendation = "downweight_until_false_positive_rate_improves"
+ suggested_weight = 0.0
+ return {
+ "schema": "classical.family_domain_weight_calibration_preview.v1",
+ "family": matrix.get("family"),
+ "status": "partial_review_ready" if matrix.get("row_count", 0) else "blocked",
+ "suggested_relative_weight": suggested_weight,
+ "recommendation": recommendation,
+ "promotion_allowed": False,
+ "boundary": "Suggested weights are diagnostic and cannot approve prediction-ready status.",
+ }
+
+ def _gate_summary(path: Path, family: str, schema_name: str) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "family": family,
+ "schema": schema_name,
+ "path": str(path),
+ "status": "blocked",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "gate_summary": {},
+ "unsealed_rows": [],
+ "reason": "artifact_missing",
+ }
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as exc: # pragma: no cover
+ return {
+ "family": family,
+ "schema": schema_name,
+ "path": str(path),
+ "status": "blocked",
+ "promotion_status": "blocked",
+ "promotion_allowed": False,
+ "gate_summary": {},
+ "unsealed_rows": [],
+ "reason": f"artifact_unreadable:{type(exc).__name__}",
+ }
+ return {
+ "family": family,
+ "schema": data.get("schema", schema_name),
+ "path": str(path),
+ "status": data.get("status", "blocked"),
+ "promotion_status": data.get("promotion_status", "blocked"),
+ "promotion_allowed": data.get("promotion_allowed") is True,
+ "gate_summary": data.get("gate_summary") if isinstance(data.get("gate_summary"), dict) else {},
+ "unsealed_rows": _unsealed_rows_from_artifact(data),
+ "boundary": data.get("boundary", ""),
+ }
+
+ rows = []
+ for family in family_list:
+ bundle = _gate_summary(bundle_paths.get(family, Path()), family, "classical.domain_validation_bundle.v1")
+ pipeline = _gate_summary(pipeline_paths.get(family, Path()), family, "classical.reviewer_evidence_pipeline.v1")
+ bundle_gate = bundle.get("gate_summary") if isinstance(bundle.get("gate_summary"), dict) else {}
+ pipeline_gate = pipeline.get("gate_summary") if isinstance(pipeline.get("gate_summary"), dict) else {}
+ observed_independent_samples = max(
+ int(bundle_gate.get("unsealed_row_count") or 0),
+ int(pipeline_gate.get("unsealed_row_count") or 0),
+ int(pipeline_gate.get("admitted_sample_count") or 0),
+ )
+ scored_rows = max(
+ int(bundle_gate.get("score_row_count") or 0),
+ int(pipeline_gate.get("score_row_count") or 0),
+ )
+ unsealed_rows = pipeline.get("unsealed_rows") or bundle.get("unsealed_rows") or []
+ confusion_matrix = _matrix_from_unsealed_rows(family, unsealed_rows)
+ weight_preview = _weight_preview_from_matrix(confusion_matrix)
+ statistics_status = str(bundle_gate.get("statistics_status") or pipeline_gate.get("statistics_status") or "blocked")
+ blocked_gates = []
+ if observed_independent_samples < minimum_independent_samples:
+ blocked_gates.append("minimum_independent_samples")
+ if scored_rows < minimum_independent_samples:
+ blocked_gates.append("domain_score_row_count")
+ if statistics_status != "pass":
+ blocked_gates.append("statistics_status")
+ if not bundle.get("promotion_allowed") or not pipeline.get("promotion_allowed"):
+ blocked_gates.append("upstream_promotion_allowed")
+ status = "prediction_promotion_candidate" if not blocked_gates else "blocked"
+ rows.append({
+ "family": family,
+ "status": status,
+ "minimum_independent_samples": minimum_independent_samples,
+ "observed_independent_samples": observed_independent_samples,
+ "scored_row_count": scored_rows,
+ "accuracy_threshold": 0.70,
+ "observed_accuracy": confusion_matrix.get("accuracy"),
+ "false_positive_max": 0.20,
+ "observed_false_positive_rate": confusion_matrix.get("false_positive_rate"),
+ "domain_coverage_required": True,
+ "domain_coverage_status": "pass" if observed_independent_samples >= minimum_independent_samples else "blocked",
+ "statistics_status": statistics_status,
+ "confusion_matrix": confusion_matrix,
+ "domain_weight_calibration_preview": weight_preview,
+ "artifact_inputs": {
+ "domain_validation_bundle": bundle,
+ "reviewer_evidence_pipeline": pipeline,
+ },
+ "blocked_gates": sorted(set(blocked_gates)),
+ "promotion_allowed": status == "prediction_promotion_candidate",
+ "reason": (
+ "all threshold gates passed"
+ if status == "prediction_promotion_candidate"
+ else "independent reviewer-unsealed holdout statistics are not sufficient"
+ ),
+ "claim_boundary": {
+ "horary": "No final horary yes/no judgment until this row is a promotion candidate.",
+ "electional": "No approved best-time election until this row is a promotion candidate.",
+ "zr": "No ZR prediction-ready claim until this row is a promotion candidate.",
+ }.get(family, "No prediction-ready claim until this row is a promotion candidate."),
+ })
+ packet = {
+ "schema": "classical.promotion_threshold_calibration.v1",
+ "status": "prediction_promotion_candidate" if rows and all(row["promotion_allowed"] for row in rows) else "blocked",
+ "families": family_list,
+ "rows": rows,
+ "family_domain_confusion_matrix": {
+ row["family"]: row["confusion_matrix"]
+ for row in rows
+ },
+ "family_domain_weight_calibration_preview": {
+ row["family"]: row["domain_weight_calibration_preview"]
+ for row in rows
+ },
+ "blocked_gate_summary": {
+ row["family"]: row["blocked_gates"]
+ for row in rows
+ if row["blocked_gates"]
+ },
+ "promotion_allowed": bool(rows) and all(row["promotion_allowed"] for row in rows),
+ "boundary": "Threshold calibration is a gate contract only until independent samples, false-positive limits, accuracy floor, and domain coverage all pass.",
+ }
+ if output_path:
+ out = Path(output_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(packet, ensure_ascii=False, indent=2, default=str), encoding="utf-8", newline="\n")
+ packet["output_path"] = str(out)
+ return packet
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Calculate a minimum classical astrology packet.")
+ parser.add_argument("--input-json")
+ parser.add_argument("--case-acquisition-family", choices=["horary", "electional"])
+ parser.add_argument("--case-acquisition-queue")
+ parser.add_argument("--write-case-acquisition-packet")
+ parser.add_argument("--write-case-sample-packet")
+ parser.add_argument("--write-case-intake-manifest")
+ parser.add_argument("--write-case-reviewer-template")
+ parser.add_argument("--domain-validation-bundle-family", choices=["horary", "electional"])
+ parser.add_argument("--write-domain-validation-acquisition-packet")
+ parser.add_argument("--write-domain-validation-sample-packet")
+ parser.add_argument("--write-domain-validation-intake-manifest")
+ parser.add_argument("--write-domain-validation-reviewer-template")
+ parser.add_argument("--write-domain-validation-scoring-manifest")
+ parser.add_argument("--productization-shell", action="store_true")
+ parser.add_argument("--write-productization-shell")
+ parser.add_argument("--write-artifact-registry")
+ parser.add_argument("--promotion-threshold-calibration", action="store_true")
+ parser.add_argument("--promotion-threshold-families", default="horary,electional,zr")
+ parser.add_argument("--minimum-independent-samples", type=int, default=30)
+ parser.add_argument("--write-promotion-threshold-calibration")
+ args = parser.parse_args(argv)
+ if args.promotion_threshold_calibration:
+ print(json.dumps(
+ build_classical_promotion_threshold_calibration_packet(
+ families=args.promotion_threshold_families,
+ minimum_independent_samples=args.minimum_independent_samples,
+ output_path=args.write_promotion_threshold_calibration,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ default=str,
+ ))
+ return 0
+ if args.productization_shell:
+ print(json.dumps(
+ build_classical_productization_shell_packet(
+ output_path=args.write_productization_shell,
+ artifact_registry_output_path=args.write_artifact_registry,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ default=str,
+ ))
+ return 0
+ if args.domain_validation_bundle_family:
+ print(json.dumps(
+ build_classical_domain_validation_bundle_packet(
+ family=args.domain_validation_bundle_family,
+ acquisition_packet_path=args.write_domain_validation_acquisition_packet,
+ sample_packet_path=args.write_domain_validation_sample_packet,
+ intake_manifest_path=args.write_domain_validation_intake_manifest,
+ reviewer_template_path=args.write_domain_validation_reviewer_template,
+ scoring_manifest_path=args.write_domain_validation_scoring_manifest,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ default=str,
+ ))
+ return 0
+ if args.case_acquisition_family:
+ print(json.dumps(
+ build_classical_case_acquisition_runner_packet(
+ family=args.case_acquisition_family,
+ queue_path=args.case_acquisition_queue,
+ output_packet_path=args.write_case_acquisition_packet,
+ output_sample_packet_path=args.write_case_sample_packet,
+ output_intake_manifest_path=args.write_case_intake_manifest,
+ output_reviewer_template_path=args.write_case_reviewer_template,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ default=str,
+ ))
+ return 0
+ if not args.input_json:
+ parser.error("--input-json is required unless --case-acquisition-family, --domain-validation-bundle-family, or --productization-shell is provided")
+ payload = json.loads(args.input_json)
+ print(json.dumps(build_classical_astrology_packet(**payload), ensure_ascii=False, indent=2, default=str))
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py
index f66aa24a..de80dc14 100644
--- a/scripts/jyotish_engine.py
+++ b/scripts/jyotish_engine.py
@@ -435,6 +435,52 @@ def _calc_pushkara_flags(planets):
return result
+def _calc_lagna_vargottamamsa(report, varga_full):
+ chart = report.get('modules', {}).get('chart', {}) if isinstance(report, dict) else {}
+ ascendant = chart.get('ascendant', {}) if isinstance(chart, dict) else {}
+ d1_sign = ascendant.get('sign') if isinstance(ascendant, dict) else None
+ d9 = varga_full.get('D9_Navamsa', {}) if isinstance(varga_full, dict) else {}
+ d9_ascendant = d9.get('ascendant') if isinstance(d9, dict) else None
+ if not isinstance(d9_ascendant, dict) and isinstance(d9, dict):
+ d9_ascendant = d9.get('Ascendant', {})
+ d9_sign = d9_ascendant.get('sign') if isinstance(d9_ascendant, dict) else None
+ return {
+ 'd1_sign': d1_sign,
+ 'd9_sign': d9_sign,
+ 'is_lagna_vargottamamsa': bool(d1_sign and d9_sign and d1_sign == d9_sign),
+ 'source': 'raman_horoscope_v1_bounded_pp64',
+ }
+
+
+def _summarize_pushkara_planets(flags):
+ """Return the compact full-reading list without discarding raw flags."""
+ return [
+ f"{planet} ({data['sign']} {data['degree_in_sign']:.1f}°)"
+ for planet, data in flags.items()
+ if data.get('pushkara_navamsa') or data.get('pushkara_bhaga')
+ ]
+
+
+def _public_secondary_dasha_module(native):
+ """Expose one native rashi dasha under the upstream full-reading key.
+
+ The period table and profile id stay ours. The outer status is what the
+ upstream synastry validator reads, and it is never a bare error object.
+ """
+ if not isinstance(native, dict):
+ return {
+ 'status': 'blocked',
+ 'reason': 'native_rashi_dasha_missing',
+ 'periods': [],
+ }
+ return {
+ 'status': 'parameter_sensitive',
+ 'profile_id': native.get('profile_id'),
+ 'periods': list(native.get('periods') or []),
+ 'native_result': native,
+ }
+
+
def _calc_sensitive_points(planets):
result = {}
try:
@@ -15551,7 +15597,9 @@ def cmd_full_reading(args):
report['errors'].append(f"yoga-context-injection: {yoga_ctx_e}")
report['modules']['vargottama'] = _calc_vargottama(planets, varga_result)
+ report['modules']['lagna_vargottamamsa'] = _calc_lagna_vargottamamsa(report, varga_result)
report['modules']['pushkara'] = _calc_pushkara_flags(planets)
+ report['modules']['pushkara_planets'] = _summarize_pushkara_planets(report['modules']['pushkara'])
report['modules']['sensitive_points'] = _calc_sensitive_points(planets)
except Exception as e:
report['errors'].append(f"varga-full: {e}")
@@ -16036,6 +16084,39 @@ def cmd_full_reading(args):
'status': 'blocked',
'reason': 'reference_date_missing',
}
+
+ try:
+ from punarphoo import detect_punarphoo
+
+ report['modules']['punarphoo'] = detect_punarphoo(planets)
+ except Exception as e:
+ report['modules']['punarphoo'] = {
+ 'status': 'blocked',
+ 'reason': f'punarphoo_producer_failed:{e}',
+ }
+ try:
+ from rashi_dashas_native import calculate_rashi_dashas
+
+ secondary_dashas = calculate_rashi_dashas(
+ _birth_datetime_from_args(args),
+ float(asc_deg),
+ planet_lons,
+ )
+ report['modules']['navamsha_dasha'] = _public_secondary_dasha_module(secondary_dashas.get('navamsha'))
+ report['modules']['niryaana_shoola_dasha'] = _public_secondary_dasha_module(
+ secondary_dashas.get('niryaana_shoola')
+ )
+ except Exception as e:
+ report['modules']['navamsha_dasha'] = {
+ 'status': 'blocked',
+ 'reason': f'navamsha_dasha_producer_failed:{e}',
+ 'periods': [],
+ }
+ report['modules']['niryaana_shoola_dasha'] = {
+ 'status': 'blocked',
+ 'reason': f'niryaana_shoola_dasha_producer_failed:{e}',
+ 'periods': [],
+ }
_record_stage_timing(
stage_timings,
'dasha_and_core_varga_stack',
diff --git a/scripts/unified_western_reading.py b/scripts/unified_western_reading.py
new file mode 100644
index 00000000..34b31425
--- /dev/null
+++ b/scripts/unified_western_reading.py
@@ -0,0 +1,268 @@
+"""Auditable orchestration for the native tropical Western calculation layers."""
+
+from __future__ import annotations
+
+try: # reached as a script: this directory is on the path, the root is not
+ import _script_root # noqa: F401
+except ModuleNotFoundError: # pragma: no cover - reached as scripts.x instead
+ try:
+ from scripts import _script_root # noqa: F401
+ except ModuleNotFoundError:
+ pass
+
+
+import argparse
+import json
+from datetime import date
+from typing import Any, Callable
+
+try:
+ import swisseph as swe
+except ImportError: # pragma: no cover - optional runtime dependency
+ swe = None
+
+try:
+ from scripts.classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
+ from scripts.western_chart_engine import build_tropical_natal_chart
+ from scripts.western_chart_engine import build_draconic_chart, build_relocation_chart
+ from scripts.western_timing_engine import (
+ calculate_annual_profection,
+ calculate_converse_secondary_progressions,
+ calculate_converse_solar_arc_directions,
+ calculate_lunar_return,
+ calculate_lunar_return_series,
+ calculate_midpoints,
+ calculate_parans_status,
+ calculate_secondary_progressions,
+ calculate_solar_arc_directions,
+ calculate_solar_return,
+ calculate_tertiary_progressed_moon,
+ calculate_transit_duration_scan,
+ calculate_transit_to_natal,
+ calculate_zodiacal_release,
+ )
+except ModuleNotFoundError: # pragma: no cover - direct script execution
+ from classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
+ from western_chart_engine import build_tropical_natal_chart
+ from western_chart_engine import build_draconic_chart, build_relocation_chart
+ from western_timing_engine import (
+ calculate_annual_profection,
+ calculate_converse_secondary_progressions,
+ calculate_converse_solar_arc_directions,
+ calculate_lunar_return,
+ calculate_lunar_return_series,
+ calculate_midpoints,
+ calculate_parans_status,
+ calculate_secondary_progressions,
+ calculate_solar_arc_directions,
+ calculate_solar_return,
+ calculate_tertiary_progressed_moon,
+ calculate_transit_duration_scan,
+ calculate_transit_to_natal,
+ calculate_zodiacal_release,
+ )
+
+
+def _executed(inputs: dict[str, Any], calculation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+ try:
+ return {"status": "executed", "input": inputs, "raw_result": calculation()}
+ except Exception as exc: # pragma: no cover - defensive orchestration boundary
+ return {
+ "status": "blocked",
+ "input": inputs,
+ "reason": f"{exc.__class__.__name__}: {exc}",
+ }
+
+
+def _blocked(reason: str, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
+ result: dict[str, Any] = {"status": "blocked", "reason": reason}
+ if inputs:
+ result["input"] = inputs
+ return result
+
+
+def _reference_layer(
+ reference_date: str | None,
+ birth: dict[str, Any],
+ calculation: Callable[[str], dict[str, Any]],
+) -> dict[str, Any]:
+ if not reference_date:
+ return _blocked("reference_date is required")
+ return _executed({"reference_date": reference_date}, lambda: calculation(reference_date))
+
+
+def _overall_status(layers: dict[str, dict[str, Any]]) -> str:
+ return "complete" if all(layer["status"] == "executed" for layer in layers.values()) else "partial"
+
+
+def _eclipse_events(reference_date: str) -> dict[str, Any]:
+ if swe is None:
+ raise RuntimeError("swisseph is unavailable")
+ start = date.fromisoformat(reference_date)
+ jd = swe.julday(start.year, start.month, start.day, 0.0)
+ events = []
+ for kind, finder in (("solar", swe.sol_eclipse_when_glob), ("lunar", swe.lun_eclipse_when)):
+ flags, times = finder(jd)
+ year, month, day, hour = swe.revjul(times[0])
+ events.append({"kind": kind, "maximum_utc": f"{year:04d}-{month:02d}-{day:02d}T{int(hour):02d}:{int((hour % 1) * 60):02d}:00Z", "swiss_event_flags": int(flags)})
+ return {"technique": "eclipse_triggers", "reference_date": reference_date, "events": events, "boundary": "Geometric eclipse triggers only; no predictive interpretation is inferred."}
+
+
+def build_unified_western_reading(
+ *,
+ reference_date: str | None = None,
+ duration_scan_end_date: str | None = None,
+ lunar_return_months: int = 1,
+ relocation_latitude: float | None = None,
+ relocation_longitude: float | None = None,
+ relocation_timezone: str | float | int | None = None,
+ zodiacal_release_lot: str = "spirit",
+ zodiacal_release_levels: int = 2,
+ include_classical_usable_loop: bool = False,
+ classical_event_window_hours: int = 4,
+ classical_event_step_hours: int = 2,
+ classical_blind_scoring_manifest: str | None = None,
+ classical_reviewer_manifest: str | None = None,
+ **birth: Any,
+) -> dict[str, Any]:
+ """Run every available native Western layer and preserve raw evidence.
+
+ ``reference_date`` drives the one-day timing techniques. A duration scan
+ additionally needs an explicit end date, so a caller cannot unknowingly
+ request an unbounded scan. Eclipse geometry uses the local Swiss Ephemeris
+ calculation and does not infer an event interpretation.
+ """
+ layers: dict[str, dict[str, Any]] = {
+ "natal": _executed(dict(birth), lambda: build_tropical_natal_chart(**birth)),
+ "classical": _executed(
+ {"reference_date": reference_date},
+ lambda: build_classical_astrology_packet(reference_date=reference_date, **birth),
+ ),
+ "draconic": _executed(dict(birth), lambda: build_draconic_chart(**birth)),
+ "transits": _reference_layer(
+ reference_date, birth, lambda target: calculate_transit_to_natal(target_date=target, **birth)
+ ),
+ "solar_return": _reference_layer(
+ reference_date,
+ birth,
+ lambda target: calculate_solar_return(target_year=date.fromisoformat(target).year, **birth),
+ ),
+ "secondary_progressions": _reference_layer(
+ reference_date, birth, lambda target: calculate_secondary_progressions(target_date=target, **birth)
+ ),
+ "tertiary_moon": _reference_layer(
+ reference_date, birth, lambda target: calculate_tertiary_progressed_moon(target_date=target, **birth)
+ ),
+ "solar_arc": _reference_layer(
+ reference_date, birth, lambda target: calculate_solar_arc_directions(target_date=target, **birth)
+ ),
+ "midpoints": _reference_layer(
+ reference_date, birth, lambda target: calculate_midpoints(target_date=target, **birth)
+ ),
+ "lunar_return": _reference_layer(
+ reference_date, birth, lambda target: calculate_lunar_return_series(
+ target_date=target, months=lunar_return_months, **birth
+ )
+ ),
+ "parans": _reference_layer(
+ reference_date, birth, lambda target: calculate_parans_status(target_date=target, **birth)
+ ),
+ "profection": _reference_layer(
+ reference_date, birth, lambda target: calculate_annual_profection(target_date=target, **birth)
+ ),
+ "zodiacal_release": _reference_layer(
+ reference_date,
+ birth,
+ lambda target: calculate_zodiacal_release(
+ target_date=target,
+ lot=zodiacal_release_lot,
+ levels=int(zodiacal_release_levels),
+ **birth,
+ ),
+ ),
+ }
+ if relocation_latitude is None or relocation_longitude is None or relocation_timezone is None:
+ layers["relocation"] = _blocked(
+ "relocation_latitude, relocation_longitude, and relocation_timezone are required"
+ )
+ else:
+ layers["relocation"] = _executed(
+ {
+ "relocation_latitude": relocation_latitude,
+ "relocation_longitude": relocation_longitude,
+ "relocation_timezone": relocation_timezone,
+ },
+ lambda: build_relocation_chart(
+ relocation_latitude=float(relocation_latitude),
+ relocation_longitude=float(relocation_longitude),
+ relocation_timezone=relocation_timezone,
+ **birth,
+ ),
+ )
+ if reference_date:
+ layers["converse"] = _executed(
+ {"reference_date": reference_date},
+ lambda: {
+ "secondary_progressions": calculate_converse_secondary_progressions(
+ target_date=reference_date, **birth
+ ),
+ "solar_arc_directions": calculate_converse_solar_arc_directions(
+ target_date=reference_date, **birth
+ ),
+ },
+ )
+ else:
+ layers["converse"] = _blocked("reference_date is required")
+
+ if not reference_date:
+ layers["transit_duration"] = _blocked("reference_date is required")
+ elif not duration_scan_end_date:
+ layers["transit_duration"] = _blocked("duration_scan_end_date is required")
+ else:
+ layers["transit_duration"] = _executed(
+ {"start_date": reference_date, "end_date": duration_scan_end_date},
+ lambda: calculate_transit_duration_scan(
+ start_date=reference_date, end_date=duration_scan_end_date, **birth
+ ),
+ )
+ layers["eclipses"] = _reference_layer(reference_date, birth, _eclipse_events)
+ if include_classical_usable_loop:
+ layers["classical_usable_loop"] = _executed(
+ {
+ "reference_date": reference_date,
+ "event_window_hours": classical_event_window_hours,
+ "event_step_hours": classical_event_step_hours,
+ "blind_scoring_manifest": classical_blind_scoring_manifest,
+ "reviewer_manifest": classical_reviewer_manifest,
+ },
+ lambda: build_classical_usable_loop_audit_packet(
+ reference_date=reference_date,
+ event_window_hours=int(classical_event_window_hours),
+ event_step_hours=int(classical_event_step_hours),
+ blind_scoring_manifest_path=classical_blind_scoring_manifest,
+ reviewer_manifest_path=classical_reviewer_manifest,
+ **birth,
+ ),
+ )
+ return {
+ "system": "western_astrology",
+ "status": _overall_status(layers),
+ "reference_date": reference_date,
+ "layers": layers,
+ "boundary": "Raw native calculations only; this entrypoint makes no predictive interpretation.",
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Run the unified native Western calculation layers.")
+ parser.add_argument("--input-json", required=True, help="Structured birth and timing input as JSON.")
+ args = parser.parse_args(argv)
+ payload = json.loads(args.input_json)
+ if not isinstance(payload, dict):
+ raise ValueError("--input-json must decode to an object")
+ print(json.dumps(build_unified_western_reading(**payload), ensure_ascii=False, default=str))
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/scripts/western_chart_engine.py b/scripts/western_chart_engine.py
index 3d5d71a4..4bdc54e4 100644
--- a/scripts/western_chart_engine.py
+++ b/scripts/western_chart_engine.py
@@ -153,6 +153,21 @@ def _birth_zone(value: str | float | int):
return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}"
+def _birth_times(
+ *,
+ year: int,
+ month: int,
+ day: int,
+ hour: int,
+ minute: int,
+ second: int,
+ timezone: str | float | int,
+) -> tuple[datetime, datetime, str]:
+ zone, timezone_label = _birth_zone(timezone)
+ local = datetime(year, month, day, hour, minute, second, tzinfo=zone)
+ utc = local.astimezone(ZoneInfo("UTC"))
+ return local, utc, timezone_label
+
def build_tropical_natal_chart(
*,
year: int,
@@ -217,6 +232,100 @@ def build_tropical_natal_chart(
}
+def build_draconic_chart(**birth: Any) -> dict[str, Any]:
+ """Derive a draconic chart by rotating tropical longitudes to the true node Aries point."""
+ natal_chart = build_tropical_natal_chart(**birth)
+ natal = natal_chart["natal"]
+ node_longitude = natal["planets"]["true_node"]["longitude"]
+
+ def _rotate_point(point: dict[str, Any]) -> dict[str, Any]:
+ rotated = _point(point["longitude"] - node_longitude)
+ if "house" in point:
+ rotated["house"] = point["house"]
+ if "speed_longitude" in point:
+ rotated["speed_longitude"] = point["speed_longitude"]
+ rotated["retrograde"] = point.get("retrograde", False)
+ return rotated
+
+ rotated_planets = {name: _rotate_point(point) for name, point in natal["planets"].items()}
+ rotated_angles = {name: _rotate_point(point) for name, point in natal["angles"].items()}
+ rotated_houses = [
+ {"house": row["house"], "cusp": _rotate_point(row["cusp"])}
+ for row in natal["houses"]
+ ]
+ aspect_points = {**rotated_planets, "ascendant": rotated_angles["ascendant"], "mc": rotated_angles["mc"]}
+ return {
+ "technique": "draconic_chart",
+ "status": "used",
+ "source_engine": natal_chart["source_engine"],
+ "base_zodiac": natal_chart["zodiac"],
+ "derived_zodiac": "draconic",
+ "node_basis": "true_node",
+ "node_longitude": node_longitude,
+ "draconic": {
+ "ascendant": rotated_angles["ascendant"],
+ "mc": rotated_angles["mc"],
+ "angles": rotated_angles,
+ "planets": rotated_planets,
+ "houses": rotated_houses,
+ "aspects": _aspects(aspect_points),
+ "distribution": _distribution(rotated_planets),
+ "house_ruler_chains": _ruler_chains(
+ [row["cusp"]["longitude"] for row in rotated_houses],
+ rotated_planets,
+ ),
+ },
+ "boundary": "Longitude rotation to the true-node Aries point only; interpretation and timing remain separate audited layers.",
+ }
+
+def build_relocation_chart(
+ *,
+ relocation_latitude: float,
+ relocation_longitude: float,
+ relocation_timezone: str | float | int,
+ house_system: str = "P",
+ **birth: Any,
+) -> dict[str, Any]:
+ """Calculate a relocated chart for the same UTC birth instant at a new location."""
+ local, utc, _ = _birth_times(
+ year=int(birth["year"]),
+ month=int(birth["month"]),
+ day=int(birth["day"]),
+ hour=int(birth["hour"]),
+ minute=int(birth["minute"]),
+ second=int(birth.get("second", 0)),
+ timezone=birth["timezone"],
+ )
+ relocation_zone, relocation_label = _birth_zone(relocation_timezone)
+ relocation_local = utc.astimezone(relocation_zone)
+ relocated_chart = build_tropical_natal_chart(
+ year=relocation_local.year,
+ month=relocation_local.month,
+ day=relocation_local.day,
+ hour=relocation_local.hour,
+ minute=relocation_local.minute,
+ second=relocation_local.second,
+ latitude=float(relocation_latitude),
+ longitude=float(relocation_longitude),
+ timezone=relocation_timezone,
+ house_system=house_system,
+ )
+ return {
+ "technique": "relocation_chart",
+ "status": "used",
+ "source_engine": relocated_chart["source_engine"],
+ "same_birth_utc": utc.isoformat(),
+ "source_local_time": local.isoformat(),
+ "relocation_local_time": relocation_local.isoformat(),
+ "relocation_timezone": relocation_label,
+ "relocation_coordinates": {
+ "latitude": float(relocation_latitude),
+ "longitude": float(relocation_longitude),
+ },
+ "relocated_chart": relocated_chart,
+ "boundary": "Same UTC birth instant with relocated coordinates and timezone; interpretation and relocation outcome judgment remain separate audited layers.",
+ }
+
def _request_field(body: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in body and body[key] not in (None, ""):
diff --git a/scripts/western_real_case_calibration.py b/scripts/western_real_case_calibration.py
index f59bf8de..dd727c16 100644
--- a/scripts/western_real_case_calibration.py
+++ b/scripts/western_real_case_calibration.py
@@ -16,6 +16,25 @@ except ImportError:
DEFAULT_MANIFEST = Path("references/real_case_calibration/replay_manifest.json")
+def _build_calibration_summary(primary_theme: str = "general") -> dict:
+ """Import shim for classical_astrology.
+
+ Upstream's scoring body is not in this tree. The caller only reads these
+ keys and treats a missing gate as blocked, so full-reading can import the
+ classical packet without inventing a specificity score.
+ """
+ return {
+ "status": "blocked",
+ "reason": "specificity_gate_not_ported",
+ "primary_theme": primary_theme,
+ "false_positive_audit": {"frozen_negative_holdout": {"status": "blocked"}},
+ "positive_replay": {},
+ "positive_holdout": {},
+ "domain_replay_scoring": {},
+ "review_pack": {},
+ }
+
+
def build_case(case_id: str, manifest_path: Path = DEFAULT_MANIFEST) -> dict:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
case = next((row for row in manifest["cases"] if row["case_id"] == case_id), None)
diff --git a/tests/test_full_reading_module_parity.py b/tests/test_full_reading_module_parity.py
new file mode 100644
index 00000000..b4d4e278
--- /dev/null
+++ b/tests/test_full_reading_module_parity.py
@@ -0,0 +1,230 @@
+"""full-reading keeps the upstream module keys and does not go online."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+from scripts.jaimini import SIGNS
+from scripts.jyotish_engine import cmd_full_reading
+from scripts.professional_report_reference import _export_args
+from scripts.punarphoo import detect_punarphoo
+from scripts.rashi_dashas_native import SIGN_LORDS, navamsha_sign
+
+ROOT = Path(__file__).resolve().parents[1]
+VALIDATOR = (
+ ROOT
+ / "docs/research/upstream_full_synastry_bde5d615/skill/scripts/validate_full_synastry_packets.py.txt"
+)
+
+# Baseline full-reading keys on origin/staging b3ef2d815, plus the five keys this
+# change adds. 81 + 5 = the upstream 86-key set.
+UPSTREAM_MODULE_KEYS = frozenset({
+ "actionable_context", "argala", "ashtakavarga", "ashtottari_dasha", "aspects",
+ "audit", "avasthas", "bhava_bala", "bhava_chalit", "bhrigu_pada_dasha",
+ "career_strict_evidence", "chart", "chaturshitisama_dasha", "congregation",
+ "d9_navamsa_expanded", "dasa_convergence", "dasha", "dasha_sandhi",
+ "dispositor_chains", "double_transit_pac", "dwadashottari_dasha",
+ "dwisaptatisama_dasha", "final_dispositors", "finance_strict_evidence",
+ "functional_benefic_malefic", "guided_topics", "house_map", "inter_chart_linkage",
+ "jaimini", "kalachakra_dasha", "kp", "lagna_vargottamamsa", "marriage_counting",
+ "moon_chart", "muntha", "nakshatra_advanced", "nakshatra_dasha", "narayana_dasha",
+ "navamsha_dasha", "niryaana_shoola_dasha", "pancha_pakshi", "panchanga",
+ "panchottari_dasha", "planetary_congregation", "planetary_friendship", "prashna",
+ "punarphoo", "pushkara", "pushkara_planets", "rashi_tulya_navamsa",
+ "relationship_strict_evidence", "remedies", "sahams", "satabdika_dasha",
+ "sensitive_points", "shadbala", "shastihayani_dasha", "shattrimshatsama_dasha",
+ "shodashottari_dasha", "solar_return", "source_priority", "special_lagnas",
+ "sudarshana", "tajika", "tajika_yogas", "tithi_lord", "transit_ll7l",
+ "transit_multi_reference", "transit_positions", "tribhagi_40_dasha",
+ "tribhagi_dasha", "trimshamsa_d30", "unified_western_reading", "upagrahas",
+ "validation", "varga_extended", "varga_full", "varga_research_high", "vargottama",
+ "vedastro_official_full_snapshot", "vedastro_range_scan_result", "vimsopaka",
+ "vivah_saham", "yoga", "yogas_doshas", "yogini_dasha",
+})
+
+
+def _case(path: Path, case_id: str) -> dict:
+ rows = json.loads(path.read_text(encoding="utf-8"))
+ birth = next(row for row in rows if row["id"] == case_id)
+ return {
+ "year": birth["year"],
+ "month": birth["month"],
+ "day": birth["day"],
+ "hour": birth["hour"],
+ "minute": birth["minute"],
+ "second": birth.get("second") or 0,
+ "lat": birth["lat"],
+ "lon": birth["lon"],
+ "tz": birth["tz"],
+ "ayanamsa": "lahiri",
+ "node_mode": "mean",
+ "today": "2026-10-18",
+ }
+
+
+@pytest.fixture(scope="module")
+def reports():
+ cases = {
+ "pitt": _case(ROOT / "tests/indastro_cases.json", "brad_pitt"),
+ "jolie": _case(ROOT / "tests/celebrity_cases.json", "jolie"),
+ }
+ return {name: cmd_full_reading(_export_args(birth)) for name, birth in cases.items()}
+
+
+def _validator():
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader("validate_full_synastry_packets", str(VALIDATOR))
+ spec = importlib.util.spec_from_loader(loader.name, loader)
+ module = importlib.util.module_from_spec(spec)
+ loader.exec_module(module)
+ return module
+
+
+def _goel_method_1_first(d9_lagna: int, lord_sign_by_sign: dict[int, int]) -> tuple[int, int]:
+ start = d9_lagna if d9_lagna % 2 == 0 else (d9_lagna + 6) % 12
+ lord_sign = int(lord_sign_by_sign[start]) % 12
+ if start % 2 == 0:
+ distance = (lord_sign - start) % 12
+ else:
+ distance = (start - lord_sign) % 12
+ years = 12 if distance == 0 else distance + 1
+ return start, years
+
+
+def test_unified_western_reading_module_imports() -> None:
+ import unified_western_reading
+
+ assert callable(unified_western_reading.build_unified_western_reading)
+
+
+def test_engine_does_not_import_the_retired_niryaana_file() -> None:
+ source = (ROOT / "scripts/jyotish_engine.py").read_text(encoding="utf-8")
+ assert "from niryaana_shoola_dasha import" not in source
+ assert "import niryaana_shoola_dasha" not in source
+
+
+def test_full_reading_module_keys_cover_the_upstream_set(reports) -> None:
+ assert len(UPSTREAM_MODULE_KEYS) == 86
+ for name, report in reports.items():
+ missing = UPSTREAM_MODULE_KEYS - set(report["modules"])
+ assert not missing, name
+
+
+def test_simple_modules_match_the_upstream_shapes(reports) -> None:
+ for report in reports.values():
+ modules = report["modules"]
+ lagna = modules["lagna_vargottamamsa"]
+ assert set(lagna) == {"d1_sign", "d9_sign", "is_lagna_vargottamamsa", "source"}
+ assert lagna["source"] == "raman_horoscope_v1_bounded_pp64"
+ assert lagna["is_lagna_vargottamamsa"] is bool(
+ lagna["d1_sign"] and lagna["d9_sign"] and lagna["d1_sign"] == lagna["d9_sign"]
+ )
+ flags = modules["pushkara"]
+ expected = [
+ f"{planet} ({data['sign']} {data['degree_in_sign']:.1f}°)"
+ for planet, data in flags.items()
+ if data.get("pushkara_navamsa") or data.get("pushkara_bhaga")
+ ]
+ assert modules["pushkara_planets"] == expected
+ assert modules["punarphoo"] == detect_punarphoo(report["chart"]["planets"])
+
+
+def test_secondary_dashas_use_native_profiles_and_match_goel_method_1(reports) -> None:
+ for report in reports.values():
+ modules = report["modules"]
+ navamsha = modules["navamsha_dasha"]
+ niryaana = modules["niryaana_shoola_dasha"]
+ assert navamsha["status"] == "parameter_sensitive"
+ assert navamsha["profile_id"] == "goel_method_1_v1"
+ assert niryaana["status"] == "parameter_sensitive"
+ assert niryaana["profile_id"] == "goel_nirayana_shoola_v1"
+ assert navamsha["native_result"]["status"] == "listed"
+ first = navamsha["periods"][0]
+ d9 = modules["varga_full"]["D9_Navamsa"]
+ d9_lagna = int(d9["Ascendant"]["sign_idx"])
+ lord_signs = {
+ index: int(d9[SIGN_LORDS[SIGNS[index]]]["sign_idx"])
+ for index in range(12)
+ }
+ # Upstream goel_method_1_v1 reads the lord's D9 sign. Ours derives the
+ # same sign from the natal longitude. Both must name the same first major.
+ assert (first["sign_index"], first["years"]) == _goel_method_1_first(d9_lagna, lord_signs)
+ longitudes = {
+ name: float(row["degree_raw"])
+ for name, row in report["chart"]["planets"].items()
+ if isinstance(row, dict) and isinstance(row.get("degree_raw"), (int, float))
+ }
+ asc_lon = float(report["chart"]["ascendant"]["degree_raw"])
+ longitude_lords = {
+ index: navamsha_sign(longitudes[SIGN_LORDS[SIGNS[index]]])
+ for index in range(12)
+ }
+ assert (first["sign_index"], first["years"]) == _goel_method_1_first(
+ navamsha_sign(asc_lon), longitude_lords
+ )
+
+
+def test_synastry_validator_accepts_the_secondary_dashas(reports) -> None:
+ validator = _validator()
+ receipt = validator.build_execution_receipt(reports["pitt"], reports["jolie"])
+ rows = {row["technique"]: row for row in receipt["individual_technique_audit"]}
+ for label in ("Navamsha Dasha", "Niryaana Shoola Dasha"):
+ assert rows[label]["status"] in {"executed", "parameter_sensitive"}
+ tasks = {row["task"]: row for row in receipt["pair_execution_tasks"]}
+ assert tasks["secondary-dasha convergence and disagreement review"]["status"] != "blocked"
+
+
+def test_full_reading_still_succeeds_when_network_and_subprocess_raise() -> None:
+ import subprocess
+ import urllib.request
+
+ from unified_western_reading import build_unified_western_reading
+
+ calls = {"count": 0, "stacks": []}
+ original_urlopen = urllib.request.urlopen
+ original_run = subprocess.run
+
+ def boom(*_args, **_kwargs):
+ import traceback
+
+ calls["count"] += 1
+ calls["stacks"].append("".join(traceback.format_stack()))
+ raise RuntimeError("network and external programs are not allowed")
+
+ urllib.request.urlopen = boom
+ subprocess.run = boom
+ try:
+ birth = _case(ROOT / "tests/indastro_cases.json", "brad_pitt")
+ report = cmd_full_reading(_export_args(birth))
+ western = report["modules"]["unified_western_reading"]
+ assert western["status"] != "blocked"
+ # VedAstro's existing local runner may attempt subprocess and fail closed.
+ # The western packet must not be on that stack.
+ for stack in calls["stacks"]:
+ assert "unified_western_reading.py" not in stack
+ assert "classical_astrology.py" not in stack
+ calls["count"] = 0
+ packet = build_unified_western_reading(
+ year=birth["year"],
+ month=birth["month"],
+ day=birth["day"],
+ hour=birth["hour"],
+ minute=birth["minute"],
+ second=birth["second"],
+ latitude=birth["lat"],
+ longitude=birth["lon"],
+ timezone=birth["tz"],
+ reference_date=birth["today"],
+ duration_scan_end_date=birth["today"],
+ lunar_return_months=3,
+ )
+ assert packet["system"] == "western_astrology"
+ assert calls["count"] == 0
+ finally:
+ urllib.request.urlopen = original_urlopen
+ subprocess.run = original_run