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