From f224146348716bb7b99a29e26edaff09b95541ad Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 3 Sep 2026 20:28:57 +0800 Subject: [PATCH] feat(upstream): merge a6f47abd engine, MCP, and orchestrator Three-way-merge calculation modules and pl9-export into the product fork while keeping commercial API routes, Raman ayanamsa, and the consultation contract as a keypath superset. Co-authored-by: Cursor --- docs/tasks/PROGRESS-upstream-sync-20260903.md | 157 +- mcp_server.py | 663 +- references/ai-reading-workflow-prompt.md | 9 +- .../interpretation_template_registry.json | 145 + .../mandatory-verification-gate-protocol.md | 39 + ...p_numeric_oracle_readiness_2026_07_23.json | 66 + ...act_cusp_closure_dashboard_2026_08_22.json | 47 + ...exact_cusp_mainline_status_2026_08_22.json | 176 + .../kp_real_event_replay_gate_2026_07_30.json | 65 + scripts/ashtottari_dasha.py | 287 +- scripts/ashtottari_rule_profiles.py | 90 + scripts/domain_calculation_service.py | 6 + scripts/gulika.py | 142 +- scripts/jaimini.py | 137 +- scripts/jyotish_engine.py | 11165 +++++++++++++++- scripts/kp_system.py | 335 +- scripts/muhurta.py | 1618 ++- scripts/narayana_dasha.py | 572 + scripts/report_orchestrator.py | 33 + scripts/report_pack_contract.py | 254 + scripts/run_quality_gate.py | 13 + scripts/solar_return.py | 84 +- scripts/tajika.py | 279 +- scripts/tajika_kernel.py | 64 +- scripts/unified_consultation_orchestrator.py | 561 +- scripts/varga.py | 54 +- scripts/western_timing_engine.py | 230 +- .../consultation_contract_keypaths_v1.json | 98 + tests/test_gulika.py | 8 +- ...est_report_orchestrator_reader_contract.py | 2 +- 30 files changed, 17151 insertions(+), 248 deletions(-) create mode 100644 references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json create mode 100644 references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json create mode 100644 references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json create mode 100644 references/oracle/kp_real_event_replay_gate_2026_07_30.json create mode 100644 scripts/ashtottari_rule_profiles.py create mode 100644 scripts/report_pack_contract.py diff --git a/docs/tasks/PROGRESS-upstream-sync-20260903.md b/docs/tasks/PROGRESS-upstream-sync-20260903.md index 7fe2455c..98516f99 100644 --- a/docs/tasks/PROGRESS-upstream-sync-20260903.md +++ b/docs/tasks/PROGRESS-upstream-sync-20260903.md @@ -5,14 +5,15 @@ 上游只读克隆:`/Users/jesse/Downloads/Copse/astrology/.upstream-readonly/yinduzhanxing`(HTTPS + `gh auth git-credential`;未写入、未提交、未推送) 任务书:`docs/tasks/TASK-upstream-sync-20260903.md` -未碰 `main`。未改 `.gitea/workflows/**`。任务 2 未开。 +未碰 `main`。未改 `.gitea/workflows/**`。任务 2 已合完,未提交。 ## 基线与 rebase | 点 | SHA | 说明 | | --- | --- | --- | | 开工基线 | `58cae371` | 当时的 `origin/staging`(含并行规则那段任务书) | -| 当前 HEAD | `01a45363` | rebase 到已含整备 0–3 的 `origin/staging` | +| rebase 后 HEAD | `01a45363` | rebase 到已含整备 0–3 的 `origin/staging` | +| 任务 0/1/4 提交 | `45d13258` | 快照 + 真相层 + VedAstro REST;任务 2 尚未提交 | | 整备合入 | `b6a70aa7`、`2415e751`、`01a45363` | 岁差参考集 / 发布门与产品 URL / 整备进度与状态板 | | 上游 pin | `a6f47abd2c9b7c6baa911ea00c971e1ec902380c` | `source_committed_at=2026-09-03T05:33:11Z` | | 上游 tree | `650551ddcaddce0dacc76360efca425158ebb8d4` | `source_tree_sha256=790fd5c05ffd6c1a17c6347259143d3c174e0e425246ea2207a8405f7bf6eaed` | @@ -25,9 +26,9 @@ rebase 无冲突。任务 2 的 ours 以 rebase 后的工作树为准,没有 | 任务 | 状态 | 说明 | | --- | --- | --- | -| 0 快照 / golden / 冲突表 | 完成(未提交) | 只写了导入记录、上游镜像、golden、新测试;语义合并六路径保持 `review_required` | -| 1 真相层 | 完成(未提交) | 116 项注册表、覆盖层 25+Ashtottari、根 SKILL 三段、徽章、`6.9.15` 版本目录 | -| 2 引擎三方合并 | 未开 | 工作清单见下方冲突表(rebase 后复核) | +| 0 快照 / golden / 冲突表 | 完成(`45d13258`) | 导入记录、上游镜像、golden、新测试;语义合并六路径保持 `review_required` | +| 1 真相层 | 完成(`45d13258`) | 116 项注册表、覆盖层 25+Ashtottari、根 SKILL 三段、徽章、`6.9.15` 版本目录 | +| 2 引擎三方合并 | 合完(未提交) | 零/低/高冲突文件已按硬红线 5 裁决;API 保持商业文件;golden 已重采;quick 门 Python 段与任务 1 同绿 | 任务 4 写在 `PROGRESS-upstream-capabilities-20260903.md`。 @@ -168,6 +169,148 @@ tests/test_import_yinduzhanxing.py 4. 上游 25 项的 command/output_path 按本仓实文件改过,没有把 `classical_astrology.py` / ziwei 研究脚本带进来。 5. 为避免 capability audit 把 `report`/`timing` 域误判成 thematic-report API,从网页未执行项的 domains 里拿掉了这些触发词;ID 与覆盖层不变。 -## 下一步 +## 任务 2 -任务 2:rebase 后的冲突表,从零冲突文件 `jaimini.py` / `muhurta.py` / `western_timing_engine.py` / `solar_return.py` 开始 `git merge-file`。 +三方基线:ours = `45d13258` 工作树,base = `5db72537`,theirs = `a6f47abd`。硬红线 3 校正文件跳过。`technique_registry.json` 已在任务 1 处理。 + +### 零冲突文件(全取 merge-file,0 hunk) + +| 文件 | 取舍 | 测试 | +| --- | --- | --- | +| `scripts/jaimini.py` | theirs 新函数 | `test_jaimini.py` + `test_jaimini_rangacharya_api.py` 绿 | +| `scripts/muhurta.py` | theirs +1600 | `test_muhurta.py` 绿。`test_muhurta_factor_probe` 的 `full_scoring_status` 基线已是 `factor_only_scoring_observation`,与本合并无关 | +| `scripts/western_timing_engine.py` | theirs +230 | `test_western_timing_engine.py` / `test_western_chart_engine.py` 绿 | +| `scripts/solar_return.py` | theirs +84 | 无专属测试;`py_compile` 过 | + +### 低冲突计算模块 + +| 文件 | hunks | 取舍 | 理由 | +| --- | ---: | --- | --- | +| `scripts/tajika_kernel.py` | 0 | merge-file | 无冲突 | +| `scripts/varga.py` | 1 | theirs + `mapping_status` / `normalize_varga_ascendant` | 计算正确性;`test_varga_bphs` 绿。`test_varga_vedastro_mode` 在 HEAD 已红(D2 Ascendant Leo vs Sagittarius),不是本轮新增 | +| `scripts/tajika.py` | 1 | theirs 新年主/PyJHora sidecar 辅助函数 | 纯新增;import 在本仓可解析。工作机路径只作可选探测 | +| `scripts/gulika.py` | 1 | theirs 新 Upagraha 函数 + `DAY_RULERS` 常量 | 新函数。`calculate_gulika` 默认 Raman;PyJHora smoke 测例显式 `ayanamsa="lahiri"` | +| `scripts/kp_system.py` | 3 | theirs `kp_maturity_profile` / `build_kp_report_pack` | 无守护读取 oracle,已带入 4 个 JSON(打开过,无出生资料)。另两个已在本仓的 hash/workflow 文件一并读取 | +| `scripts/ashtottari_dasha.py` | 5 | 手工:保留 paksha 白名单(改从 `ashtottari_rule_profiles` 导入)+ theirs 候选规则族 | 任务 1 已解冻的 `rahu_from_lagna_lord_kendra_trikona_plus_paksha_daynight`。纯计算模块 `ashtottari_rule_profiles.py` 一并带入 | +| `scripts/narayana_dasha.py` | 4 | theirs 新 profile/序列函数;**去掉** Gemini/Sagittarius 1993 平局硬编码 | 默认 `legacy_*` 保持本仓 AD 行为。`test_narayana_dasha.py` 绿 | +| `scripts/ayanamsa_utils.py` | 5 | **全部 ours** | 商业岁差 API(`DEFAULT_AYANAMSA_NAME` / `UnsupportedAyanamsaError`)。auto-merge 曾把 `apply_ayanamsa` 的未知岁差改成吞掉 ValueError,已撤回 | +| `scripts/domain_calculation_service.py` | 5 | **hunks ours**;补 `is_supported_ayanamsa_name` import | `position_mode` 不取(引擎尚未接)。transit 校验是 theirs 非冲突插入 | +| `scripts/run_quality_gate.py` | 4 | **全部 ours**(CORE 列表 / glob 展开 / 门禁范围) | 不让上游换掉商业 quick 列表。profile 里多了未使用的 `test_timeout_seconds` 与 `--test-timeout` 参数,未接入 pytest 调用 | +| `scripts/report_orchestrator.py` | 1 | ours 商业 `CANONICAL_DOMAINS` import | `build_report_language_contract_audit` 本仓无模块,本地 fallback `blocked` | +| `scripts/report_pack_contract.py` | 1(新文件) | theirs 合同壳 + 缺失研究模块 fallback | 不拖 `canonical_jyotish_profile` / dashboard 研究脚本 | + +KP 带入(无隐私): + +- `references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json` +- `references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json` +- `references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json` +- `references/oracle/kp_real_event_replay_gate_2026_07_30.json` + +### 产品面数据(ours==base,0 hunk,快进 theirs) + +- `references/interpretation_template_registry.json` +145 +- `references/ai-reading-workflow-prompt.md` +9 +- `references/mandatory-verification-gate-protocol.md` +39 + +无 `1993-04-17` / `14:49` / nadi 清单。 + +### 高冲突入口 + +#### `scripts/unified_consultation_orchestrator.py`(18 hunks) + +**18 hunks 全 ours**(商业路由/reader 合同/分盘命名)。再手工:health `focus_techniques` 对齐上游并保留 `non-medical boundary`;`route_surface_contract` 用本仓 `normalize_domain`(wealth 为运行时名,finance 为别名,**不**引入会把 wealth 改成 finance 的 `route_aliases.py`);`evidence_packet_required_sections` health 加 D6/D8/D30/Narayana/功能吉凶/transit/non_medical;专家判断影子层因缺 `expert_judgment` 返回 `blocked`;不取 `build_full_spectrum_invocation_plan`。`test_unified_consultation_orchestrator.py` 全绿。 + +#### `scripts/jyotish_engine.py`(25 hunks) + +| hunk | 函数 | 取舍 | 理由 | +| ---: | --- | --- | --- | +| 1 | 顶层 import | 手工 | `Path` / `SimpleNamespace` / `Any` + `re` / `contextlib` / `io` | +| 2 | import | 手工 | 只留 `build_guided_topics`;本地 stub `build_report_theme_catalog` / `attach_calculation_profile`;不导入缺失的 startrack / calculation_profile_contract | +| 3 | `_calc_sidereal_planets_for_jd` | theirs 速度旗标 + 补 `calc_flags` | 计算正确性;ours 同函数无商业改动 | +| 4 | 新 PL9/报告辅助函数(~9107 行) | theirs + 消毒 | 纯新增且 import 可解析或已守护。去掉 1993 候选表加载、Domi 控制样本、`14:49`、本机路径 | +| 5 | envelope / startrack | 手工 stub | `_build_response_envelope` **原样返回 payload**,避免 CLI/API 合同变形 | +| 6 | `_add_chart_args` | ours | `DEFAULT_AYANAMSA_NAME`;不接 `position-mode` / `chesta-profile` CLI(shadbala 无该参数) | +| 7 | `_build_ai_prompt_pack` 岁差默认 | 手工 | name=`DEFAULT_AYANAMSA_NAME`,display=`Raman`(产品默认,不再写 Lahiri) | +| 8 | `compute_chart_data` | theirs 速度旗标 + 补 `calc_flags` | 计算正确性;签名保持无 `position_mode` | +| 9 | `cmd_chart` | ours | 不包 envelope | +| 10 | `cmd_dasha` 日期轴 | ours | 商业 MD 用完整年数从出生前回推,与 balance 展示数学等价 | +| 11 | `cmd_dasha` AD 字段 | theirs | 超集:`start_datetime` + `pratyantar_dasha_timeline` | +| 12 | `cmd_dasha` result | ours | 不接缺失的 `canonical_jyotish_profile` | +| 13 | `cmd_shadbala` | ours | 无 `chesta_profile`;不覆盖本地 ranking | +| 14 | `_resolve_tajika_year_lord` | theirs | 新年主辅助;缺 `calc_panchadhikari_year_lord` 时 try/except 回退 | +| 15 | `cmd_tajika` return | ours | 不包 envelope | +| 16 | natal foundation helpers | theirs | 内部已 try/except;full-reading 暂未接线 | +| 17 | `cmd_full_reading` friendship | theirs | `planetary_friendship` 模块 | +| 18 | `build_pl9_style_export_packet` 后半 + `cmd_pl9_export` | 手工 | 收到 `cmd_pl9_export` 为止;**不取** visual-ocr / classical / nadi | +| 19–20 | `main` 岁差/`position-mode` | ours | 商业岁差 API | +| 21 | `pl9-export` argparse | 手工 | 只注册 `pl9-export`,不注册 visual-ocr | +| 22–23 | `main` 岁差应用 | ours | fail-closed `UnsupportedAyanamsaError` | +| 24 | 命令表 | 手工 | 加 `pl9-export`;不加 classical/nadi/visual | +| 25 | `pl9-export` 输出 | 手工 | json / markdown / pdf / **authority**(MCP 工具需要);缺模块降级 blocked | + +`cmd_classical_*` / `cmd_nadi_*` 未取。auto-merge 进来的 argparse 与 dispatch 已删。缺失研究模块一律 stub / `_try_attr_import`,不拖脚本。 + +消毒:`_load_1993_candidate_segment_table` 恒返回 `None`;`_is_domi_pl9_control_case` 恒 `False`;Domi 视觉页 PDF/fixture 合同(原按空日期 + 控制样本时刻触发)整段不导入。`scripts/tajika.py` 的 PyJHora sidecar 只认 `JYOTISH_PYJHORA_PYTHON` / `python3.14` / `python3`,不带本机用户路径。`scripts/narayana_dasha.py` 把 `observed_pl9_1993_case_only` 改成 `observed_pl9_control_case_only`(答案泄漏字面量守卫)。 + +虚构 Beijing 1990-01-01 12:00:`full-reading`(quick 门 `test_cli_smoke` 全绿)、`pl9-export --format json` schema=`pl9_style_professional_export_v1`、树内 `status=failed` 为 0;岁差日志 `[Ayanamsa] raman`。缺研究文件的段是 `blocked`(可接受)。 + +#### `scripts/jyotish_api_server.py`(38 hunks) + +**全部 ours。** 商业路由、`_request_ayanamsa`、咨询工作流、校正入口都不让上游改。auto-merge 曾把 nadi leaf assistant 整段插进来(+1500 行,会打穿 growth cap 11363),已整文件回到合并前的商业版本(11120 行)。nadi 路由不取。`test_api_server_growth_contract.py`、`test_commercial_domain_calculation_contract.py` 绿。 + +任务 5 的 `/api/professional_report_reference` 仍按任务书另开,不在本文件加功能体。 + +#### `mcp_server.py`(11 hunks) + +| hunk | 取舍 | 理由 | +| ---: | --- | --- | +| 1 | ours(空) | `varga_evidence_matrix` / `full_spectrum_invocation_plan` / `domain_profile_builder` 本仓没有 | +| 2 | theirs | `_single_varga_engine_call` / `_dasha_at_date` 纯辅助 | +| 3 | 手工 | 只加 `engine_observations`;不调缺失的 `build_domain_profile` | +| 4 | 手工 | 保留 D10,并入 D4/D11/D16/D24 | +| 5–6 | ours(空) | calculation archive / varga matrix 模块缺失 | +| 7 | ours | 商业 `calculate_transit` 体 | +| 8 | theirs | 给 transit 补 `ayanamsa`/`date` | +| 9 | theirs | `_build_execution_receipt` | +| 10 | theirs | `route_surface_contract`(编排器已有) | +| 11 | ours(空) | `_attach_requested_official_formal_varga_audit` 未带入 | + +三个新工具(`get_pl9_expert_reader_report` / `get_pl9_full_report_authority` / `route_jyotish_question`)是非冲突插入,已保留。`route_jyotish_question` 对缺失 `knowledge_registry` 返回 blocked。 + +H03 曾让 `_attach_top_reader_contract` 直接调用 `_build_engine_observations()`。该函数依赖本仓没有的 `load_external_profiles`,NameError 会打断整个 `strict-evidence-collector`,咨询/报告里的 `career_strict_evidence` / `confidence_boundary` 随之消失。现已在调用处 try/except,缺模块时写入 `status=blocked`。`tests/test_cli_smoke.py` 相关 4 条与 thematic 报告 5 条已绿。 + +校正:`active_rectification_*` 按硬红线 3 跳过。 + +### 被触碰的既有断言(任务 2) + +| 文件 | 原值 | 现在 | 原因 | +| --- | --- | --- | --- | +| `tests/test_gulika.py` PyJHora smoke | 隐式 Lahiri(函数写死 `SIDM_LAHIRI`) | 显式 `ayanamsa="lahiri"` | 函数现接收岁差,产品默认 Raman;该 oracle 是 Lahiri | +| `tests/test_report_orchestrator_reader_contract.py` `source_mode` | `archive` | `git` | 任务 1 快照已改成 git,这条测试当时没跟上 | + +### 因缺文件而 blocked(有守护,未拖研究脚本) + +- `_event_replay_contract` / 1993 候选分段表:不导入 +- `_load_rectification_evidence_contract`:缺 JSON 时 blocked +- `_profile_aware_benchmark_appendix` / ch10 ashtama / chara-narayana 书测 JSON:缺则空或 blocked +- MCP `engine_observations`:`evidence_maturity` 不在本仓 → blocked +- MCP `route_jyotish_question`:`knowledge_registry` 不在本仓 → blocked +- 专家判断影子层:`expert_judgment` 不在本仓 → blocked +- `full_report_quality_gate` / `kp_monthly_report_packet` / startrack / calculation archive:blocked +- `report_quality_gate.reason=full_report_quality_gate_absent` + +### 基线已红、本轮未新增 + +- `tests/test_varga_vedastro_mode.py`:HEAD 的 `calc_varga(..., mode="vedastro")` 已与 golden 不一致 +- `tests/test_muhurta_factor_probe.py::test_muhurta_factor_probe_keeps_full_scoring_blocked`:HEAD 已是 `factor_only_scoring_observation` +- `frontend/tests/class-name-definition-contract.test.ts`:`ayanamsa-preference` 无 CSS 规则。`origin/staging` 同文件同红(整备岁差选择器只复用了 `sheet-section`)。任务 2 未改 `frontend/**`,fail 数不高于基线。 + +### 门禁与契约 + +- `run_quality_gate.py --profile quick` Python 段:**503 passed, 1 skipped**(与任务 1 相同)。随后 `npm test`:**2607 pass / 1 fail**,即上面的 CSS 基线项;lint 未跑到,单独 `npm run lint`:**0 error**(73 warning,不修)。 +- `./node_modules/.bin/tsc --noEmit`:**0**(symlink 的主仓 `node_modules`,不提交)。 +- golden 已重采:14 个 case 全是超集(咨询链每题多 `routing.audit_route` / `document_route` / `registry_route` / `route_aliases` / `runtime_route`,以及 `runtime_evidence_log.{finance_astrology_support_review,kp_western_support,specialized_indian_closure_review}`;wealth skip_false 与 timing skip_true 另多 VedAstro gateway 键)。无丢键、无类型变化。career / wealth / health 的 `/api/consultation_workflow` 全部 HTTP 200。 +- `tests/test_api_server_growth_contract.py`、`tests/test_commercial_domain_calculation_contract.py` 绿。隐私扫描 `public_release_privacy_scan` / `commercial_privacy_artifact_scan`:**pass, 0 findings**。 +- `next build` 本轮未跑(前端字节未改)。staging 部署验收留到推送之后。 + +未 push。未提升 `main`。任务 3 / 5 / 6 / 7 按任务书另开 `codex/upstream-capabilities-20260903`。 diff --git a/mcp_server.py b/mcp_server.py index 763dcafb..56a640f9 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -28,6 +28,7 @@ it is not the runtime source of truth for this server. import sys import os import json +import hashlib import subprocess import asyncio from copy import deepcopy @@ -58,7 +59,7 @@ mcp = FastMCP( "Jyotish (Vedic Astrology) calculation engine. " "Provides chart calculation, Vimshottari Dasha, Shadbala, " "Ashtakavarga, Nakshatra analysis, and full-reading synthesis. " - "All calculations use Swiss Ephemeris (Lahiri ayanamsa). " + "Calculations use Swiss Ephemeris and must preserve the caller-selected ayanamsa when supplied. " "IMPORTANT: partial techniques (marked in audit) are approximate " "and should NOT be used as sole evidence for high-stakes predictions." ), @@ -94,6 +95,38 @@ def _run_engine(subcommand: str, args: Dict[str, Any]) -> Dict[str, Any]: return {"raw_output": result.stdout} +def _single_varga_engine_call(args: Dict[str, Any]) -> Dict[str, Any]: + """Preserve legacy single-varga MCP calls across the newer CLI split.""" + varga = str(args.get("varga") or "D9").strip().upper() + base_args = dict(args) + base_args.pop("varga", None) + if varga == "D9": + return _run_engine("varga", {**base_args, "d9": True}) + if varga == "D10": + return _run_engine("varga", {**base_args, "d10": True}) + return _run_engine("varga-full", {**base_args, "divisions": varga}) + + +def _dasha_at_date(timeline: List[Dict[str, Any]], requested: datetime) -> Dict[str, str]: + """Return the Maha/Antar Dasha overlapping an MCP-selected date.""" + target = requested.date() + for maha in timeline: + maha_start = datetime.strptime(maha["start"][:10], "%Y-%m-%d").date() + maha_end = datetime.strptime(maha["end"][:10], "%Y-%m-%d").date() + if not maha_start <= target < maha_end: + continue + + result = {"mahadasha": maha["lord"]} + for antar in maha.get("antardasha_timeline", []): + antar_start = datetime.strptime(antar["start"][:10], "%Y-%m-%d").date() + antar_end = datetime.strptime(antar["end"][:10], "%Y-%m-%d").date() + if antar_start <= target < antar_end: + result["antardasha"] = antar["lord"] + break + return result + return {} + + def _audit_status() -> Dict[str, Any]: """Run audit and return structured status.""" audit = os.path.join(SCRIPT_DIR, "scripts", "audit_capabilities.py") @@ -677,6 +710,7 @@ def _execute_mcp_consultation_workflow( tz: float, transit_date: str, node_mode: str, + ayanamsa: str = "raman", entry_mode: str = "direct_chart", theme: list[str] | None = None, events: list[dict[str, Any]] | None = None, @@ -698,6 +732,7 @@ def _execute_mcp_consultation_workflow( "tz": tz, "transit_date": transit_date, "node_mode": node_mode, + "ayanamsa": ayanamsa, "entry_mode": entry_mode, "theme": theme or [], "events": events or [], @@ -767,6 +802,9 @@ _VEDASTRO_ROUTE_DOMAIN = { "career": "career", "relationship": "marriage", "finance": "wealth", + "marriage": "marriage", + "wealth": "wealth", + "money": "wealth", } @@ -829,17 +867,22 @@ def _derive_yogi_wealth_support(modules: Dict[str, Any]) -> Optional[Dict[str, A asc_sign = _sign_from_longitude(asc_lon) planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} - sun_lon = _normalize_longitude(_safe_get(planets, "Sun", "degree_raw") or _safe_get(planets, "Sun", "degree")) - moon_lon = _normalize_longitude(_safe_get(planets, "Moon", "degree_raw") or _safe_get(planets, "Moon", "degree")) + sun_data = planets.get("Sun") if isinstance(planets.get("Sun"), dict) else {} + moon_data = planets.get("Moon") if isinstance(planets.get("Moon"), dict) else {} + sun_lon = _normalize_longitude(sun_data.get("degree_raw", sun_data.get("degree"))) + moon_lon = _normalize_longitude(moon_data.get("degree_raw", moon_data.get("degree"))) if sun_lon is None or moon_lon is None: return None - yogi_point_lon = (sun_lon + moon_lon) % 360.0 + # Match PyJHora's Yogi Sphuta profile: Sun + Moon + 93 degrees 20 minutes. + yogi_point_lon = (sun_lon + moon_lon + 93.0 + 20.0 / 60.0) % 360.0 + avayogi_point_lon = (yogi_point_lon + 186.0 + 40.0 / 60.0) % 360.0 yogi_nak_idx = int(yogi_point_lon // _NAKSHATRA_SPAN) % 27 yogi_point_nakshatra = _NAKSHATRA_NAMES[yogi_nak_idx] yogi_planet = _NAKSHATRA_LORDS[yogi_nak_idx] duplicate_yogi = _SIGN_LORDS[_sign_from_longitude(yogi_point_lon)] - avayogi = _NAKSHATRA_LORDS[(yogi_nak_idx + 6) % 27] + avayogi_nak_idx = int(avayogi_point_lon // _NAKSHATRA_SPAN) % 27 + avayogi = _NAKSHATRA_LORDS[avayogi_nak_idx] yogi_point_house = _house_from_longitude(yogi_point_lon, asc_sign) yogi_data = _planet_snapshot(planets, yogi_planet, asc_sign) @@ -884,10 +927,13 @@ def _derive_yogi_wealth_support(modules: Dict[str, Any]) -> Optional[Dict[str, A return { "level": level, "source": "yogi_asc_tight_orb_wealth", + "formula_profile": "pyjhora_yogi_sphuta_v1", + "claim_boundary": "legacy_finance_support_only", "yogi_planet": yogi_planet, "duplicate_yogi": duplicate_yogi, "avayogi": avayogi, "yogi_point_longitude": round(yogi_point_lon, 4), + "avayogi_point_longitude": round(avayogi_point_lon, 4), "yogi_point_nakshatra": yogi_point_nakshatra, "yogi_point_house": yogi_point_house, "lagna_yogi_distance_deg": lagna_yogi_distance, @@ -2997,7 +3043,7 @@ def _attach_prashna_guarded_evidence( "lat": request["lat"], "lon": request["lon"], "timezone": request["timezone"], - "ayanamsa": request.get("ayanamsa", "lahiri"), + "ayanamsa": request.get("ayanamsa", "raman"), "node_mode": request.get("node_mode", "mean"), "location_convention": "wgs84", } @@ -3291,6 +3337,160 @@ def _build_release_hygiene_plan() -> Dict[str, Any]: } +def _engine_observation_artifact(path: str) -> Dict[str, str] | None: + artifact_path = os.path.join(SCRIPT_DIR, path) + if not os.path.isfile(artifact_path): + return None + with open(artifact_path, "rb") as source: + return { + "path": path, + "sha256": hashlib.sha256(source.read()).hexdigest(), + } + + +def _engine_observed_output(path: str, *, scope: str) -> Dict[str, Any]: + artifact_path = os.path.join(SCRIPT_DIR, path) + try: + with open(artifact_path, "r", encoding="utf-8") as source: + return { + "scope": scope, + "is_current_chart": False, + "data": json.load(source), + } + except (OSError, ValueError, json.JSONDecodeError): + return {"scope": scope, "is_current_chart": False, "data": None} + + +def _build_engine_observations() -> List[Dict[str, Any]]: + # evidence_maturity.load_external_profiles is a research-only helper; this + # repo does not ship it. Callers already treat a raised exception as blocked. + if "load_external_profiles" not in globals(): + raise NameError("load_external_profiles") + profiles = load_external_profiles(os.path.join(SCRIPT_DIR, "tests", "fixtures", "evidence_maturity", "external_profiles_v1.json")) + local = { + "profile_id": "local_lahiri_mean_compat_v1", + "version": "runtime-current", + "source_kind": "local_native_engine_compatibility_profile", + "calculation_contract": {"ayanamsa": "Lahiri", "node_mode": "mean", "engine": "local"}, + "source_refs": ["scripts/jyotish_engine.py"], + } + pyjhora = profiles["pyjhora_lahiri_mean_compat_v1"] # historical compatibility profile, not current service default + jhora = { + "profile_id": "jhora_compatibility_v1", + "version": "compatibility-only", + "source_kind": "desktop_compatibility_profile", + "calculation_contract": {"ayanamsa": "Lahiri", "node_mode": "mean", "engine": "JHora"}, + "source_refs": [], + } + vedastro = profiles["vedastro_official_snapshot_v1"] + return [ + { + "engine": "local", + "profile": {"profile_id": local["profile_id"], "version": local["version"]}, + "execution_status": "current_chart_runtime", + "source_kind": local["source_kind"], + "effective_settings": local["calculation_contract"], + "source_refs": local["source_refs"], + "raw_artifact": None, + "observed_output": {"scope": "current_chart_runtime", "is_current_chart": True, "data": None}, + "input_scope": {"same_as_current_chart": True, "case_label": "current user chart"}, + "field_differences": [], + "upstream_source": {"kind": "local_native_engine", "status": "repository_runtime"}, + "identity_status": "local_repository", + "maturity": { + "calculated": {"status": "used", "reason": "current_chart_runtime"}, + "profile_validated": {"status": "partial", "reason": "profile_visible_not_globally_closed"}, + "external_parity": {"status": "blocked", "reason": "same_input_external_parity_not_attached"}, + "predictively_validated": {"status": "blocked", "reason": "independent_holdout_not_passed"}, + }, + "visibility_boundary": "Current chart uses this local profile; its states remain separate from external engines.", + }, + { + "engine": "PyJHora", + "profile": {"profile_id": pyjhora["profile_id"], "version": pyjhora["version"]}, + "execution_status": "archived_reference_observation", + "source_kind": pyjhora["source_kind"], + "effective_settings": pyjhora.get("effective_settings") or {}, + "source_refs": pyjhora["source_refs"], + "raw_artifact": _engine_observation_artifact("references/oracle/artifacts/pyjhora_steve_jobs_high_rigor_raw.json"), + "observed_output": _engine_observed_output( + "references/oracle/artifacts/pyjhora_steve_jobs_high_rigor_raw.json", + scope="archived_public_reference_case", + ), + "input_scope": { + "same_as_current_chart": False, + "case_label": "Steve Jobs public AA reference chart (1955-02-24, San Francisco)", + }, + "field_differences": [], + "upstream_source": { + "kind": "external_open_source_engine", + "url": "https://github.com/naturalstupid/PyJHora", + "status": "archived_observation_only", + }, + "identity_status": "package_version_not_fully_pinned", + "maturity": { + "calculated": {"status": "blocked", "reason": "current_chart_pyjhora_runtime_not_executed"}, + "profile_validated": {"status": "blocked", "reason": "typed_public_replay_not_complete"}, + "external_parity": {"status": "blocked", "reason": "same_input_comparison_not_archived"}, + "predictively_validated": {"status": "blocked", "reason": "independent_holdout_not_passed"}, + }, + "visibility_boundary": "Archived PyJHora observations are visible as reference evidence, not as a current-chart result or universal truth.", + }, + { + "engine": "JHora", + "profile": {"profile_id": jhora["profile_id"], "version": jhora["version"]}, + "execution_status": "not_executed", + "source_kind": jhora["source_kind"], + "effective_settings": jhora["calculation_contract"], + "source_refs": [], + "raw_artifact": None, + "observed_output": {"scope": "not_executed", "is_current_chart": False, "data": None}, + "input_scope": {"same_as_current_chart": False, "case_label": "no JHora desktop capture"}, + "field_differences": [], + "upstream_source": {"kind": "desktop_compatibility_profile", "status": "no_raw_source_archived"}, + "identity_status": "desktop_capture_missing", + "maturity": { + "calculated": {"status": "blocked", "reason": "desktop_runtime_not_captured"}, + "profile_validated": {"status": "blocked", "reason": "immutable_jhora_replay_missing"}, + "external_parity": {"status": "blocked", "reason": "same_input_jhora_raw_missing"}, + "predictively_validated": {"status": "blocked", "reason": "independent_holdout_not_passed"}, + }, + "visibility_boundary": "JHora compatibility profile only; no desktop raw capture is archived.", + }, + { + "engine": "VedAstro", + "profile": {"profile_id": vedastro["profile_id"], "version": vedastro["version"]}, + "execution_status": "archived_reference_observation", + "source_kind": vedastro["source_kind"], + "effective_settings": vedastro.get("effective_settings") or {}, + "source_refs": vedastro["source_refs"], + "raw_artifact": _engine_observation_artifact("references/oracle/artifacts/vedastro_steve_jobs_public_aa_divisional_raw.json"), + "observed_output": _engine_observed_output( + "references/oracle/artifacts/vedastro_steve_jobs_public_aa_divisional_raw.json", + scope="archived_public_reference_case", + ), + "input_scope": { + "same_as_current_chart": False, + "case_label": "Steve Jobs public AA reference chart (1955-02-24, San Francisco)", + }, + "field_differences": [], + "upstream_source": { + "kind": "hosted_api_observation", + "url": "https://api.vedastro.org", + "status": "hosted_identity_unproven", + }, + "identity_status": "blocked", + "maturity": { + "calculated": {"status": "blocked", "reason": "current_chart_vedastro_runtime_not_executed"}, + "profile_validated": {"status": "blocked", "reason": "official_profile_replay_not_complete"}, + "external_parity": {"status": "blocked", "reason": "hosted_deployment_identity_unproven"}, + "predictively_validated": {"status": "blocked", "reason": "independent_holdout_not_passed"}, + }, + "visibility_boundary": "Hosted VedAstro observations remain visible, but deployment identity is unproven and cannot establish truth or parity.", + }, + ] + + def _build_multi_reference_reading_summary(route: str, present: Dict[str, Any], strict: Dict[str, Any]) -> Dict[str, Any]: return { "root_frame": _summary_root_frame(route, present), @@ -3328,6 +3528,14 @@ def _attach_top_reader_contract(route: str, strict: Dict[str, Any]) -> Dict[str, strict["multi_reference_reading_summary"] = _build_multi_reference_reading_summary(route, present, strict) strict["official_day_signal_summary"] = _build_official_day_signal_summary(present.get("external_activation")) strict["monthly_adjudication_summary"] = _build_monthly_adjudication_summary(route, strict) + try: + strict["engine_observations"] = _build_engine_observations() + except Exception as exc: + strict["engine_observations"] = { + "status": "blocked", + "reason": f"engine_observations_unavailable:{exc.__class__.__name__}", + } + # This is report metadata only. The technique audit remains the execution record. strict["verdict"] = event_judgement.get("verdict") strict["dominant_label"] = event_judgement.get("dominant_label") strict["main_conflicts"] = strict.get("conflicts") or [] @@ -3681,7 +3889,12 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present = { "d2_hora": _safe_get(modules, "varga_full", "D2_Hora"), "d10_dasamsa": _safe_get(modules, "varga_full", "D10_Dasamsa"), + "d4_turyamsa": _safe_get(modules, "varga_full", "D4_Turyamsa"), + "d11_rudramsa": _safe_get(modules, "varga_full", "D11_Rudramsa"), + "d16_shodasamsa": _safe_get(modules, "varga_full", "D16_Shodasamsa"), + "d24_siddhamsa": _safe_get(modules, "varga_full", "D24_Siddhamsa"), "shadbala": _safe_get(modules, "shadbala", "planets"), + "bhava_bala": _safe_get(modules, "bhava_bala"), "ashtakavarga_house_scores": _safe_get(modules, "ashtakavarga", "house_scores"), "vimshottari_current": _safe_get(modules, "dasha", "current_dasha"), "narayana_current": _safe_get(modules, "narayana_dasha", "current_dasha"), @@ -3689,6 +3902,12 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An "gains_convergence": domain_activations.get("gains_wishes"), "chart": _safe_get(modules, "chart"), "career_convergence": domain_activations.get("career_status"), + "hora_lagna": _safe_get(modules, "special_lagnas", "Hora_Lagna"), + "indu_lagna": _safe_get(modules, "special_lagnas", "Indu_Lagna"), + "sree_lagna": _safe_get(modules, "special_lagnas", "Sree_Lagna"), + "dhan_saham": _safe_get(modules, "sahams", "dhan_saham"), + "artha_saham": _safe_get(modules, "sahams", "artha_saham"), + "labha_saham": _safe_get(modules, "sahams", "labha_saham"), "wealth_promise_strength": _derive_wealth_promise_strength(modules), "avayogi_risk": avayogi_risk, } @@ -3711,7 +3930,7 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules) present["interpretation_source_pack"] = _existing_interpretation_source_pack() missing = [key for key, value in present.items() if key not in { - "chart", "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "dignity_guardrail", "gains_convergence", "career_convergence", "avayogi_risk", "ashtakavarga_finance_support", "shadbala_component_audit", "asc_sign", "pav_finance_support", "sodhita_finance_support", "kakshya_finance_support", "functional_benefic_malefic", "interpretation_source_pack" + "chart", "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "dignity_guardrail", "gains_convergence", "career_convergence", "avayogi_risk", "ashtakavarga_finance_support", "shadbala_component_audit", "asc_sign", "pav_finance_support", "sodhita_finance_support", "kakshya_finance_support", "functional_benefic_malefic", "interpretation_source_pack", "d4_turyamsa", "d16_shodasamsa", "d24_siddhamsa", "bhava_bala", "hora_lagna", "indu_lagna", "sree_lagna", "dhan_saham", "artha_saham", "labha_saham" } and value in (None, {}, [], "")] convergence_hits: List[Dict[str, Any]] = [ item for item in [ @@ -3810,6 +4029,7 @@ def _maybe_attach_vedastro_evidence( tz: float, transit_date: str, node_mode: str, + ayanamsa: str = "raman", ) -> Dict[str, Any]: if not isinstance(result, dict) or result.get("error"): return result @@ -3845,7 +4065,8 @@ def _maybe_attach_vedastro_evidence( "ayanamsa_policy": ( _safe_get(result, "meta", "ayanamsa") or _safe_get(result, "chart", "ayanamsa") - or "lahiri" + or ayanamsa + or "raman" ), "node_policy": node_mode or "mean", }, @@ -3898,13 +4119,15 @@ def calculate_chart( lon: float, tz: float, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate a complete Vedic birth chart (D1 Rashi). Returns: planets with sidereal longitudes, houses (whole-sign), Nakshatra placements, dignity levels, and combustion status. - Uses Swiss Ephemeris with Lahiri ayanamsa. + Uses Swiss Ephemeris with caller-selected ayanamsa (current default Raman). Args: year: Birth year (e.g. 1990) @@ -3916,15 +4139,17 @@ def calculate_chart( lon: Longitude in decimal degrees (east positive, e.g. 77.20) tz: Timezone offset from UTC in hours (e.g. 5.5 for IST, 8.0 for CST) node_mode: 'mean' (default) or 'true' for lunar node calculation + ayanamsa: sidereal zodiac system, e.g. 'lahiri', 'raman', 'kp', 'true_citra' Returns: JSON with planets, houses, ascendant, Nakshatras, dignities """ return _run_engine("chart", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -3941,6 +4166,8 @@ def calculate_dasha( start_date: Optional[str] = None, years: int = 10, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate Vimshottari Dasha (planetary period) timeline. @@ -3955,15 +4182,17 @@ def calculate_dasha( start_date: Optional start date (YYYY-MM-DD) for Dasha from a specific date years: Number of years to calculate from birth (default 10) node_mode: 'mean' or 'true' + ayanamsa: sidereal zodiac system, e.g. 'lahiri', 'raman', 'kp', 'true_citra' Returns: JSON with Dasha periods, start/end dates, and current Dasha at birth """ args = { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "years": years, "node_mode": node_mode, + "ayanamsa": ayanamsa, } if start_date: args["start_date"] = start_date @@ -3981,6 +4210,8 @@ def calculate_shadbala( lon: float, tz: float, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate Shadbala (six-fold planetary strength). @@ -4005,9 +4236,10 @@ def calculate_shadbala( """ return _run_engine("shadbala", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4022,6 +4254,8 @@ def calculate_ashtakavarga( lon: float, tz: float, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate Ashtakavarga (eight-fold strength matrix). @@ -4041,9 +4275,10 @@ def calculate_ashtakavarga( """ return _run_engine("ashtakavarga", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4059,6 +4294,8 @@ def calculate_varga( tz: float, varga: str = "D9", node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate a specific Varga (divisional chart). @@ -4077,12 +4314,13 @@ def calculate_varga( Returns: JSON with varga chart planets and house placements """ - return _run_engine("varga", { + return _single_varga_engine_call({ "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "varga": varga, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4097,6 +4335,8 @@ def calculate_varga_full( lon: float, tz: float, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate ALL Vargas (D2 through D60) in one call. @@ -4118,9 +4358,10 @@ def calculate_varga_full( """ return _run_engine("varga-full", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4136,6 +4377,8 @@ def analyze_nakshatra( tz: float, mode: str = "full", node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Advanced Nakshatra analysis (Chandra Bala, Tara Bala, combined score). @@ -4158,10 +4401,11 @@ def analyze_nakshatra( """ return _run_engine("nakshatra-adv", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "mode": mode, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4176,6 +4420,8 @@ def calculate_yogas( lon: float, tz: float, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Detect Yogas (planetary combinations) in the birth chart. @@ -4201,9 +4447,10 @@ def calculate_yogas( """ return _run_engine("yoga", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4219,6 +4466,8 @@ def calculate_transit( tz: float, transit_date: str, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Calculate planetary transits for a specific date. @@ -4238,10 +4487,12 @@ def calculate_transit( """ return _run_engine("transit", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "transit_date": transit_date, "node_mode": node_mode, + "ayanamsa": ayanamsa, + "date": transit_date, }) @@ -4258,6 +4509,8 @@ def full_reading( age: int, transit_date: str, node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, ) -> Dict[str, Any]: """ Full Jyotish reading: all techniques in one synthesized report. @@ -4284,11 +4537,12 @@ def full_reading( """ return _run_engine("full-reading", { "year": year, "month": month, "day": day, - "hour": hour, "minute": minute, + "hour": hour, "minute": minute, "second": second, "lat": lat, "lon": lon, "tz": tz, "age": age, "transit_date": transit_date, "node_mode": node_mode, + "ayanamsa": ayanamsa, }) @@ -4310,6 +4564,27 @@ def get_audit_status() -> Dict[str, Any]: return _audit_status() +def _build_execution_receipt(chart: Any, runtime_planner: Any) -> Dict[str, Any]: + """Expose actual module returns without inferring execution from the plan.""" + chart_data = chart if isinstance(chart, dict) else {} + planner = runtime_planner if isinstance(runtime_planner, dict) else {} + module_returns = chart_data.get("modules") + module_returns = module_returns if isinstance(module_returns, dict) else {} + profile = chart_data.get("calculation_profile") + profile = profile if isinstance(profile, dict) else {} + return { + "schema_version": "1.0", + "status": "executed" if module_returns else "no_module_returns", + "input_hash": profile.get("input_hash"), + "result_hash": chart_data.get("result_hash"), + "module_count": len(module_returns), + "module_returns": module_returns, + "executed_steps": list(planner.get("executed_steps") or []), + "skipped_steps": list(planner.get("skipped_steps") or []), + "boundary": "Module returns are actual chart module outputs; planned steps alone do not prove execution. input_hash/result_hash are present only when the chart producer supplied a bound calculation profile.", + } + + @mcp.tool() def strict_workflow( question: str, @@ -4324,6 +4599,7 @@ def strict_workflow( age: int, transit_date: str, node_mode: str = "mean", + ayanamsa: str = "raman", western_evidence_packet: Optional[Dict[str, Any]] = None, western_oracle_payload: Optional[Dict[str, Any]] = None, prashna_request: Optional[Dict[str, Any]] = None, @@ -4370,6 +4646,7 @@ def strict_workflow( tz=tz, transit_date=transit_date, node_mode=node_mode, + ayanamsa=ayanamsa, entry_mode="direct_chart", theme=normalized_themes, western_evidence_packet=western_evidence_packet, @@ -4377,6 +4654,13 @@ def strict_workflow( ) chart = result.get("chart") if isinstance(result, dict) else {} route = _safe_get(result, "routing", "question_type") or route_packet["question_type"] or "general" + route_surface = _UNIFIED_CONSULTATION_ORCHESTRATOR.route_surface_contract(route) + if isinstance(result, dict): + routing = result.get("routing") if isinstance(result.get("routing"), dict) else {} + result["routing"] = {**route_packet, **routing, **route_surface} + result["route_normalization"] = route_surface + planner = result.get("runtime_planner") if isinstance(result.get("runtime_planner"), dict) else {} + result["execution_receipt"] = _build_execution_receipt(chart, planner) if isinstance(chart, dict) and "error" not in chart: chart = _maybe_attach_vedastro_evidence( route, @@ -4391,6 +4675,7 @@ def strict_workflow( tz=tz, transit_date=transit_date, node_mode=node_mode, + ayanamsa=ayanamsa, ) result["chart"] = chart result["strict_workflow"] = _attach_prashna_guarded_evidence( @@ -4461,6 +4746,7 @@ def life_event_graph( age: int, transit_date: str, node_mode: str = "mean", + ayanamsa: str = "raman", ) -> Dict[str, Any]: """ Build a graph-friendly event timeline from strict workflow evidence. @@ -4482,6 +4768,7 @@ def life_event_graph( age=age, transit_date=transit_date, node_mode=node_mode, + ayanamsa=ayanamsa, ) route = _safe_get(result, "routing", "question_type") or "general" strict = result.get("strict_workflow") if isinstance(result, dict) else {} @@ -4497,6 +4784,342 @@ def life_event_graph( # Skill experience tools # ============================================================================ +_FULL_REPORT_AUTHORITY_FIELDS = frozenset( + { + "schema_version", + "authority_id", + "report_id", + "report_version", + "report_contract_version", + "status", + "publication_status", + "read_only", + "report_metadata", + "chart_identity", + "lineage", + "quality_gate_reference", + "content_reference", + "surface_slices", + "limitations", + "judgment_lineage", + "publication_boundary", + } +) + + +_EXPERT_READER_DELIVERY_BOUNDARY = { + "requested_scope": "expert_workspace_review_only", + "authorization_status": "caller_asserted_not_authenticated", + "boundary": ( + "The local MCP transport has no identity or role verification. This " + "field selects a governed delivery mode only; it is not an access grant " + "and cannot authorize product publication." + ), +} + + +def _project_full_report_authority(payload: Any) -> Dict[str, Any]: + """Return only the governed Authority envelope from a PL9 engine response.""" + authority = payload if isinstance(payload, dict) else {} + if authority.get("schema_version") != "jyotish.shared_full_report_authority.v1": + return { + "schema_version": "jyotish.shared_full_report_authority.v1", + "status": "blocked", + "publication_status": "blocked", + "read_only": True, + "limitations": ["shared_full_report_authority_missing_or_invalid"], + } + return { + key: deepcopy(value) + for key, value in authority.items() + if key in _FULL_REPORT_AUTHORITY_FIELDS + } + + +def _render_pl9_reader_report(packet: Dict[str, Any]) -> str: + """Render an already assembled packet without exposing that packet itself.""" + from jyotish_engine import render_pl9_reader_markdown + + return render_pl9_reader_markdown(packet) + + +def _build_report_assembly_manifest(packet: Any) -> Dict[str, Any]: + """Expose assembled report-state metadata without copying worksheets.""" + source = packet if isinstance(packet, dict) else {} + report_pack = source.get("full_report_pack") if isinstance(source.get("full_report_pack"), dict) else {} + sections = report_pack.get("sections") if isinstance(report_pack.get("sections"), dict) else {} + quality_gate = source.get("report_quality_gate") if isinstance(source.get("report_quality_gate"), dict) else {} + ai_audit = ( + source.get("worksheets", {}).get("ai_and_audit", {}) + if isinstance(source.get("worksheets"), dict) + else {} + ) + evidence_profiles = ai_audit.get("evidence_profiles") if isinstance(ai_audit, dict) else {} + reference_parity = ai_audit.get("reference_parity") if isinstance(ai_audit, dict) else {} + section_statuses = { + str(section_id): str(section.get("status") or "available") + for section_id, section in sections.items() + if isinstance(section, dict) + } + coverage_fields = { + "material_id", + "admission_tier", + "report_role", + "source_reference", + "status", + "source_status", + "surface_location", + "rendered", + "limitation_reference", + } + coverage_manifest = [ + {key: deepcopy(value) for key, value in item.items() if key in coverage_fields} + for item in quality_gate.get("professional_coverage_manifest", []) + if isinstance(item, dict) + ] + return { + "schema_version": "jyotish.pl9_report_assembly_manifest.v1", + "report_pack_schema": report_pack.get("schema"), + "section_statuses": section_statuses, + "quality_gate": { + "status": quality_gate.get("status"), + "blocking_reasons": list(quality_gate.get("blocking_reasons") or []), + "review_reasons": list(quality_gate.get("review_reasons") or []), + "warning_reasons": list(quality_gate.get("warning_reasons") or []), + }, + "professional_coverage_manifest": coverage_manifest, + "evidence_profiles": deepcopy(evidence_profiles) if isinstance(evidence_profiles, dict) else {}, + "reference_parity_status": ( + reference_parity.get("status") if isinstance(reference_parity, dict) else "not_available" + ), + "claim_boundary": ( + "This manifest reports assembled report and evidence states. It " + "does not prove independent numerical parity or turn partial " + "techniques into verified judgment authority." + ), + } + + +def _build_expert_reader_report_response(packet: Any) -> Dict[str, Any]: + """Project an assembled PL9 packet into an expert-review-only MCP response.""" + source = packet if isinstance(packet, dict) else {} + authority = _project_full_report_authority(source.get("shared_full_report_authority")) + if authority.get("status") == "blocked": + return { + "schema_version": "jyotish.mcp_expert_reader_report.v1", + "status": "blocked", + "publication_status": "blocked", + "access_scope": "expert_workspace_review_only", + "access_control": deepcopy(_EXPERT_READER_DELIVERY_BOUNDARY), + "authority": authority, + "limitations": ["report_quality_gate_or_authority_blocked"], + } + try: + reader_markdown = _render_pl9_reader_report(source) + except Exception: + return { + "schema_version": "jyotish.mcp_expert_reader_report.v1", + "status": "blocked", + "publication_status": "blocked", + "access_scope": "expert_workspace_review_only", + "access_control": deepcopy(_EXPERT_READER_DELIVERY_BOUNDARY), + "authority": authority, + "limitations": ["reader_report_rendering_failed"], + } + return { + "schema_version": "jyotish.mcp_expert_reader_report.v1", + "status": authority.get("status", "review_required"), + "publication_status": authority.get("publication_status", "pending_review"), + "access_scope": "expert_workspace_review_only", + "access_control": deepcopy(_EXPERT_READER_DELIVERY_BOUNDARY), + "authority": authority, + "report_assembly_manifest": _build_report_assembly_manifest(source), + "reader_markdown": reader_markdown, + "limitations": [ + "expert_review_only", + "reader_report_is_not_automatic_product_publication", + "authority_and_chart_identity_status_remain_binding", + ], + } + + +@mcp.tool() +def get_pl9_full_report_authority( + year: int, + month: int, + day: int, + hour: int, + minute: int, + lat: float, + lon: float, + tz: float, + node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, + today: Optional[str] = None, + transit_date: Optional[str] = None, + target_year: Optional[int] = None, +) -> Dict[str, Any]: + """Build the read-only PL9/KP/three-year report Authority envelope. + + This is the MCP-safe access point for the existing shared full-report + authority. It executes the established PL9 export producer, but projects + only its governed reference envelope: no birth payload, raw calculation, + worksheet, report body, shadow artifact, or publication authority leaves + this tool. + """ + result = _run_engine( + "pl9-export", + { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + "lat": lat, + "lon": lon, + "tz": tz, + "node_mode": node_mode, + "ayanamsa": ayanamsa, + "today": today, + "transit_date": transit_date, + "target_year": target_year, + "pack": "full", + "format": "authority", + }, + ) + if not isinstance(result, dict) or result.get("error"): + return { + "schema_version": "jyotish.shared_full_report_authority.v1", + "status": "blocked", + "publication_status": "blocked", + "read_only": True, + "limitations": ["pl9_authority_export_failed"], + } + return _project_full_report_authority(result) + + +@mcp.tool() +def get_pl9_expert_reader_report( + year: int, + month: int, + day: int, + hour: int, + minute: int, + lat: float, + lon: float, + tz: float, + node_mode: str = "mean", + ayanamsa: str = "raman", + second: int = 0, + today: Optional[str] = None, + transit_date: Optional[str] = None, + target_year: Optional[int] = None, + access_scope: str = "expert_workspace_review_only", +) -> Dict[str, Any]: + """Return the governed PL9 reader report in an expert-review delivery mode. + + The report is generated by the existing full PL9 producer and retains its + D1-D60, D11, KP, and three-year material. The scope is caller asserted: + this local MCP transport does not authenticate identity or grant access. + The tool does not publish a report, promote a Chart Identity, or replace + the existing Web/API response. + """ + if access_scope != "expert_workspace_review_only": + return { + "schema_version": "jyotish.mcp_expert_reader_report.v1", + "status": "blocked", + "publication_status": "blocked", + "access_scope": "expert_workspace_review_only", + "access_control": deepcopy(_EXPERT_READER_DELIVERY_BOUNDARY), + "limitations": ["expert_workspace_review_scope_required"], + } + result = _run_engine( + "pl9-export", + { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "second": second, + "lat": lat, + "lon": lon, + "tz": tz, + "node_mode": node_mode, + "ayanamsa": ayanamsa, + "today": today, + "transit_date": transit_date, + "target_year": target_year, + "pack": "full", + }, + ) + if not isinstance(result, dict) or result.get("error"): + return { + "schema_version": "jyotish.mcp_expert_reader_report.v1", + "status": "blocked", + "publication_status": "blocked", + "access_scope": "expert_workspace_review_only", + "access_control": deepcopy(_EXPERT_READER_DELIVERY_BOUNDARY), + "limitations": ["pl9_reader_report_export_failed"], + } + return _build_expert_reader_report_response(result) + + +@mcp.tool() +def route_jyotish_question( + question: str, + product_surface_target: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a governed, metadata-only Evidence Request for an Agent question. + + The tool reads the immutable Jyotish Knowledge Registry snapshot and uses + the Question Router to select registered technique metadata. It never + invokes a calculator, generates Evidence, makes a Judgment, or returns a + user report. Callers must pass the returned request to later governed + runtime layers when those layers become available. + """ + from knowledge_registry.loader import RegistryLoadError, load_default_registry + from knowledge_registry.question_router import QuestionRouterError, route_question + + try: + if context is not None and not isinstance(context, dict): + raise QuestionRouterError("context_must_be_object") + routed = route_question( + load_default_registry(), + question, + context=context, + product_surface_target=product_surface_target, + ) + except (RegistryLoadError, QuestionRouterError, ValueError, ModuleNotFoundError, ImportError) as exc: + return { + "schema_version": "jyotish.agent_question_route.v1", + "status": "blocked", + "claim_boundary": "registry_metadata_only", + "blocked_reason": str(exc), + "limitations": [ + "no_calculation_execution", + "no_evidence_generation", + "no_judgment_or_report_output", + ], + } + + return { + "schema_version": "jyotish.agent_question_route.v1", + "status": "metadata_only", + "claim_boundary": "registry_metadata_only", + "registry_route": routed, + "limitations": [ + "no_calculation_execution", + "no_evidence_generation", + "no_judgment_or_report_output", + "technique_selection_is_not_execution", + ], + } + + @mcp.tool() def skill_onboarding(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Return the minimal next input or active rectification question set.""" diff --git a/references/ai-reading-workflow-prompt.md b/references/ai-reading-workflow-prompt.md index ea0363d0..df8457e3 100644 --- a/references/ai-reading-workflow-prompt.md +++ b/references/ai-reading-workflow-prompt.md @@ -561,7 +561,8 @@ AI不应该凭空假设一个时间,而应该通过结构化互动帮助用户 |----------|-------------|-------------| | 事业机会/职业方向/项目落地 | `career-timing-strict` | D1+D9+D10+Vimshottari+Jaimini+AmK/Karakamsha+AL/A10+Shadbala+AV+Argala | | 婚恋/婚姻/伴侣/关系结果 | `relationship-timing-strict` | 性别/昼夜确认+D1+D9+DK+UL+Double Transit+KP | -| 财运/收入/到账/资产 | `wealth-timing-strict` | 2H/11H+D2/D10+Shadbala+AV+Argala+KP | +| 财运/收入/到账/资产 | `finance-timing-strict`(兼容 `wealth-timing-strict`) | 2H/11H+D2/D10+Shadbala+AV+Argala+KP | +| 健康压力/住院风险/恢复窗口 | `health-timing-strict` | D1+D6+D8+D30+Vimshottari+Narayana+6/8/12轴+functional benefic/malefic+non-medical boundary | | 具体事件是否发生/何时发生 | `event-timing-strict` | 事件宫位定义+Dasha+Transit+KP+Moon trigger | | 历史事件验证/技法可靠性 | `event-verification-strict` | 同一技法回测+命中/失败评分+个人化规则 | @@ -569,11 +570,11 @@ AI不应该凭空假设一个时间,而应该通过结构化互动帮助用户 | 用户意图 | 目标宫位 | 核心参考文件 | 承诺模板 | |----------|---------|-------------|---------| -| 婚姻/恋爱/关系 | 7宫 | `strict-workflow-router.md` + `relationship-astrology-guide.md` | §1 | +| 婚恋/伴侣/关系 | 7宫 | `strict-workflow-router.md` + `relationship-astrology-guide.md` | §1 | | 事业/职业/工作 | 10宫 | `strict-workflow-router.md` + `house-modern-mapping.md` | §2 | -| 财富/收入/投资 | 2宫+11宫 | `strict-workflow-router.md` + `house-domain-planet-mapping.md` | §3 | +| 财务/收入/资产 | 2宫+11宫 | `strict-workflow-router.md` + `house-domain-planet-mapping.md` | §3 | | 子女/创作 | 5宫 | `modern-life-scenarios-complete.md` | §4 | -| 健康/体质 | 1宫+6宫+8宫 | — | §5 | +| 健康/体质 | 1宫+6宫+8宫 | `strict-workflow-router.md` + `varga-evidence-matrix.py` | §5 | | 教育/学业 | 4宫+9宫 | — | §6 | | 出国/迁移 | 9宫+12宫 | — | §7 | | 灵性/修行 | 9宫+12宫 | — | §8 | diff --git a/references/interpretation_template_registry.json b/references/interpretation_template_registry.json index e2b2ecd4..8bed94d8 100644 --- a/references/interpretation_template_registry.json +++ b/references/interpretation_template_registry.json @@ -418,6 +418,151 @@ "When the ascendant is close to a boundary, explicitly say the result is sensitive and should be verified with life events, D9/D10 and timing evidence.", "Frame ascendant degree interpretation as a launch-style or embodiment modifier, not a standalone verdict on wealth, marriage or destiny." ] + }, + "dual_luminary_timing_context": { + "title": "Dual-Luminary Timing Context Clause", + "status": "frozen_template", + "authority_level": "B/C guarded", + "domain": ["timing", "transit", "dasha", "moon", "lagna"], + "source_refs": [ + "references/mevg_chinese_full_layer_and_practice_audit_2026_08_26.md", + "references/mandatory-verification-gate-protocol.md", + "references/strict-workflow-router.md" + ], + "trigger_patterns": [ + "双灯", + "Moon and Lagna", + "月亮和上升", + "行运应期", + "Dasha Transit" + ], + "required_cross_checks": [ + "Moon-based transit context", + "Lagna or Sun-based transit context", + "Dasha lord and antardasha lord activation", + "Question-domain varga and house promise", + "MEVG source receipt before timing language is strengthened" + ], + "confidence_ceiling": "B when Moon, Lagna/Sun, Dasha, varga and MEVG agree; C when only one luminary or one timing trigger is present.", + "forbidden_claims": [ + "One luminary alone proves timing", + "A transit alone can override Dasha and natal promise", + "Moon transit alone guarantees the event" + ], + "safe_output_patterns": [ + "Use Moon and Lagna/Sun context together before strengthening timing language.", + "If only one luminary supports the window, keep the result as a candidate window.", + "Dasha and transit agreement can raise review confidence, but not prediction certainty without external calibration." + ] + }, + "seventh_house_topic_split": { + "title": "Seventh-House Topic Split Clause", + "status": "frozen_template", + "authority_level": "B/C guarded", + "domain": ["relationship", "contract", "public", "opponent", "house_judgment"], + "source_refs": [ + "references/mevg_chinese_full_layer_and_practice_audit_2026_08_26.md", + "references/event_judgment_marriage.md", + "references/strict-workflow-router.md" + ], + "trigger_patterns": [ + "七宫", + "7th house", + "婚姻宫", + "合同", + "partner", + "open opponent" + ], + "required_cross_checks": [ + "Clarify the user's topic before selecting seventh-house meaning", + "Check 7L, Venus/Jupiter, DK, UL and D9 for marriage questions", + "Check contract/business significators for negotiation or client questions", + "Check public-facing and opponent indicators when the question is not romantic", + "MEVG receipt for the selected seventh-house topic" + ], + "confidence_ceiling": "B for topic-specific synthesis when matching varga/timing/source evidence exists; C when seventh-house evidence is broad or mixed.", + "forbidden_claims": [ + "The seventh house alone means romance", + "A seventh-house transit alone guarantees marriage", + "One seventh-house factor alone proves a specific partner or contract outcome" + ], + "safe_output_patterns": [ + "State which seventh-house meaning is being used: spouse, partner, contract, audience, or open opponent.", + "For marriage, route through D9, UL, DK, Venus/Jupiter and timing evidence before outcome language.", + "For business or public questions, avoid romance wording unless the user explicitly asked about relationship." + ] + }, + "empty_window_not_success": { + "title": "Empty Window Is Not Success Clause", + "status": "frozen_template", + "authority_level": "B/C guarded", + "domain": ["prediction_boundary", "timing", "transit", "dasha"], + "source_refs": [ + "references/mevg_chinese_full_layer_and_practice_audit_2026_08_26.md", + "references/prediction-output-protocol.md", + "references/mandatory-verification-gate-protocol.md" + ], + "trigger_patterns": [ + "空窗", + "没有阻碍", + "no blocker", + "一定成", + "必成" + ], + "required_cross_checks": [ + "Positive natal promise for the topic", + "Dasha activation of relevant houses and lords", + "Transit trigger instead of only absence of negative transit", + "Relevant varga support", + "Real-case or MEVG evidence before success wording" + ], + "confidence_ceiling": "C when evidence is only absence of blockers; B only when positive promise, timing activation, varga support and MEVG agree.", + "forbidden_claims": [ + "No blocker alone guarantees success", + "An empty adverse window alone proves the event", + "Absence of malefic testimony alone is enough for a prediction" + ], + "safe_output_patterns": [ + "No blocker means the chart is less obstructed, not that the event is promised.", + "Require positive promise and activation before calling a window supportive.", + "When only negative evidence is absent, describe it as cleaner background rather than success." + ] + }, + "dasha_boundary_not_event_date": { + "title": "Dasha Boundary Is Not Event Date Clause", + "status": "frozen_template", + "authority_level": "B/C guarded", + "domain": ["dasha", "timing", "event_prediction", "sandhi"], + "source_refs": [ + "references/mevg_chinese_full_layer_and_practice_audit_2026_08_26.md", + "references/dasa-convergence-methodology.md", + "assets/timing-prediction-template.md" + ], + "trigger_patterns": [ + "换运日", + "Dasha boundary", + "Dasha Sandhi", + "大运切换", + "事件日" + ], + "required_cross_checks": [ + "Vimshottari boundary and current subperiod", + "Narayana or other available Dasha cross-check for timing claims", + "Transit trigger around the candidate period", + "Question-domain varga and natal promise", + "MEVG and real-case calibration before exact-date language" + ], + "confidence_ceiling": "C for boundary-only timing; B only when multiple Dasha systems, transit trigger, promise and external evidence converge.", + "forbidden_claims": [ + "Dasha boundary alone is the event date", + "Dasha Sandhi alone guarantees the event", + "A period change alone proves exact timing" + ], + "safe_output_patterns": [ + "Treat Dasha boundaries as review windows, not exact event dates.", + "Use the boundary to widen or prioritize a candidate period only after promise and trigger evidence are present.", + "When only the Dasha boundary is available, ask for event verification or keep the claim downgraded." + ] } } } diff --git a/references/mandatory-verification-gate-protocol.md b/references/mandatory-verification-gate-protocol.md index baaca1d6..8a2bd015 100644 --- a/references/mandatory-verification-gate-protocol.md +++ b/references/mandatory-verification-gate-protocol.md @@ -73,6 +73,45 @@ - 补充查询可用中文(覆盖华语占星圈解读) - 每个解读点至少 1 个英文查询 +### Step V1.5:中文全层检索与错题保留 + +当问题涉及中文用户表达、中文平台流行说法、婚恋/事业/出入境等容易跨体系混用的主题时, +MEVG 不能只检索英文专业站。必须把中文平台写入检索计划,并记录每层状态: + +| 平台层 | 默认状态 | 记录要求 | +|--------|----------|----------| +| 微信 / 公众号 | required_for_chinese_topic | 记录关键词、文章标题/账号、采用/拒绝原因 | +| 小红书 | required_for_chinese_topic | 只作流行说法/错题来源,不作为高权威来源 | +| 知乎 | required_for_chinese_topic | 记录是否为印度占星、八字、西占或混合体系 | +| B 站 | required_for_chinese_topic | 记录视频号、标题、讲者/频道、时间戳或字幕证据 | +| YouTube 具名课/讲座 | required_when_available | 记录视频号、讲者、主张、时间戳/字幕证据 | +| 英文专业站/经典文本 | always_required | 作为主要 doctrine 仲裁层 | + +若中文平台结果跑题、混淆体系或只能作为负样本,必须写入 `rejected_or_mismatch_sources`, +不得静默删除。有效结论必须区分: + +- `accepted_doctrine_source` +- `context_only_source` +- `rejected_mismatch_source` +- `negative_sample` + +已入库候选视频号:`M2VnJnlLaOA`、`axiLhGsAOBE`、`XBOdbVHTTAQ`。这些只能作为检索锚点; +未冻结字幕、讲者、时间戳和 doctrine 主张前,不得升级为 canonical rule。 + +### Step V1.6:Mismatch 词表(命中即降级或阻断) + +以下混淆必须在 MEVG receipt 里显式标注,不得让其进入强结论: + +| Mismatch | 正确处理 | +|----------|----------| +| 本命落宫 ≠ 行运行运 | 本命结构只能说明承诺/倾向,不能替代 transit trigger | +| 西占木土合相 ≠ K.N. Rao double transit | 只能作为西占背景,不能冒充印度双重过境 | +| 八字换运 ≠ Dasha Sandhi | 可作为中文语境错题,不能作为 Jyotish 大运证据 | +| 出入境/旅行“双重过境” ≠ Jyotish double transit | 记录为同词异义,不能计入双重过境验证 | +| 空窗 ≠ 必成 | 缺少阻断只代表阻力少,不代表有正向承诺 | +| 换运日 ≠ 事件日 | Dasha boundary 只能作为 review window,不能单独定日 | +| Yoga 宫主名误标 | 输出前核对“宫主名 = 计算出的实际宫主”,不一致则 blocked | + ### Step V2:执行检索与收集 **最低检索量**(按分析等级): diff --git a/references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json b/references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json new file mode 100644 index 00000000..107f54a7 --- /dev/null +++ b/references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json @@ -0,0 +1,66 @@ +{ + "acceptance_gate": "Node mode must be explicitly pinned or licensed raw must supply the same-case request/response contract; replay mismatches must be explicitly source-attributed before numeric_oracle_partial.", + "boundary": "A complete public 12-cusp packet exists and is replay-ready. The only remaining mismatch is a source-attributed cusp-1 sub-sub boundary flip, while hosted raw-capture contract gaps still keep KP numeric oracle truth blocked.", + "claim_status": "public_packet_ready_truth_blocked", + "created_at": "2026-07-23", + "hosted_api_capture": { + "artifact": "references/oracle/kp_api_pinned_raw_capture_readiness_2026_07_23.json", + "artifact_sha256": "1df82e608af7708dd63000f82517235d053c314306e2ea191ebe3afcc5faa600", + "claim_status": "blocked_until_key_terms_version", + "provider_statuses": { + "AjmerAstro": "tool_surface_no_raw_contract", + "AstrologyAPI": "key_terms_version_required", + "RoxyAPI": "key_terms_version_required" + } + }, + "local_replay_delta": { + "artifact": "references/oracle/cases/kp_12_cusp_panchangbodh_steve_jobs_2026_07_23.local_replay_delta.json", + "artifact_sha256": "2165c4510845bb6ea4362de251704651474a2619018685e67eaf992d084b70db", + "claim_status": "replay_delta_observation_only", + "status": "mismatch_explained", + "summary": { + "degree_tolerance_arcsec": 30, + "lord_mismatch_count": 1, + "max_abs_arcsec_delta": 18, + "row_count": 12 + } + }, + "next_artifacts": [ + "references/oracle/artifacts/kp_api_raw_capture__.json", + "references/oracle/cases/kp_12_cusp_.local_replay_delta.json", + "references/oracle/kp_sub_sub_boundary_attribution_2026_07_29.json" + ], + "packet_hash": "3ca1dbc8f1bf76b78c3dd33bea344d2c06d12b37c46f70fe2f70c00a2e089607", + "production_tuning_allowed": false, + "readiness_status": "public_packet_ready_settings_and_replay_unclosed", + "ready_evidence": [ + "public_packet_has_all_12_exact_cusp_longitudes", + "public_packet_has_all_12_star_lords", + "public_packet_has_all_12_sub_lords", + "public_packet_has_all_12_sub_sub_lords", + "house_system_is_explicitly_visible_as_placidus", + "local_replay_delta_is_archived" + ], + "remaining_blockers": [ + "node_mode_not_explicitly_visible", + "local_replay_has_one_source_attributed_sub_sub_boundary_flip", + "hosted_kp_api_raw_capture_blocked_until_key_terms_version" + ], + "scope": "kp_12_cusp_numeric_oracle_readiness", + "source_packet": { + "artifact": "references/oracle/cases/kp_12_cusp_panchangbodh_steve_jobs_2026_07_23.json", + "artifact_sha256": "43b7967930cc3a5e5e0a016b66fd8f18058b1864b63373a24e568947e9b84f1b", + "ayanamsa": "KP implied by KP Birth Chart Calculator; exact ayanamsa setting not explicitly visible", + "claim_status": "ready_for_replay_packet", + "field_counts": { + "twelve_exact_cusp_longitudes": 12, + "twelve_star_lords": 12, + "twelve_sub_lords": 12, + "twelve_sub_sub_lords": 12 + }, + "house_system": "Placidus explicitly visible", + "packet_id": "kp_12_cusp_panchangbodh_steve_jobs_2026_07_23", + "timezone": "America/Los_Angeles / -08:00 inferred_not_page_visible" + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json b/references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json new file mode 100644 index 00000000..7573aa61 --- /dev/null +++ b/references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json @@ -0,0 +1,47 @@ +{ + "business_boundary": "This dashboard is a flattening layer only. It does not upgrade KP exact-cusp truth; it just makes the remaining closure paths consumable in one place.", + "created_at": "2026-08-22", + "human_brief": { + "human_brief": "KP exact cusp remains support-ready but not promotable: today's lane is licensed_raw_capture, gate is still closed because licensed_provider_not_terms_safe, and no revisit is recommended until new same-case artifact appears or one licensed provider becomes terms-safe for archived capture.", + "selected_lane": "licensed_raw_capture" + }, + "lane_count": 3, + "lanes": [ + { + "claim_status": "public_packet_ready_settings_and_replay_unclosed", + "lane": "mainline_status", + "next_action": "close settings_visibility by pinning timezone from one same-case source or licensed raw capture" + }, + { + "claim_status": "same_case_timezone_visible_source_located", + "lane": "timezone_source_hunt", + "next_action": "advance to one terms-safe licensed raw request/response capture for the PanchangBodh-equivalent KP cusp case" + }, + { + "claim_status": "licensed_raw_capture_lane_blocked_until_terms_safe_provider", + "lane": "licensed_raw_capture", + "next_action": "keep all hosted raw capture blocked until a provider publishes explicit archived-retention permission or a licensed same-case request/response contract is pinned" + } + ], + "production_tuning_allowed": false, + "promotion_gate": { + "gate_passed": false, + "remaining_hard_reasons": [ + "licensed_provider_not_terms_safe" + ] + }, + "recommended_next_action": "prioritize the timezone source-hunt lane first; if no same-case visible artifact can be landed, escalate to a terms-safe licensed raw capture", + "scope": "kp_exact_cusp_closure_dashboard", + "selector": { + "fallback_condition": "switch only if artifact path stays exhausted and one provider becomes terms-safe for archived same-case capture", + "fallback_lane": "licensed_raw_capture", + "selected_lane": "licensed_raw_capture", + "selection_reason": "timezone is now pinned from a same-case public source, so the remaining live lane is licensed raw capture" + }, + "staleness_revisit_gate": { + "next_revisit_trigger": "new same-case artifact appears or one licensed provider becomes terms-safe for archived capture", + "revisit_now": false, + "wait_reason": "reviewed local artifact sweep already failed to re-locate same-case timezone-visible screenshots, and no new provider terms-safe signal is recorded yet" + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json b/references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json new file mode 100644 index 00000000..6f449f44 --- /dev/null +++ b/references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json @@ -0,0 +1,176 @@ +{ + "business_boundary": "The exact-cusp lane is no longer an empty blocked lane. It is now usable for observation, replay-ready comparison, and timing-window support, but not for exact day-level event truth or final cusp-trigger claims.", + "claim_status": "public_packet_ready_settings_and_replay_unclosed", + "completed_lanes": [ + { + "business_value": "KP cusp/star/sub/sub-sub can already be displayed as observation in reports and audits", + "evidence": { + "artifact": "references/oracle/vedicastro_kp_house_cusp_probe_steve_jobs_2026_07_23.json", + "artifact_sha256": "468e6aa26ff5ebde8185db3badeaa7b087beca0c81f01ca71c68ce5b6073df8c", + "claim_status": "observation_only", + "engine": "VedicAstro", + "fields": [ + "cusp_longitude", + "star_lord", + "sub_lord", + "sub_sub_lord" + ], + "house_count": 12, + "raw_hash": "4f0a694b7cd498664785a2f2ab8ea4dc36037e905ac4ba019eae3268c5973d43", + "schema_fingerprint": { + "fields": [ + "Object", + "HouseNr", + "Rasi", + "LonDecDeg", + "SignLonDMS", + "SignLonDecDeg", + "DegSize", + "Nakshatra", + "RasiLord", + "NakshatraLord", + "SubLord", + "SubSubLord" + ], + "house_count": 12 + } + }, + "lane": "runtime_observation", + "status": "single_engine_observation_ready" + }, + { + "business_value": "a same-case 12-cusp packet already exists and can support replay-ready research comparison", + "evidence": { + "artifact": "references/oracle/cases/kp_12_cusp_panchangbodh_steve_jobs_2026_07_23.json", + "artifact_sha256": "43b7967930cc3a5e5e0a016b66fd8f18058b1864b63373a24e568947e9b84f1b", + "ayanamsa": "KP implied by KP Birth Chart Calculator; exact ayanamsa setting not explicitly visible", + "claim_status": "ready_for_replay_packet", + "field_counts": { + "twelve_exact_cusp_longitudes": 12, + "twelve_star_lords": 12, + "twelve_sub_lords": 12, + "twelve_sub_sub_lords": 12 + }, + "house_system": "Placidus explicitly visible", + "packet_id": "kp_12_cusp_panchangbodh_steve_jobs_2026_07_23", + "timezone": "America/Los_Angeles / -08:00 inferred_not_page_visible" + }, + "lane": "public_packet_capture", + "status": "public_packet_ready_settings_and_replay_unclosed" + }, + { + "business_value": "all 12 cusps are degree-close locally and only one cusp-1 sub-sub boundary mismatch remains named", + "evidence": { + "artifact": "references/oracle/cases/kp_12_cusp_panchangbodh_steve_jobs_2026_07_23.local_replay_delta.json", + "artifact_sha256": "2165c4510845bb6ea4362de251704651474a2619018685e67eaf992d084b70db", + "claim_status": "replay_delta_observation_only", + "status": "mismatch_explained", + "summary": { + "degree_tolerance_arcsec": 30, + "lord_mismatch_count": 1, + "max_abs_arcsec_delta": 18, + "row_count": 12 + } + }, + "lane": "local_replay_delta", + "status": "mismatch_explained" + }, + { + "business_value": "the last cusp-1 sub-sub flip has already been source-attributed to a rounded-display boundary issue rather than an unnamed timing-truth failure", + "evidence": { + "artifact": "references/oracle/kp_sub_sub_boundary_attribution_2026_07_29.json", + "boundary_probe": { + "distance_local_to_boundary_arcsec": 19.5, + "distance_screenshot_to_boundary_arcsec": 0.7, + "first_moon_sub_sub_at_deg": 149.1574166667 + }, + "finding": "The only remaining mismatch is a cusp-1 sub-sub-lord flip. Star and sub lords remain identical, while the visible degree gap is 17 arcseconds and the packet's Moon label implies hidden upstream precision beyond the rounded 29°09'26\" display." + }, + "lane": "boundary_attribution", + "status": "source_attributed_boundary_flip" + } + ], + "created_at": "2026-08-22", + "current_position": { + "what_is_not_true_yet": [ + "same-input verified exact-cusp oracle", + "precise cusp-level timing truth upgrade", + "negative-holdout-cleared event timing use" + ], + "what_is_true_now": [ + "exact cusp runtime raw is already displayable as observation", + "a same-case 12-cusp public packet already exists", + "local replay is degree-close and replay-ready", + "the cusp-1 sub-sub-lord mismatch source has already been explicitly named", + "Steve Jobs public birth sources already corroborate UTC -8:00 even though the KP case page itself does not visibly print timezone", + "PanchangBodh's own page family explicitly states KP uses Krishnamurti ayanamsha", + "node_mode does not appear in the local KP cusp request contract and is retained as metadata rather than a cusp-lane blocker", + "timing truth remains blocked because settings visibility and hosted raw capture are still open" + ] + }, + "exact_cusp_oracle_status": "blocked_but_narrowed", + "highest_value_next_actions": [ + "close settings_visibility by pinning timezone from one same-case source or licensed raw capture", + "close hosted_raw_capture by landing one terms-safe pinned provider request/response packet" + ], + "human_brief": { + "human_brief": "KP exact cusp remains support-ready but not promotable: today's lane is licensed_raw_capture, gate is still closed because licensed_provider_not_terms_safe, and no revisit is recommended until new same-case artifact appears or one licensed provider becomes terms-safe for archived capture.", + "selected_lane": "licensed_raw_capture" + }, + "open_source_parity_gate": { + "acceptance_criteria": [ + "same_case_visible_artifact captured", + "exact cusp numeric packet replayable", + "license-safe archived source attached" + ], + "blocked_reason": "Current mainline exact-cusp lane still lacks a frozen same-case artifact and a stable replayable numeric oracle.", + "canonical_baseline": "PyJHora", + "evidence_paths": [ + "https://github.com/naturalstupid/PyJHora" + ], + "missing_fields": [ + "same_case_visible_artifact", + "stable_exact_cusp_numeric_oracle", + "licensed_archived_capture" + ], + "next_action": "freeze one visible same-case source and one replayable numeric packet" + }, + "production_tuning_allowed": false, + "promotion_gate": { + "gate_passed": false, + "remaining_hard_reasons": [ + "licensed_provider_not_terms_safe" + ] + }, + "ready_evidence": [ + "public_packet_has_all_12_exact_cusp_longitudes", + "public_packet_has_all_12_star_lords", + "public_packet_has_all_12_sub_lords", + "public_packet_has_all_12_sub_sub_lords", + "house_system_is_explicitly_visible_as_placidus", + "local_replay_delta_is_archived" + ], + "ready_evidence_count": 6, + "remaining_blocker_count": 1, + "remaining_blockers": [ + { + "blocker": "hosted_kp_api_raw_capture_blocked_until_key_terms_version", + "impact": "keeps same-case hosted raw/hash oracle capture unavailable for promotion", + "lane": "hosted_raw_capture", + "next_action": "capture one terms-safe pinned raw request/response contract from an allowed provider" + } + ], + "scope": "kp_exact_cusp_mainline_status", + "selector": { + "fallback_condition": "switch only if artifact path stays exhausted and one provider becomes terms-safe for archived same-case capture", + "fallback_lane": "licensed_raw_capture", + "selected_lane": "licensed_raw_capture", + "selection_reason": "timezone is now pinned from a same-case public source, so the remaining live lane is licensed raw capture" + }, + "staleness_revisit_gate": { + "next_revisit_trigger": "new same-case artifact appears or one licensed provider becomes terms-safe for archived capture", + "revisit_now": false, + "wait_reason": "reviewed local artifact sweep already failed to re-locate same-case timezone-visible screenshots, and no new provider terms-safe signal is recorded yet" + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/kp_real_event_replay_gate_2026_07_30.json b/references/oracle/kp_real_event_replay_gate_2026_07_30.json new file mode 100644 index 00000000..7db93615 --- /dev/null +++ b/references/oracle/kp_real_event_replay_gate_2026_07_30.json @@ -0,0 +1,65 @@ +{ + "scope": "kp_real_event_replay_gate", + "created_at": "2026-07-30", + "claim_status": "blocked", + "timing_truth_promoted": false, + "holdout_status": "awaiting_independent_labels", + "blocking_packets": [ + "day_level_holdout_readiness_ledger", + "timing_negative_holdout_source_audit", + "day_level_holdout_human_annotation_packet" + ], + "blocking_paths": [ + "references/real_case_calibration/day_level_holdout_readiness_ledger_2026_07_19.json", + "references/real_case_calibration/timing_negative_holdout_source_audit_2026_07_17.json", + "references/real_case_calibration/day_level_holdout_v3_human_annotation_packet_2026_07_19.json" + ], + "current_holdout_counts": { + "candidate_annotation_count": 9, + "frozen_final_count": 0, + "frozen_negative_count": 0, + "frozen_positive_count": 0 + }, + "required_holdout_counts": { + "minimum_frozen_negative": 80, + "minimum_frozen_positive": 20 + }, + "remaining_holdout_counts": { + "negative_needed": 80, + "positive_needed": 20 + }, + "workflow_blocker": { + "step": "timing_outcome_oracle", + "requires": [ + "event domain houses", + "positive event date/window", + "negative windows", + "blind ranking before seeing labels" + ], + "current_status": "blocked_until_human_holdout", + "blocker": "independent labeled holdout missing" + }, + "maturity_gap": [ + "Placidus cusp longitude parity", + "1-249 sub-lord end-to-end table audit", + "ruling planets", + "horary/event judgment workflows", + "real event replay" + ], + "negative_source_contract": { + "explicit_non_event_intervals": true, + "independent_human_reviewed": true, + "positive_and_negative_split_locked_before_scoring": true, + "unobserved_before_preregistration": true + }, + "blocked_reasons": [ + "KP real event replay remains blocked until independent frozen positive and negative holdout labels exist.", + "Pilot candidates are not independent frozen labels. Public positive event sources cannot be used as negative intervals without human absence adjudication.", + "independent labeled holdout missing", + "Wikidata/EventKG/BiographyNet derived timelines: missing_explicit_non_event_intervals, not_independently_human_reviewed, observed_before_preregistration", + "existing 40 control dates: not_independently_human_reviewed, observed_before_preregistration" + ], + "boundary": "KP cusp/significator layers may be displayed for research, but this legacy replay lane remains blocked until exact KP workflow evidence is paired with independently frozen holdout labels. Current exact-cusp governance should be read from the August 22 kp_precision_timing mainline/dashboard packets.", + "production_tuning_allowed": false, + "truth_matrix_allowed": false +} diff --git a/scripts/ashtottari_dasha.py b/scripts/ashtottari_dasha.py index 6f52ad92..55bd68dc 100644 --- a/scripts/ashtottari_dasha.py +++ b/scripts/ashtottari_dasha.py @@ -28,6 +28,14 @@ The starting lord is determined by the Moon's Nakshatra at birth. """ from datetime import datetime, timedelta +from ashtottari_rule_profiles import ( + RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA, + RULE_FAMILY_RAHU_FROM_LAGNA_LORD, + SHUKLA_PAKSHA_NAKSHATRAS, + KRISHNA_PAKSHA_NAKSHATRAS, + evaluate_moon_nakshatra_plus_paksha_rule, + evaluate_rahu_from_lagna_lord_rule, +) # --------------------------------------------------------------------------- # Constants @@ -43,9 +51,38 @@ NAKSHATRAS = [ "Uttara Bhadrapada", "Revati" ] -# Applicable Nakshatras for Ashtottari Dasha -SHUKLA_PAKSHA_NAKSHATRAS = {0, 4, 6, 13, 20, 22} # Ashwini, Mrigashira, Punarvasu, Chitra, Shravana, Dhanishta -KRISHNA_PAKSHA_NAKSHATRAS = {3, 5, 7, 8, 9, 26} # Rohini, Ardra, Pushya, Ashlesha, Magha, Revati +# Candidate 28-nakshatra cycle used by the PL9 sample-admitted path. +# This preserves the sample's Mula -> Mercury balance note while leaving the +# legacy Moon-whitelist rule available for research comparison. +_CANDIDATE_NAKSHATRA_STARTING_LORDS = { + "Ardra": "Sun", + "Punarvasu": "Sun", + "Pushya": "Sun", + "Ashlesha": "Sun", + "Magha": "Moon", + "Purva Phalguni": "Moon", + "Uttara Phalguni": "Moon", + "Hasta": "Mars", + "Chitra": "Mars", + "Swati": "Mars", + "Vishakha": "Mars", + "Anuradha": "Mercury", + "Jyeshtha": "Mercury", + "Mula": "Mercury", + "Purva Ashadha": "Saturn", + "Uttara Ashadha": "Saturn", + "Shravana": "Saturn", + "Dhanishta": "Jupiter", + "Shatabhisha": "Jupiter", + "Purva Bhadrapada": "Jupiter", + "Uttara Bhadrapada": "Rahu", + "Revati": "Rahu", + "Ashwini": "Rahu", + "Bharani": "Rahu", + "Krittika": "Venus", + "Rohini": "Venus", + "Mrigashira": "Venus", +} # Ashtottari planetary sequence and year allotments (BPHS Ch.19) DASHA_SEQUENCE = [ @@ -61,6 +98,17 @@ DASHA_SEQUENCE = [ TOTAL_CYCLE = 108 # years +_PLANET_ABBR = { + "Sun": "Su", + "Moon": "Mo", + "Mars": "Ma", + "Mercury": "Me", + "Saturn": "Sa", + "Jupiter": "Ju", + "Rahu": "Ra", + "Venus": "Ve", +} + # Mapping from starting Nakshatra index to first dasha lord index in DASHA_SEQUENCE # Per BPHS Ch.19: # - Rohini (3), Ardra (5), Pushya (7), Ashlesha (8), Magha (9), Revati (26) @@ -99,10 +147,145 @@ def is_ashtottari_applicable(moon_nakshatra_index: int, is_shukla_paksha: bool) Returns: bool: True if Ashtottari Dasha applies. """ - if is_shukla_paksha: - return moon_nakshatra_index in SHUKLA_PAKSHA_NAKSHATRAS - else: - return moon_nakshatra_index in KRISHNA_PAKSHA_NAKSHATRAS + applicable, _ = evaluate_moon_nakshatra_plus_paksha_rule(moon_nakshatra_index, is_shukla_paksha) + return applicable + + +def _moon_nakshatra_name(moon_nakshatra_index: int) -> str: + if moon_nakshatra_index is None: + return "unknown" + try: + return NAKSHATRAS[moon_nakshatra_index] + except Exception: + return "unknown" + + +def _candidate_nakshatra_starting_lord(moon_nakshatra_index: int) -> str: + return _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(_moon_nakshatra_name(moon_nakshatra_index), "Sun") + + +def _candidate_lord_nakshatra_span(starting_planet: str) -> tuple[int, int] | tuple[None, None]: + matching = [ + idx for idx, name in enumerate(NAKSHATRAS) + if _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(name) == starting_planet + ] + if not matching: + return None, None + return matching[0], matching[-1] + + +def _years_to_balance_parts(years: float) -> dict: + total_days = max(years, 0.0) * 365.256364 + whole_years = int(total_days // 365.256364) + remaining_days = total_days - whole_years * 365.256364 + whole_months = int(remaining_days // 30.0) + residual_days = remaining_days - whole_months * 30.0 + day_count = int(round(residual_days)) + if residual_days > 0: + day_count += 1 + if day_count >= 30: + whole_months += day_count // 30 + day_count = day_count % 30 + if whole_months >= 12: + whole_years += whole_months // 12 + whole_months = whole_months % 12 + return { + "years": years, + "years_whole": whole_years, + "months_whole": whole_months, + "days_whole": day_count, + } + + +def _years_to_balance_parts_360(years: float) -> dict: + total_days = max(years, 0.0) * 360.0 + whole_years = int(total_days // 360.0) + remaining_days = total_days - whole_years * 360.0 + whole_months = int(remaining_days // 30.0) + residual_days = remaining_days - whole_months * 30.0 + day_count = int(round(residual_days)) + if residual_days > 0: + day_count += 1 + if day_count >= 30: + whole_months += day_count // 30 + day_count = day_count % 30 + if whole_months >= 12: + whole_years += whole_months // 12 + whole_months = whole_months % 12 + return { + "years": years, + "years_whole": whole_years, + "months_whole": whole_months, + "days_whole": day_count, + } + + +def _calc_candidate_balance_and_birth_chain(moon_longitude: float, starting_planet: str) -> tuple[dict | None, dict | None]: + start_nak_idx, end_nak_idx = _candidate_lord_nakshatra_span(starting_planet) + if start_nak_idx is None or end_nak_idx is None: + return None, None + + one_star = 360.0 / 27.0 + segment_start = start_nak_idx * one_star + segment_end = (end_nak_idx + 1) * one_star + longitude = float(moon_longitude) % 360.0 + if not (segment_start <= longitude <= segment_end): + return None, None + + span = segment_end - segment_start + elapsed = longitude - segment_start + remaining_ratio = max(0.0, min(1.0, (segment_end - longitude) / span)) + md_years = next(lord["years"] for lord in DASHA_SEQUENCE if lord["planet"] == starting_planet) + remaining_years = md_years * remaining_ratio + + def _sequence_from(lord: str) -> list[str]: + idx = next(i for i, item in enumerate(DASHA_SEQUENCE) if item["planet"] == lord) + planets = [item["planet"] for item in DASHA_SEQUENCE] + return planets[idx:] + planets[:idx] + + def _child_periods(parent_lord: str, parent_duration_years: float) -> list[dict]: + rows = [] + for child_lord in _sequence_from(parent_lord): + child_years = parent_duration_years * next( + item["years"] for item in DASHA_SEQUENCE if item["planet"] == child_lord + ) / TOTAL_CYCLE + rows.append({"lord": child_lord, "years": child_years}) + return rows + + elapsed_in_md = md_years - remaining_years + hierarchy = [starting_planet] + parent_lord = starting_planet + parent_duration = md_years + elapsed_within_parent = elapsed_in_md + + for _depth in range(4): + rows = _child_periods(parent_lord, parent_duration) + cursor = 0.0 + selected = rows[-1] + for row in rows: + next_cursor = cursor + row["years"] + if cursor <= elapsed_within_parent < next_cursor: + selected = row + elapsed_within_parent = elapsed_within_parent - cursor + break + cursor = next_cursor + hierarchy.append(selected["lord"]) + parent_lord = selected["lord"] + parent_duration = selected["years"] + + balance = { + "planet": starting_planet, + "segment_start_nakshatra": NAKSHATRAS[start_nak_idx], + "segment_end_nakshatra": NAKSHATRAS[end_nak_idx], + "segment_span_nakshatras": end_nak_idx - start_nak_idx + 1, + "display_calendar": "360_day_traditional", + **_years_to_balance_parts_360(remaining_years), + } + birth_chain = { + "lords": hierarchy, + "compact": "-".join(_PLANET_ABBR.get(lord, lord[:2]) for lord in hierarchy), + } + return balance, birth_chain def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list: @@ -130,6 +313,50 @@ def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list: return periods +def _sequence_from_lord(starting_planet: str) -> list[dict]: + start_idx = next( + idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet + ) + return DASHA_SEQUENCE[start_idx:] + DASHA_SEQUENCE[:start_idx] + + +def _build_subperiods(parent_period: dict, level_key: str) -> list[dict]: + start_dt = datetime.fromisoformat(parent_period["start_date"]) + current_dt = start_dt + child_periods = [] + for lord in _sequence_from_lord(parent_period["planet"]): + child_years = float(parent_period["years"]) * float(lord["years"]) / float(TOTAL_CYCLE) + end_dt = current_dt + timedelta(days=child_years * 365.25) + child_periods.append({ + "level": level_key, + "planet": lord["planet"], + "lord": lord["planet"], + "years": child_years, + "start_date": current_dt.isoformat(), + "end_date": end_dt.isoformat(), + "status": "parameter_sensitive", + "derivation": "native_ashtottari_recursive_proportional_variant", + }) + current_dt = end_dt + if child_periods: + child_periods[-1]["end_date"] = parent_period["end_date"] + return child_periods + + +def _attach_recursive_subperiods(major_periods: list[dict]) -> tuple[list[dict], list[dict]]: + all_antardashas = [] + all_pratyantardashas = [] + for period in major_periods: + antardasha = _build_subperiods(period, "antardasha") + period["antardasha"] = antardasha + all_antardashas.extend(antardasha) + for child in antardasha: + pratyantardasha = _build_subperiods(child, "pratyantardasha") + child["pratyantardasha"] = pratyantardasha + all_pratyantardashas.extend(pratyantardasha) + return all_antardashas, all_pratyantardashas + + def calculate_ashtottari_dasha(birth_info: dict) -> dict: """ Calculate Ashtottari Dasha for a native. @@ -151,16 +378,23 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict: moon_idx = birth_info.get("moon_nakshatra_index") is_shukla = birth_info.get("is_shukla_paksha", True) birth_dt = birth_info.get("birth_datetime") + moon_longitude = birth_info.get("moon_longitude") + rule_family = birth_info.get("applicability_rule_family") or RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA if isinstance(birth_dt, str): birth_dt = datetime.fromisoformat(birth_dt) if birth_dt is None: birth_dt = datetime.now() - applicable = is_ashtottari_applicable(moon_idx, is_shukla) + if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: + applicable, applicability_reason = evaluate_rahu_from_lagna_lord_rule(birth_info) + starting_planet = _candidate_nakshatra_starting_lord(moon_idx) + else: + applicable, applicability_reason = evaluate_moon_nakshatra_plus_paksha_rule(moon_idx, is_shukla) + starting_planet = None if not applicable: - return { + result = { "applicable": False, "major": [], "current": None, @@ -168,11 +402,31 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict: "starting_planet": None, "reason": f"Moon Nakshatra '{NAKSHATRAS[moon_idx]}' does not qualify for Ashtottari Dasha under {'Shukla' if is_shukla else 'Krishna'} Paksha.", } + if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: + result.update({ + "execution_status": "blocked", + "confidence_status": "blocked", + "verification_status": "unverified", + }) + return result - start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0) - starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"] + if starting_planet is None: + start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0) + starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"] + else: + start_lord_idx = next( + idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet + ) major_periods = _build_major_periods(start_lord_idx, birth_dt) + antardasha_periods, pratyantardasha_periods = _attach_recursive_subperiods(major_periods) + balance_at_birth = None + dasha_at_birth = None + if moon_longitude is not None and rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: + balance_at_birth, dasha_at_birth = _calc_candidate_balance_and_birth_chain( + moon_longitude=float(moon_longitude), + starting_planet=starting_planet, + ) # Determine current period. Major periods repeat every 108 years; older natives # should still return a current period instead of None after the first cycle. @@ -195,13 +449,22 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict: break cumulative += years - return { + result = { "applicable": True, "major": major_periods, + "antardasha": antardasha_periods, + "pratyantardasha": pratyantardasha_periods, "current": current_period, "total_cycle": TOTAL_CYCLE, "starting_planet": starting_planet, } + if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: + result.update({ + "execution_status": "executed", + "confidence_status": "parameter_sensitive", + "verification_status": "unverified", + }) + return result # --------------------------------------------------------------------------- diff --git a/scripts/ashtottari_rule_profiles.py b/scripts/ashtottari_rule_profiles.py new file mode 100644 index 00000000..d003cc50 --- /dev/null +++ b/scripts/ashtottari_rule_profiles.py @@ -0,0 +1,90 @@ +"""Shared Ashtottari applicability rule-family helpers. + +This module does not declare a single verified truth. It only centralizes the +repo's current competing rule families so runtime surfaces can expose them +consistently and audits can compare them without hidden drift. +""" +from __future__ import annotations + +from typing import Any + + +RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA = "moon_nakshatra_plus_paksha" +RULE_FAMILY_RAHU_NOT_IN_KENDRA = "rahu_not_in_kendra" +RULE_FAMILY_RAHU_FROM_LAGNA_LORD = "rahu_from_lagna_lord_kendra_trikona_plus_paksha_daynight" + +SHUKLA_PAKSHA_NAKSHATRAS = {0, 4, 6, 13, 20, 22} +KRISHNA_PAKSHA_NAKSHATRAS = {3, 5, 7, 8, 9, 26} +ALLOWED_RAHU_HOUSES_FROM_LAGNA_LORD = {4, 5, 7, 9, 10} +KENDRA_HOUSES = {1, 4, 7, 10} + + +def evaluate_moon_nakshatra_plus_paksha_rule( + moon_nakshatra_index: int | None, + is_shukla_paksha: bool | None, +) -> tuple[bool, str]: + if moon_nakshatra_index is None or is_shukla_paksha is None: + return False, "Moon-nakshatra/Paksha rule needs moon_nakshatra_index and is_shukla_paksha." + allowed = SHUKLA_PAKSHA_NAKSHATRAS if is_shukla_paksha else KRISHNA_PAKSHA_NAKSHATRAS + if moon_nakshatra_index in allowed: + return True, "Moon-nakshatra/Paksha rule matched the current whitelist." + return False, "Moon-nakshatra/Paksha rule did not match the current whitelist." + + +def evaluate_rahu_not_in_kendra_rule(rahu_house: int | None) -> tuple[bool, str]: + if rahu_house is None: + return False, "Rahu-not-in-Kendra rule needs rahu_house." + if rahu_house not in KENDRA_HOUSES: + return True, f"Rahu-not-in-Kendra rule matched: Rahu house {rahu_house} is outside 1/4/7/10." + return False, f"Rahu-not-in-Kendra rule blocked: Rahu house {rahu_house} is in 1/4/7/10." + + +def evaluate_rahu_from_lagna_lord_rule(birth_info: dict[str, Any]) -> tuple[bool, str]: + birth_is_daytime = birth_info.get("birth_is_daytime") + is_shukla = birth_info.get("is_shukla_paksha") + rahu_sign_index = birth_info.get("rahu_sign_index") + lagna_lord_sign_index = birth_info.get("lagna_lord_sign_index") + missing = [ + name for name, value in ( + ("birth_is_daytime", birth_is_daytime), + ("is_shukla_paksha", is_shukla), + ("rahu_sign_index", rahu_sign_index), + ("lagna_lord_sign_index", lagna_lord_sign_index), + ) + if value is None + ] + if missing: + return False, f"Candidate Ashtottari rule needs {', '.join(missing)}." + + if birth_is_daytime and not is_shukla: + pass + elif (not birth_is_daytime) and is_shukla: + pass + else: + return False, ( + "Candidate Ashtottari gate expects day birth in Krishna Paksha or " + "night birth in Shukla Paksha." + ) + + house_from_lagna_lord = ((int(rahu_sign_index) - int(lagna_lord_sign_index)) % 12) + 1 + if house_from_lagna_lord not in ALLOWED_RAHU_HOUSES_FROM_LAGNA_LORD: + return False, ( + "Rahu is not in Kendra/Trikona from the Lagna lord under the frozen candidate rule." + ) + return True, ( + f"Candidate rule matched: {('day' if birth_is_daytime else 'night')} birth, " + f"{'Krishna' if not is_shukla else 'Shukla'} Paksha, Rahu {house_from_lagna_lord}th from Lagna lord." + ) + + +def evaluate_rule_family(rule_family: str, payload: dict[str, Any]) -> tuple[bool, str]: + if rule_family == RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA: + return evaluate_moon_nakshatra_plus_paksha_rule( + payload.get("moon_nakshatra_index"), + payload.get("is_shukla_paksha"), + ) + if rule_family == RULE_FAMILY_RAHU_NOT_IN_KENDRA: + return evaluate_rahu_not_in_kendra_rule(payload.get("rahu_house")) + if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: + return evaluate_rahu_from_lagna_lord_rule(payload) + return False, f"Unknown Ashtottari rule family: {rule_family}" diff --git a/scripts/domain_calculation_service.py b/scripts/domain_calculation_service.py index 7d5a0e26..1cbc2d73 100644 --- a/scripts/domain_calculation_service.py +++ b/scripts/domain_calculation_service.py @@ -16,7 +16,9 @@ from ayanamsa_utils import ( DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, apply_ayanamsa, + is_supported_ayanamsa_name, normalize_ayanamsa_name, + supported_ayanamsa_names, ) from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra from jyotish_engine import SIGNS, compute_chart_data @@ -285,6 +287,10 @@ def compute_transit_longitude( local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12) except (TypeError, ValueError) as exc: raise CalculationError("reference_date must be YYYY-MM-DD") from exc + if not is_supported_ayanamsa_name(ayanamsa): + raise CalculationError( + f"unsupported ayanamsa: {ayanamsa!r}. Supported values: {', '.join(supported_ayanamsa_names())}" + ) ayanamsa_name = normalize_ayanamsa_name(ayanamsa) with _SWISSEPH_LOCK: apply_ayanamsa(ayanamsa_name, swe) diff --git a/scripts/gulika.py b/scripts/gulika.py index eb008c33..71ca9b6a 100644 --- a/scripts/gulika.py +++ b/scripts/gulika.py @@ -11,6 +11,10 @@ try: from saham_daynight import determine_daytime except ImportError: from scripts.saham_daynight import determine_daytime +try: + from ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags +except ImportError: + from scripts.ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags # Monday=0, matching datetime.weekday(). Values are the end of Saturn's share @@ -37,10 +41,40 @@ SATURN_PART_START = { 6: {"day": 6, "night": 2}, } +# Planet indices follow the local seven-graha ordering: Sun through Saturn. +# Each row assigns the eight daylight/nighttime shares for a weekday where +# Python's datetime.weekday() is Monday=0. +DAY_RULERS = ( + (1, 2, 3, 4, 5, 6, -1, 0), + (2, 3, 4, 5, 6, -1, 0, 1), + (3, 4, 5, 6, -1, 0, 1, 2), + (4, 5, 6, -1, 0, 1, 2, 3), + (5, 6, -1, 0, 1, 2, 3, 4), + (6, -1, 0, 1, 2, 3, 4, 5), + (0, 1, 2, 3, 4, 5, 6, -1), +) +NIGHT_RULERS = ( + (5, 6, -1, 0, 1, 2, 3, 4), + (6, -1, 0, 1, 2, 3, 4, 5), + (-1, 0, 1, 2, 3, 4, 5, 6), + (0, 1, 2, 3, 4, 5, 6, -1), + (1, 2, 3, 4, 5, 6, -1, 0), + (2, 3, 4, 5, 6, -1, 0, 1), + (4, 5, 6, -1, 0, 1, 2, 3), +) +TEMPORAL_UPAGRAHAS = { + "Kaala": (0, "middle"), + "Mrityu": (2, "middle"), + "Artha_Praharaka": (3, "middle"), + "Yama_Ghantaka": (4, "middle"), + "Gulika": (6, "begin"), + "Maandi": (6, "middle"), +} -def _sidereal_ascendant(jd_ut: float, lat: float, lon: float) -> float: - swe.set_sid_mode(swe.SIDM_LAHIRI) - cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SIDEREAL) + +def _sidereal_ascendant(jd_ut: float, lat: float, lon: float, ayanamsa: str = "raman") -> float: + flags = sidereal_flags(swe, ayanamsa) + cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", flags) return float(ascmc[0]) % 360 @@ -51,8 +85,10 @@ def calculate_gulika( lon: float, tz: float, method: str = "saturn_part_start", + ayanamsa: str = "raman", ) -> dict[str, Any]: """Return Gulika from local moment/location using Swiss sunrise and sunset.""" + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) is_day = bool(daynight["is_daytime"]) period = "day" if is_day else "night" @@ -73,7 +109,7 @@ def calculate_gulika( else: raise ValueError("method must be saturn_part_start or legacy_ghatika_end") segment_jd = start_jd + (end_jd - start_jd) * segment_fraction - longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon)) + longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name) return { "scope": "gulika_prasna_marga", "status": "partial", @@ -89,7 +125,103 @@ def calculate_gulika( "ghatika_end": ghatika_end, "segment_jd_ut": segment_jd, "daynight_evidence": daynight, - "ayanamsa": "lahiri", + "ayanamsa": ayanamsa_name, + "ayanamsa_display": ayanamsa_display_name(ayanamsa_name), "rule_source": "references/prashna-complete-guide.md#3.5", "boundary": "PyJHora-aligned Saturn-part-start variant. Numeric parity is evidence only and does not enable Prashna verdict layers.", } + + +def _temporal_upagraha( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, + planet_index: int, + segment_part: str, + ayanamsa: str = "raman", +) -> dict[str, Any]: + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + is_day = bool(daynight["is_daytime"]) + period = "day" if is_day else "night" + start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 + if end_jd <= start_jd: + end_jd += 1.0 + start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0) + weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday() + ruler_table = DAY_RULERS if is_day else NIGHT_RULERS + part_index = ruler_table[weekday].index(planet_index) + fraction = (part_index + (0.5 if segment_part == "middle" else 0.0)) / 8.0 + segment_jd = start_jd + (end_jd - start_jd) * fraction + longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name) + return { + "longitude": round(longitude, 6), + "sign_idx": int(longitude / 30) % 12, + "degree_in_sign": round(longitude % 30, 6), + "period": period, + "weekday": weekday, + "planet_index": planet_index, + "part_index": part_index, + "segment_part": segment_part, + "segment_fraction": fraction, + "segment_jd_ut": segment_jd, + } + + +def _solar_upagrahas(sun_longitude: float) -> dict[str, dict[str, Any]]: + sun = float(sun_longitude) % 360.0 + longitudes = { + "Dhuma": (sun + 133.0 + 20.0 / 60.0) % 360.0, + "Vyatipata": (360.0 - ((sun + 133.0 + 20.0 / 60.0) % 360.0)) % 360.0, + } + longitudes["Parivesha"] = (longitudes["Vyatipata"] + 180.0) % 360.0 + longitudes["Indrachapa"] = (360.0 - longitudes["Parivesha"]) % 360.0 + longitudes["Upaketu"] = (sun - 30.0) % 360.0 + return { + name: { + "longitude": round(longitude, 6), + "sign_idx": int(longitude / 30) % 12, + "degree_in_sign": round(longitude % 30, 6), + "source": "solar_longitude_formula", + } + for name, longitude in longitudes.items() + } + + +def calculate_upagrahas( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, + sun_longitude: float, + ayanamsa: str = "raman", +) -> dict[str, Any]: + """Calculate the D1 Upagraha packet using local Swiss Ephemeris inputs.""" + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) + temporal = { + name: _temporal_upagraha( + moment, + lat=lat, + lon=lon, + tz=tz, + planet_index=planet_index, + segment_part=segment_part, + ayanamsa=ayanamsa_name, + ) + for name, (planet_index, segment_part) in TEMPORAL_UPAGRAHAS.items() + } + return { + "status": "computed_pending_validation", + "raw": {**temporal, **_solar_upagrahas(sun_longitude)}, + "ayanamsa": ayanamsa_name, + "ayanamsa_display": ayanamsa_display_name(ayanamsa_name), + "scope": "d1_only", + "boundary": ( + "Local D1 formulas are retained with segment evidence. Public PyJHora observations are " + "used only for calibration; D-N Upagraha mapping and external numeric closure remain pending." + ), + } diff --git a/scripts/jaimini.py b/scripts/jaimini.py index 5ab4125b..2d6e4b13 100644 --- a/scripts/jaimini.py +++ b/scripts/jaimini.py @@ -300,6 +300,12 @@ _PLANET_DIGNITY_KNRAO = { # Aquarius (sign 10): 传统主Saturn vs 共主Rahu — PyJHora用stronger_planet动态判定 # Scorpio (sign 7): 传统主Mars vs 共主Ketu — PyJHora用stronger_planet动态判定 _CHARA_DASHA_CO_LORD_SIGNS = {10, 7} # Aquarius, Scorpio 有共主争议 +CHARA_DASHA_PROFILES = { + "kn_rao", + "goel_lagna_start", + "goel_gendered", + "goel_shastri_method2", +} def _jaimini_planet_dignity_level(planet: str, sign_idx: int) -> int: @@ -439,7 +445,12 @@ def _get_sign_lord_house(longitudes: Dict[str, float], sign_idx: int) -> int: return _get_planet_house(longitudes, lord) -def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> int: +def _chara_dasha_duration_profile( + longitudes: Dict[str, float], + sign_idx: int, + *, + profile: str = "kn_rao", +) -> int: """ KN Rao Chara Dasha 大运时长计算 v6.1.12。 @@ -453,7 +464,31 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> 若宫主在所在宫位 ⟹ Exalted(+1);Debilitated(-1) Mercury在Virgo/Gemini是own sign非exalted,不+1 """ - # 动态宫主判定:Aquarius→Saturn/Rahu比较,Scorpio→Mars/Ketu比较 + if profile not in CHARA_DASHA_PROFILES: + raise ValueError(f"unsupported Chara Dasha profile: {profile}") + + if profile == "goel_lagna_start": + lord = SIGN_LORDS[SIGNS[sign_idx]] + lord_house = _get_planet_house(longitudes, lord) + if lord_house == sign_idx: + return 12 + return _count_rasis_forward(sign_idx, lord_house) + + if profile == "goel_gendered": + lord = SIGN_LORDS[SIGNS[sign_idx]] + lord_house = _get_planet_house(longitudes, lord) + if lord_house == sign_idx: + return 12 + if (lord_house - sign_idx) % 12 == 6: + return 10 + if sign_idx % 2 == 0: + count = _count_rasis_forward(lord_house, sign_idx) + else: + count = _count_rasis_backward(lord_house, sign_idx) + return max(1, count - 1) + + # KN Rao keeps Scorpio/Aquarius dual-lord separation; start/order rules are + # handled by the progression helper. lord = _resolve_chara_dasha_lord(longitudes, sign_idx) lord_house = _get_planet_house(longitudes, lord) @@ -469,7 +504,9 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> if years <= 0: years = 12 - # 尊贵调整(对齐PyJHora house_strengths_of_planets表) + # This remains the historical PyJHora-aligned local KN Rao profile. The + # source ledger keeps the no-extra-year K.N. Rao textual boundary visible + # for later formula reconciliation. dignities = _PLANET_DIGNITY_KNRAO.get(lord, {}) if dignities: if lord_house in dignities.get('exalted', set()): @@ -480,7 +517,23 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> return years -def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) -> list: +def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> int: + return _chara_dasha_duration_profile(longitudes, sign_idx, profile="kn_rao") + + +def _fourth_from_lagna_prakriti(asc_sign_idx: int) -> int: + if asc_sign_idx % 2 == 0: + return (asc_sign_idx + 3) % 12 + return (asc_sign_idx - 3) % 12 + + +def _chara_progression_profile( + asc_sign_idx: int, + longitudes: Dict[str, float], + *, + profile: str = "kn_rao", + gender: str | None = None, +) -> list: """ KN Rao Chara Dasha 星座序列生成。 @@ -489,11 +542,19 @@ def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) -> 2. 检查第9宫:若为偶数脚 → 逆向,否则正向 3. 生成12个星座的顺序 """ + if profile not in CHARA_DASHA_PROFILES: + raise ValueError(f"unsupported Chara Dasha profile: {profile}") + start_idx = asc_sign_idx + if profile == "goel_gendered" and (gender or "").lower().startswith("f"): + start_idx = _fourth_from_lagna_prakriti(asc_sign_idx) ninth_idx = (asc_sign_idx + 8) % 12 if _sign_is_even_footed(ninth_idx): - return [(asc_sign_idx + 12 - i) % 12 for i in range(12)] - else: - return [(asc_sign_idx + i) % 12 for i in range(12)] + return [(start_idx + 12 - i) % 12 for i in range(12)] + return [(start_idx + i) % 12 for i in range(12)] + + +def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) -> list: + return _chara_progression_profile(asc_sign_idx, longitudes, profile="kn_rao") def _jd_to_date_tuple(jd: float): @@ -522,7 +583,9 @@ def _jd_to_date_tuple(jd: float): def calc_chara_dasha(asc_sign_idx: int, planet_longitudes: Dict[str, float], birth_year: int, birth_month: int, - birth_day: int = 1) -> Dict: + birth_day: int = 1, + profile: str = "kn_rao", + gender: str | None = None) -> Dict: """ Chara Dasha计算(v6.1.11重写:KN Rao Method) @@ -534,13 +597,21 @@ def calc_chara_dasha(asc_sign_idx: int, - 时长:基于宫主所在宫位而非行星计数 - 尊贵:Exalted +1年 / Debilitated -1年 """ - progression = _chara_progression_knrao(asc_sign_idx, planet_longitudes) + progression = _chara_progression_profile( + asc_sign_idx, + planet_longitudes, + profile=profile, + gender=gender, + ) dasha_sequence = [] for i, sign_idx in enumerate(progression): sign_name = SIGNS[sign_idx] - lord = _resolve_chara_dasha_lord(planet_longitudes, sign_idx) - duration = _chara_dasha_duration_knrao(planet_longitudes, sign_idx) + if profile == "goel_lagna_start": + lord = SIGN_LORDS[sign_name] + else: + lord = _resolve_chara_dasha_lord(planet_longitudes, sign_idx) + duration = _chara_dasha_duration_profile(planet_longitudes, sign_idx, profile=profile) # 宮主所在宫位 lord_house = _get_planet_house(planet_longitudes, lord) @@ -549,7 +620,9 @@ def calc_chara_dasha(asc_sign_idx: int, # 尊贵状态 dignities = _PLANET_DIGNITY_KNRAO.get(lord, {}) dignity_status = 'none' - if dignities: + if profile == "goel_lagna_start": + dignity_status = 'not_applied' + elif dignities: exalted_set = dignities.get('exalted', set()) debil_set = dignities.get('debilitated', set()) if lord_house in exalted_set: @@ -571,7 +644,10 @@ def calc_chara_dasha(asc_sign_idx: int, total_years = sum(d['duration_years'] for d in dasha_sequence) return { - 'method': 'Chara Dasha (KN Rao Method, v6.1.11, PyJHora-aligned)', + 'method': f'Chara Dasha ({profile})', + 'variant_profile': profile, + 'variant_profile_status': 'profiled', + 'gender': gender, 'ascendant': SIGNS[asc_sign_idx], 'ascendant_idx': asc_sign_idx, 'progression_source': '9th_house_direction', @@ -584,22 +660,45 @@ def calc_chara_dasha(asc_sign_idx: int, def calc_chara_dasha_with_antardasha(asc_sign_idx: int, planet_longitudes: Dict[str, float], birth_year: int, birth_month: int, - birth_day: int = 1) -> Dict: + birth_day: int = 1, + profile: str = "kn_rao", + gender: str | None = None) -> Dict: """ Chara Dasha 完整3层计算(MD → AD → PD) 使用KN Rao方法(v6.1.11重写)。 Antardasha:等分法(parent/12),序列为Maha序列偏移1位。 """ - base = calc_chara_dasha(asc_sign_idx, planet_longitudes, birth_year, birth_month, birth_day) - progression = _chara_progression_knrao(asc_sign_idx, planet_longitudes) - - # Antardasha序列:Mahadasha序列偏移1(PyJHora method=2) - antar_sequence = progression[1:] + progression[:1] + base = calc_chara_dasha( + asc_sign_idx, + planet_longitudes, + birth_year, + birth_month, + birth_day, + profile=profile, + gender=gender, + ) + progression = _chara_progression_profile( + asc_sign_idx, + planet_longitudes, + profile=profile, + gender=gender, + ) for md in base['dasha_sequence']: md_sign_idx = md['sign_idx'] md_duration_years = md['duration_years'] + if profile == "goel_shastri_method2": + # Goel's Method 2 worked example gives the first sub-period to the + # mahadasha sign and then follows that sign's own direct/indirect order. + ninth_from_md = (md_sign_idx + 8) % 12 + if _sign_is_even_footed(ninth_from_md): + antar_sequence = [(md_sign_idx + 12 - i) % 12 for i in range(12)] + else: + antar_sequence = [(md_sign_idx + i) % 12 for i in range(12)] + else: + # Antardasha序列:Mahadasha序列偏移1(PyJHora method=2) + antar_sequence = progression[1:] + progression[:1] antardasha_list = [] for j, ad_sign_idx in enumerate(antar_sequence): diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 6bef1eea..11a6e456 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -36,17 +36,24 @@ """ import argparse +import hashlib import json import sys import os import csv +import calendar import math import time import sqlite3 import importlib.util +import re from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta -from typing import Dict, List +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List +import contextlib +import io try: from tabulate import tabulate except ModuleNotFoundError: # pragma: no cover - minimal environments @@ -60,6 +67,32 @@ from life_stage_hook import generate_life_stage_hooks from capability_evidence_pool import build_capability_evidence_pool_summary from guided_topic_discovery import build_guided_topics + +def build_report_theme_catalog(report): + try: + catalog = build_guided_topics(report) + return catalog if isinstance(catalog, list) else [] + except Exception: + return [] + + +def attach_calculation_profile(payload, args=None): + return payload + + +def _try_attr_import(modname, attr): + for name in (modname, f"scripts.{modname}"): + try: + return getattr(__import__(name, fromlist=[attr]), attr) + except (ModuleNotFoundError, ImportError, AttributeError): + continue + return None + + +def build_calculation_profile(args=None): + return {"status": "blocked", "reason": "calculation_profile_contract_absent"} + + from ayanamsa_utils import ( AYANAMSA_DISPLAY_NAMES, AYANAMSA_MODES, @@ -102,8 +135,29 @@ from cmd_narayana_dasha import cmd_narayana_dasha as _cmd_narayana_dasha_impl # from cmd_muhurta import cmd_muhurta # v6.0.21 from yoga_engine import detect_yogas # v6.0.26: data-driven Yoga engine from kp_system import calc_kp_analysis, get_kp_lords # v6.9.10: KP完整系统 -from bhava_chalit import cmd_bhava_chalit # v6.9.13: Bhava Chalit 不等宫边界调整 +from bhava_chalit import BhavaChalitCalculator, cmd_bhava_chalit # v6.9.13: Bhava Chalit 不等宫边界调整 from sudarshana_chakra import calc_sudarshana_chakra, generate_sudarshana_report # v6.9.14: Sudarshana Chakra 三参考点盘 +try: + from canonical_jyotish_profile import build_canonical_jyotish_profile, build_disputed_method_policy +except ModuleNotFoundError: # pragma: no cover - research module not vendored + def build_canonical_jyotish_profile(*args, **kwargs): + return {"status": "blocked", "reason": "canonical_jyotish_profile_absent"} + + def build_disputed_method_policy(*args, **kwargs): + from collections import defaultdict + return defaultdict(lambda: {"status": "blocked", "reason": "disputed_method_policy_absent"}) + +try: + from pyjhora_canonical_adapter import pyjhora_raasi_dasha_target, pyjhora_shadbala_target, pyjhora_vimshottari_target +except ModuleNotFoundError: # pragma: no cover - research module not vendored + def pyjhora_raasi_dasha_target(**kwargs): + return {"status": "blocked", "reason": "pyjhora_canonical_adapter_absent"} + + def pyjhora_shadbala_target(**kwargs): + return {"status": "blocked", "reason": "pyjhora_canonical_adapter_absent"} + + def pyjhora_vimshottari_target(**kwargs): + return {"status": "blocked", "reason": "pyjhora_canonical_adapter_absent"} # ============================================================================ # 常量 @@ -444,6 +498,72 @@ def _get_temporary_relationship(planet1, planet2, planets_data): else: return 'ENEMY' + +def _build_planetary_friendship_snapshot(planets_data): + """Expose existing relationship rules as an auditable natal raw table.""" + planet_order = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu') + available = [ + planet for planet in planet_order + if isinstance(planets_data.get(planet), dict) and planets_data[planet].get('sign') + ] if isinstance(planets_data, dict) else [] + if not available: + return {'status': 'blocked', 'reason': 'D1 planet signs are unavailable for friendship calculation.'} + + compound_labels = { + ('FRIEND', 'FRIEND'): 'GREAT_FRIEND', + ('FRIEND', 'ENEMY'): 'NEUTRAL', + ('NEUTRAL', 'FRIEND'): 'FRIEND', + ('NEUTRAL', 'ENEMY'): 'ENEMY', + ('ENEMY', 'FRIEND'): 'NEUTRAL', + ('ENEMY', 'ENEMY'): 'GREAT_ENEMY', + } + rows = [] + for source in available: + natural = {'friends': [], 'enemies': [], 'neutral': []} + temporary = {'friends': [], 'enemies': []} + compound = { + 'great_friends': [], 'friends': [], 'neutral': [], 'enemies': [], 'great_enemies': [], + } + for target in available: + if source == target: + continue + if target in PERMANENT_FRIENDS.get(source, []): + natural_kind = 'FRIEND' + natural['friends'].append(target) + elif target in PERMANENT_ENEMIES.get(source, []): + natural_kind = 'ENEMY' + natural['enemies'].append(target) + else: + natural_kind = 'NEUTRAL' + natural['neutral'].append(target) + temporary_kind = _get_temporary_relationship(source, target, planets_data) + temporary['friends' if temporary_kind == 'FRIEND' else 'enemies'].append(target) + compound_kind = compound_labels.get((natural_kind, temporary_kind), 'NEUTRAL') + compound[{ + 'GREAT_FRIEND': 'great_friends', + 'FRIEND': 'friends', + 'NEUTRAL': 'neutral', + 'ENEMY': 'enemies', + 'GREAT_ENEMY': 'great_enemies', + }[compound_kind]].append(target) + rows.append({ + 'planet': source, + 'natural': natural, + 'temporary': temporary, + 'compound': compound, + 'status': 'parameter_sensitive', + }) + return { + 'status': 'parameter_sensitive', + 'method': 'local_permanent_relationship_constants_plus_temporary_sign_distance', + 'source_planets': available, + 'rows': rows, + 'boundary': ( + 'PL9 p41 node relationship rules and presentation categories are not yet field-level parity verified; ' + 'this is a local raw relationship snapshot only.' + ), + } + def _check_neecha_bhanga(planet, sign, planets_data): """Check for Cancellation of Debilitation.""" if not planets_data: @@ -497,7 +617,7 @@ def _get_dignity_level(planet, sign, deg_in_sign=None, planets_data=None): if deg_in_sign is not None and mt[1] <= deg_in_sign < mt[2]: return 'MOOLATRIKONA' - if SIGN_LORDS.get(sign) == planet: + if SIGN_LORDS.get(sign) == planet or (planet == 'Rahu' and sign == 'Virgo') or (planet == 'Ketu' and sign == 'Pisces'): return 'OWN_SIGN' if DEBILITATION.get(planet) == sign: @@ -561,14 +681,39 @@ def _planet_map_for_node_mode(node_mode='mean'): return {**BASE_PLANETS_SWE, 'Rahu': _node_pid(node_mode)} +POSITION_MODES = ('legacy', 'mean', 'apparent') + + +def _normalize_position_mode(position_mode): + mode = str(position_mode or 'legacy').lower() + if mode not in POSITION_MODES: + raise ValueError(f"position_mode must be one of: {', '.join(POSITION_MODES)}") + return mode + + +def _sidereal_calculation_profile(jd, position_mode): + """Return matching Swiss Ephemeris flags and ayanamsa for one frame.""" + mode = _normalize_position_mode(position_mode) + ephemeris_flags = getattr(swe, 'FLG_SWIEPH', 2) + if mode == 'mean': + ephemeris_flags |= getattr(swe, 'FLG_NONUT', 64) + ayanamsa = swe.get_ayanamsa_ex_ut(jd, ephemeris_flags)[1] + elif mode == 'apparent': + ayanamsa = swe.get_ayanamsa_ex_ut(jd, ephemeris_flags)[1] + else: + ayanamsa = swe.get_ayanamsa(jd) + return mode, ephemeris_flags, ayanamsa + + def _calc_sidereal_planets_for_jd(jd, node_mode='mean', include_ketu=True): """使用 Swiss Ephemeris 计算指定 Julian Day 的恒星黄道行星位置。""" if not HAS_SWE: return {}, None ayanamsa = swe.get_ayanamsa(jd) + calc_flags = getattr(swe, 'FLG_SWIEPH', 2) data = {} for pname, pid in _planet_map_for_node_mode(node_mode).items(): - pos, ret = swe.calc_ut(jd, pid) + pos, ret = swe.calc_ut(jd, pid, calc_flags | getattr(swe, 'FLG_SPEED', 256)) if ret < 0: continue lon = (pos[0] - ayanamsa) % 360 @@ -606,6 +751,8946 @@ def output_json(data): print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) +def _normalize_pl9_pack_selection(args) -> list[str]: + selected: list[str] = [] + raw_single = getattr(args, 'pack', None) or [] + raw_multi = getattr(args, 'packs', None) + for item in raw_single: + if item: + selected.append(str(item).strip()) + if raw_multi: + for item in str(raw_multi).split(','): + item = item.strip() + if item: + selected.append(item) + deduped: list[str] = [] + seen: set[str] = set() + for item in selected: + if item not in seen: + seen.add(item) + deduped.append(item) + return deduped + + +def _apply_pl9_pack_selection(packet: dict, selected_pack_ids: list[str]) -> dict: + if not selected_pack_ids or set(selected_pack_ids) == {'full'}: + packet['selected_report_pack_ids'] = [item['id'] for item in packet.get('report_pack_manifest', {}).get('packs', [])] + packet['selected_report_scope'] = 'full' + return packet + + manifest = packet.get('report_pack_manifest', {}) + packs = manifest.get('packs', []) + pack_map = {item['id']: item for item in packs} + invalid = [item for item in selected_pack_ids if item not in pack_map] + if invalid: + raise ValueError( + "Unknown PL9 report pack(s): " + + ", ".join(invalid) + + ". Available packs: " + + ", ".join(sorted(pack_map)) + + ", full" + ) + + selected_packs = [pack_map[item] for item in selected_pack_ids] + selected_worksheet_ids: list[str] = [] + selected_section_keys: set[str] = set() + for pack in selected_packs: + for worksheet_id in pack.get('worksheet_ids', []): + if worksheet_id not in selected_worksheet_ids: + selected_worksheet_ids.append(worksheet_id) + for section_key in pack.get('target_sections', []): + selected_section_keys.add(section_key) + + packet['worksheets'] = { + key: value + for key, value in packet.get('worksheets', {}).items() + if key in selected_worksheet_ids + } + + report_catalog = packet.get('report_catalog', {}) + worksheet_index = [ + item for item in report_catalog.get('worksheets', []) + if item.get('id') in selected_worksheet_ids + ] + ready_worksheets = [item for item in worksheet_index if item.get('status') == 'ready'] + report_catalog['worksheets'] = worksheet_index + report_catalog['worksheet_count'] = len(worksheet_index) + report_catalog['ready_worksheet_count'] = len(ready_worksheets) + packet['report_catalog'] = report_catalog + + report_index = packet.get('report_index', {}) + report_index['primary_workbook'] = 'selected_pl9_report_pack_export' + report_index['recommended_render_sequence'] = list(selected_pack_ids) + report_index['worksheet_ids'] = selected_worksheet_ids + packet['report_index'] = report_index + + packet['report_pack_manifest'] = { + **manifest, + 'selected_pack_ids': list(selected_pack_ids), + 'packs': selected_packs, + } + packet['selected_report_pack_ids'] = list(selected_pack_ids) + packet['selected_report_scope'] = 'pack_subset' + packet['coverage'] = { + **packet.get('coverage', {}), + 'ready_worksheet_ids': [item.get('id') for item in ready_worksheets], + 'report_pack_ids': list(selected_pack_ids), + 'selected_section_keys': sorted(selected_section_keys), + } + return packet + + +def _json_safe_report_snapshot(value, seen=None, args=None, field_name=None): + """Detach assembled export sections from runtime objects without hiding cycles.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if seen is None: + seen = set() + value_id = id(value) + if value_id in seen: + return {"status": "blocked", "reason": "circular_runtime_reference_omitted"} + if isinstance(value, dict): + if field_name in {'effective_settings', 'requested_settings', 'settings'}: + return {str(key): _json_safe_report_snapshot(item, seen, args=args, field_name=str(key)) for key, item in value.items()} + seen.add(value_id) + result = {str(key): _json_safe_report_snapshot(item, seen, args=args, field_name=str(key)) for key, item in value.items()} + seen.remove(value_id) + # Snapshot only: never wrap nested dicts/lists in response_envelope. + # Envelope belongs on CLI command returns, not on every packed section. + return result + if isinstance(value, (list, tuple, set)): + seen.add(value_id) + result = [_json_safe_report_snapshot(item, seen, args=args) for item in value] + seen.remove(value_id) + return result + return str(value) + + +def _build_pl9_p89_jaimini_special_points_contract(jaimini: dict) -> dict: + """Expose PL9 p89 Jaimini field parity as reusable structured evidence.""" + if not isinstance(jaimini, dict): + jaimini = {} + arudha = jaimini.get('arudha_padas') if isinstance(jaimini.get('arudha_padas'), dict) else {} + special_lagnas = jaimini.get('special_lagnas') if isinstance(jaimini.get('special_lagnas'), dict) else {} + karakas_7 = jaimini.get('chara_karaka_7') if isinstance(jaimini.get('chara_karaka_7'), dict) else {} + karakas_8 = jaimini.get('chara_karaka_8') if isinstance(jaimini.get('chara_karaka_8'), dict) else {} + yogi_local = jaimini.get('yogi_avayogi_local') if isinstance(jaimini.get('yogi_avayogi_local'), dict) else {} + yogi_comparison = jaimini.get('yogi_avayogi_local_external_comparison') if isinstance(jaimini.get('yogi_avayogi_local_external_comparison'), dict) else {} + yogi_replay = jaimini.get('yogi_avayogi_external_replay') if isinstance(jaimini.get('yogi_avayogi_external_replay'), dict) else {} + + def _field_value(source: dict, key: str): + row = source.get(key) if isinstance(source.get(key), dict) else {} + return row.get('sign') or row.get('rashi') or row.get('value') or row.get('name') + + def _blocked_special_point_contract(field: str) -> dict: + if field == 'Brahma': + try: + from sthira_brahma_selector_contract import build_sthira_brahma_selector_contract + return build_sthira_brahma_selector_contract() + except Exception as exc: + return { + 'schema': 'sthira_brahma_selector_contract_v1', + 'status': 'blocked', + 'reason': f'sthira_brahma_selector_contract_unavailable:{exc.__class__.__name__}', + } + dependency_map = { + 'Maheshwara': { + 'schema': 'pl9.p89_maheshwara_dependency_contract.v1', + 'blocked_reason': ( + 'Maheshwara requires a source-closed Jaimini derivation from a stable Brahma/Atmakaraka/' + 'lordship profile; the current local report has no same-profile producer.' + ), + 'depends_on': ['Brahma selector profile', 'Jaimini karaka profile', 'source-closed Maheshwara rule'], + }, + 'Rudra': { + 'schema': 'pl9.p89_rudra_dependency_contract.v1', + 'blocked_reason': ( + 'Rudra requires a source-closed Jaimini destruction-significator rule and tie-break profile; ' + 'the current local report has no same-profile producer.' + ), + 'depends_on': ['Jaimini karaka profile', 'eighth-lord/significator rule profile', 'source-closed Rudra rule'], + }, + } + spec = dependency_map.get(field, {}) + return { + 'schema': spec.get('schema') or 'pl9.p89_special_point_dependency_contract.v1', + 'status': 'blocked', + 'field': field, + 'available_local_surfaces': [ + 'worksheets.advanced_systems.jaimini.chara_karaka_7', + 'worksheets.advanced_systems.jaimini.chara_karaka_8', + 'worksheets.advanced_systems.jaimini.arudha_padas', + ], + 'blocked_reason': spec.get('blocked_reason') or 'No same-profile structured producer is registered.', + 'depends_on': spec.get('depends_on') or ['source-closed Jaimini rule profile'], + 'closure_requirements': [ + 'source-close the exact PL9/Jaimini rule grammar', + 'implement an independently authored local producer', + 'replay against the Domi PL9 p89 fixture and at least one external engine/source-backed case', + 'promote only after field-level replay and tie-break behavior are explicit', + ], + 'claim_boundary': f'This contract records why {field} is blocked; it does not produce a {field} value.', + } + + records = [ + { + 'field': 'Arudha Lagna (AL)', + 'local_value': _field_value(arudha, 'arudha_lagna'), + 'status': 'parameter_sensitive' if arudha.get('arudha_lagna') else 'blocked', + 'source_path': 'worksheets.advanced_systems.jaimini.arudha_padas.arudha_lagna', + }, + { + 'field': 'Upapada (UL)', + 'local_value': _field_value(arudha, 'upapada'), + 'status': 'parameter_sensitive' if arudha.get('upapada') else 'blocked', + 'source_path': 'worksheets.advanced_systems.jaimini.arudha_padas.upapada', + }, + ] + for field in ('HL', 'GL', 'PP', 'ViL', 'VL'): + records.append({ + 'field': field, + 'local_value': _field_value(special_lagnas, field), + 'status': 'parameter_sensitive' if special_lagnas.get(field) else 'blocked', + 'source_path': f'worksheets.advanced_systems.jaimini.special_lagnas.{field}', + }) + replay_points = { + 'Yogi': yogi_replay.get('yogi') if isinstance(yogi_replay.get('yogi'), dict) else {}, + 'Ava Yogi': yogi_replay.get('ava_yogi') if isinstance(yogi_replay.get('ava_yogi'), dict) else {}, + } + local_points = { + 'Yogi': yogi_local.get('yogi') if isinstance(yogi_local.get('yogi'), dict) else {}, + 'Ava Yogi': yogi_local.get('ava_yogi') if isinstance(yogi_local.get('ava_yogi'), dict) else {}, + } + comparison_rows = { + row.get('field'): row + for row in (yogi_comparison.get('rows') if isinstance(yogi_comparison.get('rows'), list) else []) + if isinstance(row, dict) + } + for field in ('Yogi', 'Ava Yogi'): + point = replay_points.get(field) or {} + local_point = local_points.get(field) or {} + comparison_row = comparison_rows.get(field) if isinstance(comparison_rows.get(field), dict) else {} + if local_point and comparison_row.get('status') == 'match': + status = 'partial_verified / pyjhora_formula_aligned' + elif local_point: + status = 'parameter_sensitive' + elif yogi_replay: + status = 'pyjhora_behavior_only / not_multiengine_parity' + else: + status = 'blocked' + records.append({ + 'field': field, + 'local_value': { + key: local_point.get(key) + for key in ('sign', 'degree_in_sign', 'longitude', 'nakshatra', 'nakshatra_lord', 'sign_lord', 'house') + if local_point.get(key) not in (None, '') + } if local_point else None, + 'external_replay_value': { + key: point.get(key) + for key in ('sign', 'degree_in_sign', 'longitude', 'nakshatra', 'nakshatra_lord', 'sign_lord') + if point.get(key) not in (None, '') + } if point else None, + 'status': status, + 'source_path': 'worksheets.advanced_systems.jaimini.yogi_avayogi_external_replay', + 'local_source_path': 'worksheets.advanced_systems.jaimini.yogi_avayogi_local', + 'comparison': comparison_row or None, + 'reason': 'Local same-formula producer is shown with PyJHora replay alignment only; this is not independent multi-engine parity.', + }) + for field in ('Brahma', 'Maheshwara', 'Rudra'): + blocked_contract = _blocked_special_point_contract(field) + records.append({ + 'field': field, + 'local_value': None, + 'status': 'blocked', + 'source_path': 'worksheets.advanced_systems.jaimini', + 'blocked_dependency_contract': blocked_contract, + 'reason': ( + 'Brahma selector rule identity is not source-closed; see the blocked selector contract.' + if field == 'Brahma' + else f'{field} rule identity is not source-closed; see the blocked dependency contract.' + ), + }) + + by_status = {} + for record in records: + status = record['status'] + by_status[status] = by_status.get(status, 0) + 1 + available_fields = [record['field'] for record in records if record.get('local_value') not in (None, '')] + blocked_fields = [record['field'] for record in records if record.get('status') == 'blocked'] + return { + 'schema': 'pl9.p89_jaimini_special_points_contract.v1', + 'status': 'parameter_sensitive' if available_fields else 'blocked', + 'claim_boundary': ( + 'p89 records expose local availability and parity boundaries only; they do not create event or identity claims.' + ), + 'source_paths': [ + 'worksheets.advanced_systems.jaimini.arudha_padas', + 'worksheets.advanced_systems.jaimini.chara_karaka_7', + 'worksheets.advanced_systems.jaimini.chara_karaka_8', + 'worksheets.advanced_systems.jaimini.special_lagnas', + 'worksheets.advanced_systems.jaimini.yogi_avayogi_local', + 'worksheets.advanced_systems.jaimini.yogi_avayogi_local_external_comparison', + 'worksheets.advanced_systems.jaimini.yogi_avayogi_external_replay', + ], + 'karaka_visibility': { + 'seven_karaka_count': len(karakas_7.get('karaka_table') or {}) if isinstance(karakas_7.get('karaka_table'), dict) else 0, + 'eight_karaka_count': len(karakas_8.get('karaka_table_8') or {}) if isinstance(karakas_8.get('karaka_table_8'), dict) else 0, + }, + 'summary': { + 'record_count': len(records), + 'by_status': by_status, + 'available_fields': available_fields, + 'external_replay_fields': [ + record['field'] for record in records + if record.get('external_replay_value') + ], + 'local_yogi_fields': [ + record['field'] for record in records + if record.get('local_value') and str(record.get('field')).endswith('Yogi') + ], + 'blocked_fields': blocked_fields, + 'blocked_dependency_contract_fields': [ + record['field'] for record in records + if record.get('blocked_dependency_contract') + ], + 'blocked_dependency_contract_count': sum( + 1 for record in records if record.get('blocked_dependency_contract') + ), + }, + 'records': records, + 'yogi_avayogi_external_replay': yogi_replay, + } + + +def _build_pl9_professional_support_section(worksheets: dict) -> dict: + """Collect existing specialist support into a report section without adjudication.""" + divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {} + advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {} + strengths = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {} + dasha_master = timing.get('dasha_master_pack') if isinstance(timing.get('dasha_master_pack'), dict) else {} + dasha_families = dasha_master.get('families') if isinstance(dasha_master.get('families'), dict) else {} + annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {} + jaimini = advanced.get('jaimini') if isinstance(advanced.get('jaimini'), dict) else {} + + special_lagnas = divisional.get('special_lagnas') if isinstance(divisional.get('special_lagnas'), dict) else {} + if not special_lagnas: + special_lagnas = jaimini.get('special_lagnas') if isinstance(jaimini.get('special_lagnas'), dict) else {} + arudha_padas = jaimini.get('arudha_padas') if isinstance(jaimini.get('arudha_padas'), dict) else {} + narayana = timing.get('narayana_dasha') if isinstance(timing.get('narayana_dasha'), dict) else dasha_families.get('narayana') + narayana = narayana if isinstance(narayana, dict) else {} + patyayini = annual.get('patyayini_dasha') if isinstance(annual.get('patyayini_dasha'), dict) else annual.get('patyayini') + patyayini = patyayini if isinstance(patyayini, dict) else {} + annual_external = annual.get('external_engine_comparison') if isinstance(annual.get('external_engine_comparison'), dict) else {} + pyjhora_replay = annual_external.get('pyjhora') if isinstance(annual_external.get('pyjhora'), dict) else {} + patyayini_replay = pyjhora_replay.get('patyayini_dasha') if isinstance(pyjhora_replay.get('patyayini_dasha'), dict) else {} + kp = advanced.get('kp') if isinstance(advanced.get('kp'), dict) else {} + yogini = dasha_families.get('yogini') if isinstance(dasha_families.get('yogini'), dict) else {} + ashtottari = dasha_families.get('ashtottari') if isinstance(dasha_families.get('ashtottari'), dict) else {} + kala_chakra = dasha_families.get('kala_chakra') if isinstance(dasha_families.get('kala_chakra'), dict) else {} + natal_sahams = advanced.get('sahams') if isinstance(advanced.get('sahams'), dict) else {} + avasthas = advanced.get('avasthas') if isinstance(advanced.get('avasthas'), dict) else {} + upagrahas = divisional.get('upagrahas') if isinstance(divisional.get('upagrahas'), dict) else {} + tajika_yogas = annual.get('tajika_yogas') if isinstance(annual.get('tajika_yogas'), dict) else {} + annual_sahams = annual.get('sahams') if isinstance(annual.get('sahams'), dict) else {} + graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {} + yogi_replay = jaimini.get('yogi_avayogi_external_replay') if isinstance(jaimini.get('yogi_avayogi_external_replay'), dict) else {} + p33_contract = strengths.get('pl9_p33_declination_contract') if isinstance(strengths.get('pl9_p33_declination_contract'), dict) else {} + p41_contract = strengths.get('pl9_p41_friendship_contract') if isinstance(strengths.get('pl9_p41_friendship_contract'), dict) else {} + p43_44_contract = strengths.get('pl9_p43_44_shadbala_contract') if isinstance(strengths.get('pl9_p43_44_shadbala_contract'), dict) else {} + p89_contract = advanced.get('pl9_p89_jaimini_special_points_contract') if isinstance(advanced.get('pl9_p89_jaimini_special_points_contract'), dict) else {} + + special_status = 'parameter_sensitive' if special_lagnas or arudha_padas else 'blocked' + narayana_status = 'parameter_sensitive' if narayana else 'blocked' + local_patyayini_status = str(patyayini.get('status') or patyayini.get('execution_status') or 'blocked') + if patyayini and local_patyayini_status != 'blocked': + patyayini_status = local_patyayini_status + elif patyayini_replay: + patyayini_status = 'pyjhora_behavior_only / not_multiengine_parity' + else: + patyayini_status = 'blocked' + kp_status = 'parameter_sensitive' if kp else 'blocked' + auxiliary_dasha_status = 'parameter_sensitive' if any( + item.get('periods') or item.get('execution_status') == 'executed' + for item in (yogini, ashtottari, kala_chakra) + ) else 'blocked' + natal_factors_status = 'parameter_sensitive' if any((natal_sahams, avasthas, upagrahas)) else 'blocked' + + return { + 'schema': 'pl9.professional_support_section.v1', + 'status': 'partial_verified' if any(value != 'blocked' for value in (special_status, narayana_status, patyayini_status, kp_status)) else 'blocked', + 'claim_boundary': ( + 'Professional support items are cross-check evidence only. They do not independently generate events, timing, or final judgments.' + ), + 'topics': { + 'special_lagnas': { + 'status': special_status, + 'source_paths': [ + 'worksheets.divisional_and_special_charts.special_lagnas', + 'worksheets.advanced_systems.jaimini.arudha_padas', + ], + 'available_keys': sorted(str(key) for key in special_lagnas.keys()), + 'arudha_keys': sorted(str(key) for key in arudha_padas.keys()), + }, + 'narayana_alignment': { + 'status': narayana_status, + 'source_paths': [ + 'worksheets.timing_and_predictive_systems.narayana_dasha', + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.narayana', + ], + 'execution_status': narayana.get('execution_status') or narayana.get('status'), + 'reference_alignment_status': (narayana.get('reference_alignment') or {}).get('status') if isinstance(narayana.get('reference_alignment'), dict) else None, + }, + 'patyayini_annual_support': { + 'status': patyayini_status, + 'source_paths': [ + 'worksheets.timing_and_predictive_systems.annual_tajika_pack.patyayini_dasha', + 'worksheets.timing_and_predictive_systems.annual_tajika_pack.external_engine_comparison.pyjhora.patyayini_dasha', + ], + 'local_period_count': len(patyayini.get('periods') or []) if isinstance(patyayini.get('periods'), list) else 0, + 'replay_status': patyayini_replay.get('status'), + }, + 'kp_domain_support': { + 'status': kp_status, + 'source_paths': [ + 'worksheets.advanced_systems.kp.houses', + 'worksheets.advanced_systems.kp.ruling_planets', + 'worksheets.advanced_systems.kp_monthly_report', + ], + 'available_layers': [ + key for key in ('houses', 'ruling_planets', 'planets') + if kp.get(key) + ], + }, + 'auxiliary_dasha_support': { + 'status': auxiliary_dasha_status, + 'source_paths': [ + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.yogini', + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.ashtottari', + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.kala_chakra', + ], + 'systems': { + 'yogini': { + 'available': bool(yogini.get('periods')), + 'status': yogini.get('confidence_status') or yogini.get('execution_status') or 'blocked', + }, + 'ashtottari': { + 'available': bool(ashtottari.get('periods')), + 'status': ashtottari.get('confidence_status') or ashtottari.get('execution_status') or 'blocked', + }, + 'kala_chakra': { + 'available': bool(kala_chakra.get('periods')), + 'status': kala_chakra.get('confidence_status') or kala_chakra.get('execution_status') or 'blocked', + }, + }, + }, + 'natal_special_factor_support': { + 'status': natal_factors_status, + 'source_paths': [ + 'worksheets.advanced_systems.sahams', + 'worksheets.advanced_systems.avasthas', + 'worksheets.divisional_and_special_charts.upagrahas', + ], + 'available_layers': { + 'natal_sahams': bool(natal_sahams), + 'avasthas': bool(avasthas), + 'upagrahas': bool(upagrahas), + }, + }, + 'restricted_research_materials': { + 'status': 'restricted_or_unclosed', + 'source_paths': [ + 'worksheets.advanced_systems.jaimini', + 'worksheets.timing_and_predictive_systems.annual_tajika_pack', + 'worksheets.strengths_and_scores.declination_ayana', + ], + 'claim_boundary': 'Visible for audit only; no row can generate a result claim, timing claim, or promotion decision.', + 'materials': { + 'jaimini_special_points': { + 'status': p89_contract.get('status') or yogi_replay.get('status') or 'blocked', + 'source_paths': [ + 'worksheets.advanced_systems.jaimini', + 'worksheets.advanced_systems.pl9_p89_jaimini_special_points_contract', + ], + 'reason': ( + 'Yogi/Ava Yogi now have a local same-formula producer with PyJHora replay alignment; ' + 'this is formula-level partial verification, not independent multi-engine parity. ' + 'Brahma/Maheshwara/Rudra still lack a same-profile structured producer.' + ), + 'p89_contract_summary': p89_contract.get('summary') if p89_contract else None, + 'yogi_avayogi_external_replay': yogi_replay, + }, + 'tajika_named_yoga': { + 'status': tajika_yogas.get('status') or 'blocked', + 'source_paths': ['worksheets.timing_and_predictive_systems.annual_tajika_pack.tajika_yogas'], + 'reason': tajika_yogas.get('reason') or 'Named-yoga adjudication is not admitted into report authority.', + }, + 'annual_sahams': { + 'status': annual_sahams.get('status') or 'blocked', + 'source_paths': ['worksheets.timing_and_predictive_systems.annual_tajika_pack.sahams'], + 'reason': 'Annual Sahams remain raw or partial support, not a fully admitted annual condition page.', + }, + 'kranti': { + 'status': 'missing_in_local / blocked', + 'source_paths': [ + 'worksheets.strengths_and_scores.declination_ayana', + 'worksheets.strengths_and_scores.pl9_p33_declination_contract', + ], + 'reason': ( + 'Declination/Ayana intermediates exist, but no independent Kranti producer is registered. ' + 'KP cusp longitudes now carry local-vs-PL9 comparison deltas while remaining parameter_sensitive.' + ), + 'p33_contract_summary': p33_contract.get('summary') if p33_contract else None, + }, + 'graha_padas': { + 'status': 'unclosed_divisional_chart' if graha_padas else 'blocked', + 'source_paths': ['worksheets.advanced_systems.jaimini.graha_padas'], + 'reason': 'Available adapted results are not yet same-profile compatible with the Lahiri report authority.', + }, + 'planetary_friendship': { + 'status': 'parameter_sensitive' if p41_contract else 'blocked', + 'source_paths': [ + 'worksheets.strengths_and_scores.planetary_friendship', + 'worksheets.strengths_and_scores.pl9_p41_friendship_contract', + ], + 'reason': 'p41 set-level relationship records are admitted for audit; the rendered local matrix and node vocabulary remain parameter_sensitive.', + 'p41_contract_summary': p41_contract.get('summary') if p41_contract else None, + }, + 'shadbala_components': { + 'status': 'parameter_sensitive' if p43_44_contract else 'blocked', + 'source_paths': [ + 'worksheets.strengths_and_scores.shadbala', + 'worksheets.strengths_and_scores.pl9_p43_44_shadbala_contract', + ], + 'reason': 'p43-p44 Sthana/Naisargika component observations are admitted; Dig/Kaala/Chesta/Drig/Total/Rupas/Rank remain parameter_sensitive.', + 'p43_44_contract_summary': p43_44_contract.get('summary') if p43_44_contract else None, + }, + }, + }, + }, + } + + +def _build_pl9_d1_d60_ledger_section(args) -> dict: + """Attach the existing 60-division ledger as read-only report evidence.""" + try: + from varga_evidence_matrix import build_varga_matrix + payload = { + 'year': int(getattr(args, 'year')), + 'month': int(getattr(args, 'month')), + 'day': int(getattr(args, 'day')), + 'hour': int(getattr(args, 'hour')), + 'minute': int(getattr(args, 'minute', 0) or 0), + 'second': int(getattr(args, 'second', 0) or 0), + 'lat': float(getattr(args, 'lat')), + 'lon': float(getattr(args, 'lon')), + 'tz': float(getattr(args, 'tz')), + 'ayanamsa': str(getattr(args, 'ayanamsa', 'raman') or 'raman'), + 'node_mode': str(getattr(args, 'node_mode', 'mean') or 'mean'), + 'position_mode': str(getattr(args, 'position_mode', 'legacy') or 'legacy'), + } + matrix = build_varga_matrix(payload, time_uncertainty_minutes=0) + return { + 'schema': 'pl9.d1_d60_ledger_section.v1', + 'status': 'available', + 'd1_to_d60': matrix.get('d1_to_d60') or {}, + 'summary': matrix.get('d1_to_d60_summary') or {}, + 'claim_boundary': ( + 'The 20 formal traditional divisions may be read only through their declared evidence boundaries; ' + 'the remaining 40 generic D-N rows are raw research data and cannot generate thematic conclusions.' + ), + } + except Exception as exc: + return { + 'schema': 'pl9.d1_d60_ledger_section.v1', + 'status': 'blocked', + 'reason': f'd1_d60_ledger_unavailable:{exc}', + 'd1_to_d60': {}, + 'summary': {}, + 'claim_boundary': 'A missing D1-D60 ledger cannot be silently replaced by a partial visual atlas.', + } + + +def _build_three_year_predictive_ephemeris_pack( + *, + timing: dict, + advanced: dict, + dasha: dict, + dasha_interpretation: dict, + annual: dict, + modules: dict, + profile: dict, +) -> dict: + kp_monthly = advanced.get('kp_monthly_report') if isinstance(advanced.get('kp_monthly_report'), dict) else {} + months = kp_monthly.get('months') if isinstance(kp_monthly.get('months'), list) else [] + yearly_highlights = kp_monthly.get('yearly_highlights') if isinstance(kp_monthly.get('yearly_highlights'), list) else [] + annual_year = ((annual.get('profile') or {}).get('target_year') if isinstance(annual.get('profile'), dict) else None) + annual_context = { + 'source_path': 'worksheets.timing_and_predictive_systems.annual_tajika_pack', + 'status': annual.get('status') or ('partial_verified' if annual else 'blocked'), + 'target_year': annual_year, + 'muntha': (annual.get('muntha') or {}) if isinstance(annual.get('muntha'), dict) else {}, + 'year_lord': (annual.get('year_lord') or {}) if isinstance(annual.get('year_lord'), dict) else {}, + 'mudda_dasha_status': ((annual.get('mudda_dasha') or {}).get('status') if isinstance(annual.get('mudda_dasha'), dict) else 'blocked'), + 'monthly_windows_count': len(annual.get('monthly_windows') or []) if isinstance(annual.get('monthly_windows'), list) else 0, + } + source_status = { + 'kp_monthly_report': { + 'status': kp_monthly.get('status') or ('parameter_sensitive' if months else 'blocked'), + 'source_path': 'worksheets.advanced_systems.kp_monthly_report', + 'month_count': len(months), + }, + 'vimshottari_five_levels': { + 'status': 'parameter_sensitive' if months else 'blocked', + 'source_path': 'worksheets.advanced_systems.kp_monthly_report.months[].vimshottari_five_levels', + }, + 'monthly_transits': { + 'status': 'parameter_sensitive' if months else 'blocked', + 'source_path': 'worksheets.advanced_systems.kp_monthly_report.months[].monthly_transits', + }, + 'dasha_master_pack': { + 'status': ((dasha.get('audit') or {}).get('status') if isinstance(dasha.get('audit'), dict) else dasha.get('status') or 'partial_verified'), + 'source_path': 'worksheets.timing_and_predictive_systems.dasha_master_pack', + }, + 'dasha_interpretation_pack': { + 'status': dasha_interpretation.get('status') or 'partial_verified', + 'source_path': 'worksheets.timing_and_predictive_systems.dasha_interpretation_pack', + }, + 'narayana_dasha': { + 'status': 'partial_verified' if modules.get('narayana_dasha') or timing.get('narayana_dasha') else 'blocked', + 'source_path': 'worksheets.timing_and_predictive_systems.narayana_dasha', + }, + 'transit_multi_reference': { + 'status': 'partial_verified' if modules.get('transit_multi_reference') or timing.get('transit_multi_reference') else 'blocked', + 'source_path': 'worksheets.timing_and_predictive_systems.transit_multi_reference', + }, + 'annual_tajika_pack': { + 'status': annual_context['status'], + 'source_path': annual_context['source_path'], + }, + } + + rows = [] + for row in months: + if not isinstance(row, dict): + continue + levels = row.get('vimshottari_five_levels') if isinstance(row.get('vimshottari_five_levels'), dict) else {} + level_rows = levels.get('levels') if isinstance(levels.get('levels'), dict) else {} + monthly_transits = row.get('monthly_transits') if isinstance(row.get('monthly_transits'), dict) else {} + planets = monthly_transits.get('planets') if isinstance(monthly_transits.get('planets'), dict) else {} + slow_planets = {} + for planet in ('Jupiter', 'Saturn', 'Rahu', 'Ketu'): + pdata = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + if pdata: + degree_in_sign = pdata.get('degree_in_sign') + if degree_in_sign is None: + degree_in_sign = pdata.get('degree') + if degree_in_sign is None and isinstance(pdata.get('longitude'), (int, float)): + degree_in_sign = round(float(pdata.get('longitude')) % 30, 4) + slow_planets[planet] = { + 'sign': pdata.get('sign'), + 'degree_in_sign': degree_in_sign, + 'retrograde': pdata.get('retrograde'), + } + support = row.get('theme_support') if isinstance(row.get('theme_support'), dict) else {} + rows.append({ + 'month': row.get('month'), + 'anchor_local': row.get('anchor_local'), + 'vimshottari': { + 'mahadasha': (level_rows.get('mahadasha') or {}).get('lord') if isinstance(level_rows.get('mahadasha'), dict) else None, + 'antardasha': (level_rows.get('antardasha') or {}).get('lord') if isinstance(level_rows.get('antardasha'), dict) else None, + 'pratyantardasha': (level_rows.get('pratyantardasha') or {}).get('lord') if isinstance(level_rows.get('pratyantardasha'), dict) else None, + 'sookshma': (level_rows.get('sookshma') or {}).get('lord') if isinstance(level_rows.get('sookshma'), dict) else None, + 'prana': (level_rows.get('prana') or {}).get('lord') if isinstance(level_rows.get('prana'), dict) else None, + }, + 'slow_planets': slow_planets, + 'slow_planet_signals': row.get('slow_planet_signals') if isinstance(row.get('slow_planet_signals'), list) else [], + 'theme_support': { + domain: { + 'promise_code': (item.get('promise_code') if isinstance(item, dict) else None), + 'brief_summary': (item.get('brief_summary') if isinstance(item, dict) else None), + } + for domain, item in support.items() + if domain in {'career', 'wealth', 'relationship'} + }, + 'evidence_label': 'parameter_sensitive', + 'source_path': 'worksheets.advanced_systems.kp_monthly_report.months', + }) + status = 'partial_verified' if rows else 'blocked' + return { + 'schema': 'jyotish.three_year_predictive_ephemeris_pack.v1', + 'status': status, + 'profile': { + 'profile_id': profile.get('profile_id'), + 'ayanamsa': profile.get('ayanamsa') or profile.get('ayanamsa_name'), + 'node_mode': profile.get('node_mode'), + 'month_count': len(rows), + 'start_month': rows[0]['month'] if rows else None, + 'end_month': rows[-1]['month'] if rows else None, + 'anchor_policy': (kp_monthly.get('window') or {}).get('anchor_policy') if isinstance(kp_monthly.get('window'), dict) else 'local_month_start_noon', + }, + 'source_status': source_status, + 'annual_context': annual_context, + 'yearly_highlights': yearly_highlights, + 'months': rows, + 'audit': { + 'claim_boundary': 'This is a local three-year monthly predictive ledger, not PL9 graphical ephemeris parity and not an exact event prediction oracle.', + 'must_not_claim': [ + 'pl9_graphical_ephemeris_numeric_parity', + 'daily_ephemeris_complete_for_every_day', + 'specific_event_truth', + 'prashna_oracle_closure', + ], + 'evidence_label': 'parameter_sensitive' if rows else 'blocked', + }, + } + + +def _attach_full_report_pack(packet: dict, args) -> dict: + if packet.get('selected_report_scope') != 'full': + return packet + profile = packet.get('calculation_profile') or {} + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {} + advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {} + modules = packet.get('modules') if isinstance(packet.get('modules'), dict) else {} + required_kp_birth_fields = ('year', 'month', 'day', 'hour', 'lat', 'lon', 'tz') + has_kp_birth_payload = all(getattr(args, field, None) is not None for field in required_kp_birth_fields) + if isinstance(advanced, dict) and has_kp_birth_payload: + build_kp_monthly_report_packet = _try_attr_import( + "kp_monthly_report_packet", "build_kp_monthly_report_packet" + ) + if build_kp_monthly_report_packet is None: + advanced['kp_monthly_report'] = { + 'schema': 'kp.monthly_report_packet.v1', + 'status': 'blocked', + 'reason': 'kp_monthly_report_packet_absent', + } + else: + target_year = getattr(args, 'target_year', None) + today_text = getattr(args, 'today', None) + if target_year not in (None, ''): + monthly_start_year = int(target_year) + elif today_text: + monthly_start_year = int(str(today_text)[:4]) + else: + monthly_start_year = datetime.now().year + advanced['kp_monthly_report'] = build_kp_monthly_report_packet( + birth_payload={ + 'year': int(getattr(args, 'year')), + 'month': int(getattr(args, 'month')), + 'day': int(getattr(args, 'day')), + 'hour': int(getattr(args, 'hour')), + 'minute': int(getattr(args, 'minute', 0) or 0), + 'second': int(getattr(args, 'second', 0) or 0), + 'lat': float(getattr(args, 'lat')), + 'lon': float(getattr(args, 'lon')), + 'tz': float(getattr(args, 'tz')), + }, + start_month=f'{monthly_start_year:04d}-01', + month_count=36, + ) + elif isinstance(advanced, dict): + advanced['kp_monthly_report'] = { + 'schema': 'kp.monthly_report_packet.v1', + 'status': 'blocked', + 'reason': 'birth_input_missing_for_kp_monthly_report', + } + annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {} + if not annual: + annual = _build_pl9_full_annual_section(args, profile) + dasha = timing.get('dasha_master_pack') if isinstance(timing.get('dasha_master_pack'), dict) else {} + if not dasha: + dasha = _build_pl9_full_dasha_section(packet, modules) + _attach_profile_id_to_dasha_section(dasha, profile) + dasha_interpretation = timing.get('dasha_interpretation_pack') if isinstance(timing.get('dasha_interpretation_pack'), dict) else {} + if not dasha_interpretation: + try: + from professional_parity_closure import build_dasha_interpretation_pack + dasha_interpretation = build_dasha_interpretation_pack(dasha, profile) + except Exception as exc: + dasha_interpretation = { + 'schema': 'pl9.dasha_interpretation_pack.v1', + 'status': 'blocked', + 'reason': str(exc), + } + three_year_predictive_ephemeris_pack = _build_three_year_predictive_ephemeris_pack( + timing=timing, + advanced=advanced, + dasha=dasha, + dasha_interpretation=dasha_interpretation, + annual=annual, + modules=modules, + profile=profile, + ) + timing['three_year_predictive_ephemeris_pack'] = three_year_predictive_ephemeris_pack + sections = { + 'base': { + 'status': 'partial_verified' if worksheets.get('birth_and_parameters') or worksheets.get('d1_rasi_bhava') else 'blocked', + 'birth_and_parameters': worksheets.get('birth_and_parameters'), + 'd1_rasi_bhava': worksheets.get('d1_rasi_bhava'), + }, + 'divisional': { + 'status': 'partial_verified' if worksheets.get('divisional_and_special_charts') else 'blocked', + 'data': worksheets.get('divisional_and_special_charts'), + }, + 'strength': { + 'status': 'partial_verified' if worksheets.get('strengths_and_scores') else 'blocked', + 'data': worksheets.get('strengths_and_scores'), + }, + 'dasha': dasha, + 'dasha_interpretation': dasha_interpretation, + 'annual': annual, + 'yoga': { + 'status': 'partial_verified' if modules.get('yoga') or advanced.get('yoga') or worksheets.get('yogas_and_doshas') else 'blocked', + 'data': modules.get('yoga') or advanced.get('yoga') or worksheets.get('yogas_and_doshas'), + }, + 'dosha': { + 'status': 'partial_verified' if modules.get('yogas_doshas') or advanced.get('yogas_doshas') or worksheets.get('yogas_and_doshas') else 'blocked', + 'data': modules.get('yogas_doshas') or advanced.get('yogas_doshas') or worksheets.get('yogas_and_doshas'), + }, + 'transit': { + 'status': 'partial_verified' if modules.get('transit_multi_reference') or modules.get('transit') else 'blocked', + 'data': modules.get('transit_multi_reference') or modules.get('transit'), + }, + 'three_year_predictive_ephemeris': three_year_predictive_ephemeris_pack, + 'd1_d60_ledger': _build_pl9_d1_d60_ledger_section(args), + 'professional_support': _build_pl9_professional_support_section(worksheets), + 'audit_appendix': _build_pl9_full_audit_appendix(packet, dasha, annual), + } + sections = _json_safe_report_snapshot(sections) + sections['ai_evidence_bundle'] = { + 'schema': 'pl9.full_report_pack.ai_evidence.v1', + 'profile_id': profile.get('profile_id'), + 'contains_private_pl9_text': False, + 'section_keys': list(sections.keys()), + 'claim_policy': 'blocked_and_conflict_fields_cannot_generate_final_predictions', + } + packet['full_report_pack'] = { + 'schema': 'pl9.full_report_pack.v1', + 'profile': _json_safe_report_snapshot(profile), + 'sections': sections, + 'status': sections['audit_appendix']['status'], + } + packet['personal_report_producer'] = _build_personal_report_producer(packet) + return packet + + +def _attach_startrack_language_bridge(packet: dict, args) -> dict: + if not getattr(args, 'startrack_language_bridge', False): + return packet + if packet.get('selected_report_scope') != 'full': + return packet + builder = _try_attr_import("startrack_language_bridge_adapter", "build_startrack_language_enrichment") + if builder is None: + packet.setdefault('startrack_language_bridge', { + 'status': 'blocked', + 'reason': 'startrack_language_bridge_adapter_absent', + }) + return packet + minute = int(getattr(args, 'minute', 0) or 0) + result = builder( + date=f"{int(getattr(args, 'year')):04d}-{int(getattr(args, 'month')):02d}-{int(getattr(args, 'day')):02d}", + time=f"{int(getattr(args, 'hour')):02d}:{minute:02d}", + gender=getattr(args, 'startrack_gender', 'female'), + longitude=float(getattr(args, 'lon')), + latitude=float(getattr(args, 'lat')), + location=getattr(args, 'startrack_location', None), + time_source=getattr(args, 'startrack_time_source', 'approximate'), + uncertainty=int(getattr(args, 'startrack_uncertainty', 4) or 4), + events_file=getattr(args, 'startrack_events_file', None), + startrack_dir=getattr(args, 'startrack_dir', None), + locale=getattr(args, 'startrack_locale', 'zh-CN'), + timeout_seconds=int(getattr(args, 'startrack_timeout_seconds', 60) or 60), + ) + packet['startrack_language_bridge'] = result + full_report_pack = packet.get('full_report_pack') if isinstance(packet.get('full_report_pack'), dict) else {} + sections = full_report_pack.get('sections') if isinstance(full_report_pack.get('sections'), dict) else {} + if isinstance(sections, dict): + ziwei_audit = result.get('ziwei_doushu_audit') if isinstance(result.get('ziwei_doushu_audit'), dict) else {} + qizheng_pack = ( + ziwei_audit.get('qizheng_practical_rule_pack') + if isinstance(ziwei_audit.get('qizheng_practical_rule_pack'), dict) + else {} + ) + qizheng_pack_summary = {} + if qizheng_pack: + qizheng_pack_summary = { + 'schema': qizheng_pack.get('schema'), + 'status': qizheng_pack.get('status'), + 'source_authority': qizheng_pack.get('source_authority'), + 'rule_count': qizheng_pack.get('rule_count', 0), + 'report_template_count': qizheng_pack.get('report_template_count', 0), + 'report_observation_count': qizheng_pack.get('report_observation_count', 0), + 'case_specific_observation_count': qizheng_pack.get('case_specific_observation_count', 0), + 'topic_index_count': qizheng_pack.get('topic_index_count', 0), + 'theme_material_count': qizheng_pack.get('theme_material_count', 0), + 'theme_material_report_ready_count': qizheng_pack.get('theme_material_report_ready_count', 0), + 'runtime_promotable_count': qizheng_pack.get('runtime_promotable_count', 0), + 'theme_material_titles': [ + str(material.get('title')) + for material in (qizheng_pack.get('theme_materials') or []) + if isinstance(material, dict) and material.get('title') + ], + 'boundary': qizheng_pack.get('language_bridge_boundary'), + } + sections['startrack_language_bridge'] = { + 'schema': 'pl9.full_report_pack.startrack_language_bridge_section.v1', + 'status': result.get('status', 'blocked') if isinstance(result, dict) else 'blocked', + 'data': result, + 'qizheng_practical_rule_pack_summary': qizheng_pack_summary, + 'boundary': ( + 'Optional StarTrack language enrichment only. Jyotish raw ' + 'calculation, report evidence, and external parity remain authoritative.' + ), + } + full_report_pack['sections'] = sections + packet['full_report_pack'] = full_report_pack + return packet + + +def _build_personal_report_producer(packet: dict) -> dict: + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + full_report_pack = packet.get('full_report_pack') if isinstance(packet.get('full_report_pack'), dict) else {} + sections = full_report_pack.get('sections') if isinstance(full_report_pack.get('sections'), dict) else {} + timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {} + advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {} + divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + strength = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {} + annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {} + dasha = sections.get('dasha') if isinstance(sections.get('dasha'), dict) else {} + dasha_interpretation = sections.get('dasha_interpretation') if isinstance(sections.get('dasha_interpretation'), dict) else {} + visual = divisional.get('visual_chart_pack') if isinstance(divisional.get('visual_chart_pack'), dict) else {} + dasha_master_pack = timing.get('dasha_master_pack') if isinstance(timing.get('dasha_master_pack'), dict) else {} + dasha_families = dasha_master_pack.get('families') if isinstance(dasha_master_pack.get('families'), dict) else {} + kala_chakra = dasha_families.get('kala_chakra') if isinstance(dasha_families.get('kala_chakra'), dict) else {} + ashtottari_family = dasha_families.get('ashtottari') if isinstance(dasha_families.get('ashtottari'), dict) else {} + + annual_path = 'worksheets.timing_and_predictive_systems.annual_tajika_pack' + year_lord_path = f'{annual_path}.year_lord' + + main_body_segment_ids = [] + if worksheets.get('birth_and_parameters'): + main_body_segment_ids.append('birth_profile_1_3') + if worksheets.get('d1_rasi_bhava'): + main_body_segment_ids.append('d1_structure_1_10') + vimshottari = ((dasha.get('families') or {}).get('vimshottari') or {}) if isinstance(dasha.get('families'), dict) else {} + if vimshottari.get('periods') or vimshottari.get('execution_status') == 'executed': + main_body_segment_ids.append('vimshottari_core_62_74') + if annual: + main_body_segment_ids.append('annual_shell_61_125_135') + annual_chart = annual.get('annual_chart') if isinstance(annual.get('annual_chart'), dict) else {} + annual_chart_data = annual_chart.get('data') if isinstance(annual_chart.get('data'), dict) else {} + if annual_chart.get('status') == 'partial_verified' and ( + annual_chart_data.get('asc_sign') is not None or annual_chart_data.get('asc_sign_idx') is not None + ): + main_body_segment_ids.append('annual_asc_125_126') + muntha = annual.get('muntha') if isinstance(annual.get('muntha'), dict) else {} + muntha_data = muntha.get('data') if isinstance(muntha.get('data'), dict) else {} + if muntha.get('status') == 'partial_verified' and ( + muntha_data.get('muntha_sign') is not None or muntha_data.get('muntha_lord') is not None + ): + main_body_segment_ids.append('muntha_125_126') + jaimini = advanced.get('jaimini') if isinstance(advanced.get('jaimini'), dict) else {} + if jaimini.get('chara_karaka_7') and jaimini.get('karakamsha'): + main_body_segment_ids.append('jaimini_core_89_91') + + rich_support_segment_ids = [] + if divisional.get('summary_card') or divisional.get('detail_blocks'): + rich_support_segment_ids.append('divisional_overview_6_16') + if divisional.get('varga_full'): + rich_support_segment_ids.append('varga_full_6_16') + if divisional.get('special_lagnas'): + rich_support_segment_ids.append('special_lagnas_11_16') + if divisional.get('upagrahas'): + rich_support_segment_ids.append('upagrahas_17') + if strength.get('shadbala') or strength.get('ashtakavarga') or strength.get('functional_benefic_malefic'): + rich_support_segment_ids.append('strength_ashtakavarga_31_60') + if advanced.get('sahams'): + rich_support_segment_ids.append('natal_sahams_support_126_155') + if jaimini.get('arudha_padas') or jaimini.get('special_lagnas'): + rich_support_segment_ids.append('jaimini_special_points_89_91') + if dasha_interpretation: + rich_support_segment_ids.append('dasha_interpretation_190_204') + kp = advanced.get('kp') if isinstance(advanced.get('kp'), dict) else {} + if kp: + rich_support_segment_ids.append('kp_123_124') + + conflict_segment_ids = [] + required_tags = {} + conflict_source_paths = [] + conflict_segment_details = {} + if kala_chakra and kala_chakra.get('execution_status') == 'executed': + conflict_segment_ids.append('kalachakra_83_88') + required_tags['kalachakra_83_88'] = ['algorithm_conflict', 'requires_label'] + conflict_source_paths.append( + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.kala_chakra' + ) + if ashtottari_family and ashtottari_family.get('execution_status') == 'executed': + asht_current_periods = _resolve_current_recursive_periods_from_family(ashtottari_family) + asht_current = asht_current_periods.get('major') if isinstance(asht_current_periods.get('major'), dict) else {} + conflict_segment_ids.append('ashtottari_75_79') + required_tags['ashtottari_75_79'] = ['parameter_sensitive', 'requires_label'] + conflict_source_paths.append( + 'worksheets.timing_and_predictive_systems.dasha_master_pack.families.ashtottari' + ) + conflict_segment_details['ashtottari_75_79'] = { + 'applicability_rule_family': ashtottari_family.get('calculation_profile', {}).get('applicability_rule_family'), + 'starting_planet': ashtottari_family.get('starting_lord') or ashtottari_family.get('starting_planet'), + 'applicability_reason': ashtottari_family.get('calculation_profile', {}).get('applicability_reason') or ashtottari_family.get('reason'), + 'execution_status': ashtottari_family.get('execution_status'), + 'confidence_status': ashtottari_family.get('confidence_status'), + 'total_cycle': ashtottari_family.get('calculation_profile', {}).get('total_cycle') or ashtottari_family.get('total_cycle'), + 'period_levels': ashtottari_family.get('period_levels') or [], + 'native_result_fields': ashtottari_family.get('calculation_profile', {}).get('native_result_fields', []), + 'dasha_balance_at_birth': ashtottari_family.get('dasha_balance_at_birth'), + 'dasha_at_birth': ashtottari_family.get('dasha_at_birth'), + 'current_period': { + 'lord': asht_current.get('lord') or asht_current.get('planet'), + 'years': asht_current.get('years'), + 'start_date': asht_current.get('start_date'), + 'end_date': asht_current.get('end_date'), + } if asht_current else None, + 'current_antardasha': asht_current_periods.get('antardasha'), + 'current_pratyantardasha': asht_current_periods.get('pratyantardasha'), + } + year_lord_field = annual.get('year_lord', {}) if isinstance(annual.get('year_lord'), dict) else {} + if year_lord_field.get('status') == 'conflict': + conflict_segment_ids.append('annual_year_lord_125_126') + required_tags['annual_year_lord_125_126'] = ['parameter_conflict', 'requires_label'] + conflict_source_paths.append( + 'worksheets.timing_and_predictive_systems.annual_tajika_pack.year_lord' + ) + if kala_chakra: + kala_current_periods = _resolve_current_recursive_periods_from_family(kala_chakra) + kala_current = kala_current_periods.get('major') if isinstance(kala_current_periods.get('major'), dict) else {} + conflict_segment_details['kalachakra_83_88'] = { + 'mode': kala_chakra.get('calculation_profile', {}).get('mode') or kala_chakra.get('mode'), + 'starting_rashi': kala_chakra.get('starting_rashi') or kala_chakra.get('calculation_profile', {}).get('starting_rashi'), + 'starting_lord': kala_chakra.get('starting_lord') or kala_chakra.get('calculation_profile', {}).get('starting_lord'), + 'deha_rashi': kala_chakra.get('deha_rashi'), + 'jeeva_rashi': kala_chakra.get('jeeva_rashi'), + 'dasha_balance_at_birth': kala_chakra.get('dasha_balance_at_birth'), + 'total_cycle': kala_chakra.get('calculation_profile', {}).get('total_cycle') or kala_chakra.get('total_cycle'), + 'period_levels': kala_chakra.get('period_levels', []), + 'reason': kala_chakra.get('reason'), + 'source_path': kala_chakra.get('source_path'), + 'native_result_fields': kala_chakra.get('calculation_profile', {}).get('native_result_fields', []), + 'execution_status': kala_chakra.get('execution_status'), + 'confidence_status': kala_chakra.get('confidence_status'), + 'period_count': len(kala_chakra.get('periods', []) or []), + 'current_period': { + 'lord': kala_current.get('lord'), + 'rashi': kala_current.get('rashi'), + 'start_date': kala_current.get('start_date'), + 'end_date': kala_current.get('end_date'), + } if kala_current else None, + 'current_antardasha': kala_current_periods.get('antardasha'), + 'current_pratyantardasha': kala_current_periods.get('pratyantardasha'), + } + if year_lord_field.get('status') == 'conflict': + conflict_segment_details['annual_year_lord_125_126'] = { + 'source_path': year_lord_path, + 'values': year_lord_field.get('values'), + 'status': year_lord_field.get('status'), + 'reason': year_lord_field.get('reason'), + 'field': year_lord_field.get('field') or 'year_lord', + 'producers_checked': year_lord_field.get('producers_checked'), + } + + blocked_segment_ids = [] + blocked_segment_details = {} + ashtottari = ashtottari_family + if ashtottari.get('execution_status') == 'blocked': + blocked_segment_ids.append('ashtottari_75_79') + blocked_segment_details['ashtottari_75_79'] = { + 'reason': ashtottari.get('reason'), + 'execution_status': ashtottari.get('execution_status'), + 'confidence_status': ashtottari.get('confidence_status'), + 'native_runtime_sources': [ + 'scripts/ashtottari_dasha.py:is_ashtottari_applicable', + 'scripts/nakshatra_dasha.py:is_ashtottari_applicable', + ], + 'rule_count': 4, + 'promotion_target': 'keep_native_producer_parameter_sensitive_until_rule_packet_freezes', + 'primary_source_rule_packet': 'docs/research/ashtottari_primary_source_rule_packet_2026_08_10.md', + } + annual_sahams = annual.get('sahams') if isinstance(annual.get('sahams'), dict) else {} + if annual_sahams.get('status') in {'blocked', 'conflict'}: + blocked_segment_ids.append('annual_sahams_126_155') + annual_exec = ((annual.get('report_sections') or {}).get('executive_summary') or {}) if isinstance(annual.get('report_sections'), dict) else {} + if annual_exec.get('status') == 'blocked': + blocked_segment_ids.append('tajika_condition_status_136_138') + blocked_segment_details['tajika_condition_status_136_138'] = { + 'reason': annual_exec.get('reason'), + 'execution_status': annual_exec.get('status'), + 'confidence_status': annual_exec.get('status'), + } + if kp: + blocked_segment_ids.append('kp_boundary_123_124') + blocked_segment_details['kp_boundary_123_124'] = { + 'reason': 'kp_event_timing_oracle_parity_not_available', + 'execution_status': 'blocked', + 'confidence_status': 'blocked', + } + + source_pack_contracts = _personal_report_source_pack_contracts( + annual=annual, + dasha=dasha, + dasha_interpretation=dasha_interpretation, + visual=visual, + kp=kp, + ) + + return _json_safe_report_snapshot({ + 'schema': 'jyotish.personal_report_producer.v1', + 'status': 'partial_verified', + 'main_body': { + 'output_level': 'core_trusted_conclusion', + 'segment_ids': main_body_segment_ids, + 'source_paths': [ + 'worksheets.birth_and_parameters', + 'worksheets.d1_rasi_bhava', + 'worksheets.timing_and_predictive_systems.dasha_master_pack', + annual_path, + 'worksheets.advanced_systems.jaimini', + ], + 'claim_policy': 'p0_only_no_blocked_promotion', + }, + 'main_body_topics': { + 'annual': { + 'segment_ids': ['annual_shell_61_125_135', 'annual_asc_125_126', 'muntha_125_126'], + 'source_path': annual_path, + 'year_lord_source_path': year_lord_path, + 'source_paths': [ + annual_path, + year_lord_path, + ], + 'must_not_claim': ['mixed_tajika_path_claim'], + }, + }, + 'rich_support': { + 'output_level': 'expandable_high_value_support', + 'segment_ids': rich_support_segment_ids, + 'required_tags': { + 'dasha_interpretation_190_204': ['appendix_preferred', 'partial_verified'], + 'kp_123_124': ['requires_label', 'kp_support_only', 'kp_sub_lord_249_segment_parity_closed', 'event_timing_oracle_not_closed'], + }, + 'source_status': { + 'has_divisional_support': bool(divisional), + 'has_strength_support': bool(strength), + 'has_jaimini_support': bool(jaimini), + 'has_dasha_interpretation': bool(dasha_interpretation), + 'has_kp_support': bool(kp), + }, + }, + 'support_topics': { + 'kp': { + 'segment_ids': ['kp_123_124'], + 'source_path': 'worksheets.advanced_systems.kp', + 'support_level': 'labeled_support_only', + 'closed_parity_assets': ['kp_sub_lord_249_segment_parity'], + 'must_not_claim': ['kp_event_timing_oracle_closed_parity'], + }, + }, + 'conflict_labeled': { + 'output_level': 'conditional_conflict_labeled_output', + 'segment_ids': conflict_segment_ids, + 'required_tags': required_tags, + 'source_paths': conflict_source_paths, + 'segment_details': conflict_segment_details, + }, + 'blocked_audit': { + 'output_level': 'explicit_blocked_audit_output', + 'segment_ids': blocked_segment_ids, + 'segment_details': blocked_segment_details, + 'blocked_policy': 'visible_not_promoted', + }, + 'source_pack_contracts': source_pack_contracts, + }) + + +def _personal_report_source_pack_contracts( + *, + annual: dict, + dasha: dict, + dasha_interpretation: dict, + visual: dict, + kp: dict, +) -> dict: + try: + from report_pack_contract import normalize_report_pack_contract + except ImportError: # pragma: no cover + from scripts.report_pack_contract import normalize_report_pack_contract + + def _normalized_or_existing(pack: dict, pack_id: str) -> dict: + exports = pack.get('exports') if isinstance(pack.get('exports'), dict) else {} + existing = exports.get('unified_report_pack_contract') if isinstance(exports, dict) else None + if isinstance(existing, dict): + return existing + if isinstance(pack, dict) and pack: + return normalize_report_pack_contract(pack, pack_id=pack_id) + return { + 'schema': 'pl9.unified_report_pack_contract.v1', + 'pack_id': pack_id, + 'status': 'blocked', + 'blocked_reasons': [f'{pack_id}_missing'], + } + + return { + 'annual_tajika_pack': _normalized_or_existing(annual, 'annual_tajika_pack'), + 'dasha_master_pack': _normalized_or_existing(dasha, 'dasha_master_pack'), + 'dasha_interpretation_pack': _normalized_or_existing(dasha_interpretation, 'dasha_interpretation_pack'), + 'visual_chart_pack': _normalized_or_existing(visual, 'visual_chart_pack'), + 'kp_support_snapshot': { + 'schema': 'jyotish.personal_report_producer.kp_support_snapshot.v1', + 'pack_id': 'kp_support_snapshot', + 'status': 'partial_verified' if kp else 'blocked', + 'source_path': 'worksheets.advanced_systems.kp', + 'support_level': 'labeled_support_only', + 'execution_status': 'executed' if kp else 'blocked', + 'closed_parity_assets': ['kp_sub_lord_249_segment_parity'] if kp else [], + 'must_not_claim': ['kp_event_timing_oracle_closed_parity'], + 'data': kp if kp else {}, + }, + } + + +def _attach_annual_tajika_pack(packet: dict, args) -> dict: + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else None + if timing is None: + return packet + timing['annual_tajika_pack'] = _json_safe_report_snapshot( + _build_pl9_full_annual_section( + args, + packet.get('calculation_profile') or {}, + ), + args=args, + ) + target_year = (timing['annual_tajika_pack'].get('profile') or {}).get('target_year') + if target_year is None: + target_year = getattr(args, 'target_year', None) + try: + target_year = int(target_year) + except (TypeError, ValueError): + return packet + def _annual_series_surface(annual_pack: dict) -> dict: + """Keep independently calculated annual evidence without duplicating full replay payloads.""" + surface = { + key: _json_safe_report_snapshot(annual_pack.get(key)) + for key in ('schema', 'status', 'reason', 'profile', 'audit', 'report_sections', 'annual_chart', 'year_lord') + if key in annual_pack + } + surface['status'] = surface.get('status') or 'partial_verified' + return surface + + annual_years = {str(target_year): _annual_series_surface(timing['annual_tajika_pack'])} + for year in range(target_year + 1, target_year + 3): + annual_args = SimpleNamespace(**vars(args)) + annual_args.target_year = year + # The primary year keeps its full PyJHora replay. Future years are + # independently calculated annual packets, but do not repeat the costly + # external replay during a single full-report export. + try: + from annual_tajika_pack import build_annual_tajika_pack + annual_years[str(year)] = _annual_series_surface( + build_annual_tajika_pack(_annual_payload_from_args(annual_args, packet.get('calculation_profile') or {})) + ) + except Exception as exc: + annual_years[str(year)] = { + 'schema': 'jyotish.annual_tajika_pack.v1', + 'status': 'blocked', + 'profile': {'target_year': year}, + 'reason': f'independent_annual_packet_failed:{exc}', + } + timing['annual_tajika_series'] = { + 'schema': 'jyotish.annual_tajika_series.v1', + 'status': 'partial_verified', + 'years': annual_years, + 'boundary': 'Each year is independently calculated from its own solar-return target year. Tajika and Patyayini remain parameter-sensitive unless their parity gates close.', + } + return packet + + +def _birth_provenance_from_args(args) -> dict: + return { + 'schema': 'jyotish.birth_provenance.v1', + 'birthplace_label': getattr(args, 'birthplace_label', None) or 'not_recorded', + 'coordinate_precision': getattr(args, 'coordinate_precision', None) or 'unverified_user_coordinates', + 'coordinate_source': getattr(args, 'coordinate_source', None) or 'not_recorded', + 'time_source': getattr(args, 'time_source', None) or 'not_recorded', + 'uncertainty_minutes': getattr(args, 'uncertainty_minutes', None), + 'boundary': 'A hospital label is not treated as hospital-grade coordinates unless coordinate_source and coordinate_precision explicitly support it.', + } + + +def _event_replay_contract(args) -> dict: + blind_holdout_policy = { + 'status': 'required_before_execution', + 'freeze_user_labels_before_comparison': True, + 'forbid_birth_time_or_parameter_tuning_from_same_events': True, + 'minimum_event_fields': [ + 'event_id', 'year_or_date', 'domain', 'event_description', + 'outcome_or_observation', 'evidence_strength', + ], + 'boundary': ( + 'Known events are calibration labels, not prompts for generating or rewriting conclusions. ' + 'Freeze labels before candidate comparison; keep at least one held-out event when a replay is executed.' + ), + } + path = getattr(args, 'event_replay_file', None) + if not path: + candidate_segment_table = _load_1993_candidate_segment_table(args) + return { + 'schema': 'jyotish.event_replay_contract.v1', + 'status': 'blocked', + 'reason': 'user_history_not_supplied', + 'required_event_fields': blind_holdout_policy['minimum_event_fields'], + 'validation_lanes': ['Vimshottari', 'Narayana', 'transit_multi_reference', 'annual_tajika'], + 'family_d12_binding': ( + 'Parent/family events may be entered with domain=family and compared against D12 only as ' + 'candidate discrimination evidence; D12 cannot select a minute by itself.' + ), + 'candidate_segment_table': candidate_segment_table, + 'candidate_segment_reference': ( + 'candidate_segment_table_not_imported' + if candidate_segment_table + else None + ), + 'blind_holdout_policy': blind_holdout_policy, + 'boundary': 'No user-known events were supplied. This report does not claim retrospective calibration.', + } + try: + events = json.loads(Path(path).read_text(encoding='utf-8')) + if not isinstance(events, list): + raise ValueError('expected JSON array') + except Exception as exc: + candidate_segment_table = _load_1993_candidate_segment_table(args) + return { + 'schema': 'jyotish.event_replay_contract.v1', 'status': 'blocked', + 'reason': f'event_replay_file_invalid:{exc}', 'events': [], + 'candidate_segment_table': candidate_segment_table, + 'candidate_segment_reference': ( + 'candidate_segment_table_not_imported' + if candidate_segment_table + else None + ), + 'blind_holdout_policy': blind_holdout_policy, + } + candidate_segment_table = _load_1993_candidate_segment_table(args) + return { + 'schema': 'jyotish.event_replay_contract.v1', + 'status': 'intake_ready', + 'events': events, + 'validation_lanes': ['Vimshottari', 'Narayana', 'transit_multi_reference', 'annual_tajika'], + 'family_d12_binding': ( + 'Parent/family events may be entered with domain=family and compared against D12 only as ' + 'candidate discrimination evidence; D12 cannot select a minute by itself.' + ), + 'candidate_segment_table': candidate_segment_table, + 'candidate_segment_reference': ( + 'candidate_segment_table_not_imported' + if candidate_segment_table + else None + ), + 'blind_holdout_policy': blind_holdout_policy, + 'boundary': 'Events are ingested for later blind replay comparison; intake alone is not a calibration pass.', + } + + +def _load_1993_candidate_segment_table(args) -> dict | None: + # Author-private candidate table is not imported (hard red line 3 / privacy). + return None + +def _load_rectification_evidence_contract() -> dict: + """Load the versioned event-to-varga registry without scoring personal events.""" + path = Path(__file__).resolve().parents[1] / 'references/rectification_evidence_contract_v1.json' + try: + contract = json.loads(path.read_text(encoding='utf-8')) + if not isinstance(contract, dict) or not isinstance(contract.get('domains'), list): + raise ValueError('domains must be a JSON array') + return contract + except Exception as exc: + return { + 'schema': 'jyotish.rectification_evidence_contract.v1', + 'status': 'blocked', + 'reason': f'rectification_evidence_contract_unavailable:{exc}', + 'domains': [], + } + + +def _build_birth_time_sensitivity(args) -> dict: + """Preserve a narrow, unapproved three-minute recast matrix for reporting.""" + center = _birth_datetime_from_args(args) + start = center - timedelta(minutes=1) + end = center + timedelta(minutes=1) + start_time = start.strftime('%H:%M') + end_time = end.strftime('%H:%M') + source_reference = { + 'review_id': f"rectification-review://report-window/{center.strftime('%Y%m%d-%H%M')}", + 'status': 'review_required', + } + approval_gate = _default_birth_time_approval_gate(source_reference) + candidate_segment_table = _load_1993_candidate_segment_table(args) + try: + from flexible_birth_time_profile import build_flexible_birth_time_profile_from_window + from flexible_birth_time_report_support import build_flexible_birth_time_report_support + from flexible_birth_time_full_report_projection import build_flexible_birth_time_full_report_projection + + profile = build_flexible_birth_time_profile_from_window( + birth_date=center.strftime('%Y-%m-%d'), + start_time=start_time, + end_time=end_time, + lat=float(args.lat), + lon=float(args.lon), + tz=float(args.tz), + ayanamsa=_current_ayanamsa_name(args), + node_mode=getattr(args, 'node_mode', 'mean') or 'mean', + source_reference=source_reference, + ) + support = build_flexible_birth_time_report_support( + profile, + approval_gate=approval_gate, + ) + projection = build_flexible_birth_time_full_report_projection(support) + return { + 'schema': 'jyotish.report_birth_time_sensitivity.v1', + 'status': 'candidate_window_only', + 'window': {'start': start_time, 'center': center.strftime('%H:%M'), 'end': end_time, 'candidate_count': 3}, + 'profile': profile, + 'approval_gate': approval_gate, + 'candidate_segment_table': candidate_segment_table, + 'candidate_micro_compare': { + 'schema_version': 'jyotish.rectification_candidate_micro_compare.v1', + 'status': 'blocked', + 'review_required': True, + 'candidate_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], + 'leader': None, + 'minute_results': [], + 'claim_boundary': ( + 'Minute comparison is packaged for the report but remains blocked until a user event replay is supplied.' + ), + }, + 'micro_compare_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], + 'report_projection': projection, + 'required_discrimination_layers': [ + 'D1', 'D3', 'D4', 'D6', 'D7', 'D9', 'D10', 'D11', 'D12', 'D16', 'D24', 'D30', + 'D40', 'D45', 'D60', 'UL', 'A7', 'A10', 'KP cusp', + ], + 'd12_parent_family_policy': ( + 'D12 is displayed as stable or minute-sensitive across all three candidates. ' + 'Only dated parent/family events can use a D12 difference for candidate discrimination; ' + 'neither D12 nor a single family event can approve a birth minute.' + ), + 'claim_boundary': ( + 'This is a local candidate comparison, not a rectification result. It cannot replace the ' + 'current chart, select a winning minute, or alter Chart Identity.' + ), + } + except Exception as exc: + return { + 'schema': 'jyotish.report_birth_time_sensitivity.v1', + 'status': 'blocked', + 'window': {'start': start_time, 'center': center.strftime('%H:%M'), 'end': end_time, 'candidate_count': 3}, + 'approval_gate': approval_gate, + 'candidate_segment_table': candidate_segment_table, + 'candidate_micro_compare': { + 'schema_version': 'jyotish.rectification_candidate_micro_compare.v1', + 'status': 'blocked', + 'review_required': True, + 'candidate_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], + 'leader': None, + 'minute_results': [], + 'claim_boundary': ( + 'Minute comparison is packaged for the report but remains blocked until a user event replay is supplied.' + ), + }, + 'micro_compare_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], + 'reason': f'birth_time_sensitivity_producer_failed:{exc}', + 'required_discrimination_layers': [ + 'D1', 'D3', 'D4', 'D6', 'D7', 'D9', 'D10', 'D11', 'D12', 'D16', 'D24', 'D30', + 'D40', 'D45', 'D60', 'UL', 'A7', 'A10', 'KP cusp', + ], + } + + +def _default_birth_time_approval_gate(source_reference: dict) -> dict: + status = str(source_reference.get('status') or 'review_required') + if status == 'review_required': + return { + 'status': 'blocked', + 'can_enter_pending_approval': False, + 'blocked_reasons': ['review_not_materialized'], + 'required_next_step': 'record_rectification_review', + 'claim_boundary': ( + 'Default report path has only candidate comparison evidence. It remains blocked ' + 'until a governed rectification review exists.' + ), + } + return { + 'status': 'blocked', + 'can_enter_pending_approval': False, + 'blocked_reasons': ['approval_gate_unavailable'], + 'required_next_step': 'record_rectification_review', + 'claim_boundary': ( + 'Default report path has no approval gate; keep the candidate window visible and ' + 'do not treat it as approved.' + ), + } + + +def _build_timing_boundary_attribution(packet: dict) -> dict: + """State which data produced a window without treating it as timing truth.""" + modules = ((packet.get('raw_full_reading') or {}).get('modules') or {}) + summary = (((packet.get('ai_and_audit') or {}).get('summary') or {}).get('executive_summary') or {}) + timing_contract = packet.get('timing_precision_contract') if isinstance(packet.get('timing_precision_contract'), dict) else {} + tiers = timing_contract.get('tiers') if isinstance(timing_contract.get('tiers'), dict) else {} + nodes = [] + for node in summary.get('key_time_nodes') if isinstance(summary.get('key_time_nodes'), list) else []: + if not isinstance(node, dict): + continue + raw_confidence = str(node.get('raw_time_confidence') or node.get('status') or 'parameter_sensitive') + tier = 'day' if raw_confidence in {'day_supported', 'single_system_inference', 'blocked'} else 'month' + tier_contract = tiers.get(tier) if isinstance(tiers.get(tier), dict) else {} + nodes.append({ + 'theme': node.get('theme'), + 'window': node.get('window'), + 'raw_sources': node.get('source_systems') or ['strict_workflow_monthly_adjudication_v1'], + 'raw_time_confidence': raw_confidence, + 'precision_tier': tier, + 'tier_status': tier_contract.get('status', 'blocked'), + 'allowed_claims': tier_contract.get('allowed_claims', []), + 'prohibited_claims': tier_contract.get('prohibited_claims', []), + 'boundary': ( + 'The listed date/window is a raw upstream observation. It cannot be promoted beyond this ' + 'tier until the required external parity and blind replay gates pass.' + ), + }) + try: + exact_cusp_status = json.loads((Path(__file__).resolve().parents[1] / 'references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json').read_text(encoding='utf-8')) + kp_status = exact_cusp_status.get('exact_cusp_oracle_status', 'blocked') + kp_reason = ', '.join(exact_cusp_status.get('promotion_gate', {}).get('remaining_hard_reasons', [])) + except Exception as exc: + kp_status, kp_reason = 'blocked', f'kp_exact_cusp_status_unavailable:{exc}' + parity_gates = [ + { + 'lane': 'Vimshottari boundary series', + 'status': 'parameter_sensitive', + 'source_path': 'raw_full_reading.modules.dasha', + 'reason': 'Local boundary series is present; broader formal external boundary closure remains incomplete.', + }, + { + 'lane': 'Narayana Dasha', + 'status': 'parameter_sensitive', + 'source_path': 'raw_full_reading.modules.narayana_dasha', + 'reason': 'Local sequence is present, but parity and interpretation closure remain incomplete.', + }, + { + 'lane': 'Patyayini / Tajika annual', + 'status': 'parameter_sensitive', + 'source_path': 'worksheets.timing_and_predictive_systems.annual_tajika_series', + 'reason': 'Independent annual packs exist, but annual timing remains limited by parity and replay gates.', + }, + { + 'lane': 'KP exact cusp', + 'status': kp_status, + 'source_path': 'references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json', + 'reason': kp_reason or 'Exact-cusp promotion gate remains closed.', + }, + ] + return { + 'schema': 'jyotish.timing_boundary_attribution.v1', + 'status': 'blocked_but_attributed', + 'nodes': nodes, + 'parity_gates': parity_gates, + 'available_local_tracks': [key for key in ('dasha', 'narayana_dasha', 'transit_multi_reference', 'tajika', 'kp') if modules.get(key)], + 'claim_boundary': 'Attribution names the upstream tracks and gates; it does not turn a raw window into verified event timing.', + } + + +def _build_module_execution_audit(packet: dict) -> list[dict]: + raw_modules = ((packet.get('raw_full_reading') or {}).get('modules') or {}) + usage_map = packet.get('raw_module_usage_map') if isinstance(packet.get('raw_module_usage_map'), dict) else {} + profile_id = packet.get('calculation_profile_id') or 'calculation_profile_id_missing' + input_requirements = { + 'prashna': 'question_datetime, question_text, question_location, timezone', + 'muhurta': 'activity_goal, candidate_datetime_or_range, location, timezone', + } + rows = [] + for module_id in sorted(raw_modules): + module = raw_modules[module_id] + if module in (None, '', [], {}): + continue + module_data = module if isinstance(module, dict) else {} + usage = usage_map.get(str(module_id)) if isinstance(usage_map.get(str(module_id)), dict) else {} + status = module_data.get('status') or module_data.get('execution_status') or 'available' + limitation = module_data.get('claim_boundary') or module_data.get('reason') or ( + 'Raw output is visible; this status alone does not promote it to a primary conclusion.' + ) + rows.append({ + 'module_id': str(module_id), + 'status': status, + 'producer': module_data.get('source') or module_data.get('method') or 'jyotish_engine.cmd_full_reading', + 'input_profile': profile_id, + 'external_reference_status': 'not_asserted_by_module', + 'report_sections': usage.get('report_sections') or ['原始模块索引与未下沉字段附录'], + 'conclusion_use': usage.get('usage_status') or 'raw_appendix_only', + 'limitation': limitation, + 'required_inputs_if_separate': input_requirements.get(str(module_id)), + }) + return rows + + +def _raw_module_usage_map(packet: dict) -> dict: + raw_modules = ((packet.get('raw_full_reading') or {}).get('modules') or {}) + section_map = { + 'dasha': '大运与时间主线', 'narayana_dasha': '大运与时间主线', + 'bhrigu_pada_dasha': '大运与时间主线', 'transit_multi_reference': '大运与时间主线', + 'double_transit_pac': '大运与时间主线', 'transit_ll7l': '大运与时间主线', + 'planetary_congregation': '大运与时间主线', 'tajika': '年度重点', + 'unified_western_reading': '大运与时间主线', + 'tajika_yogas': '年度重点', 'functional_benefic_malefic': '力量、Ashtakavarga 与功能性吉凶', + 'shadbala': '力量、Ashtakavarga 与功能性吉凶', 'ashtakavarga': '力量、Ashtakavarga 与功能性吉凶', + 'varga_full': 'D1–D60 完整原始分盘账本', 'varga_extended': 'D1–D60 完整原始分盘账本', + 'career_strict_evidence': '事业与外部发展', 'finance_strict_evidence': '财运与资源使用', + 'relationship_strict_evidence': '关系与合作模式', 'kp': 'KP 三年流月支持', + } + return { + str(module_id): { + 'source_path': f'raw_full_reading.modules.{module_id}', + 'report_sections': [section_map.get(str(module_id), '原始模块索引与未下沉字段附录')], + 'usage_status': 'thematic_or_audit_surface' if str(module_id) in section_map else 'raw_appendix_only', + } + for module_id, module in raw_modules.items() + if module not in (None, '', [], {}) + } + + +def _attach_report_governance_contracts(packet: dict, args) -> dict: + timing = ((packet.get('worksheets') or {}).get('timing_and_predictive_systems') or {}) + annual_series = timing.get('annual_tajika_series') if isinstance(timing.get('annual_tajika_series'), dict) else {} + try: + from timing_precision_contract import build_timing_precision_contract + except ImportError: # pragma: no cover + from scripts.timing_precision_contract import build_timing_precision_contract + packet['timing_precision_contract'] = build_timing_precision_contract({'annual_series': annual_series}) + packet['birth_provenance'] = _birth_provenance_from_args(args) + packet['event_replay'] = _event_replay_contract(args) + packet['rectification_evidence_contract'] = _load_rectification_evidence_contract() + packet['raw_module_usage_map'] = _raw_module_usage_map(packet) + packet['birth_time_sensitivity'] = _build_birth_time_sensitivity(args) + packet['timing_boundary_attribution'] = _build_timing_boundary_attribution(packet) + packet['module_execution_audit'] = _build_module_execution_audit(packet) + ai_pack = ((packet.get('raw_full_reading') or {}).get('ai_prompt_pack') or {}) + packet['technique_audit_table'] = ai_pack.get('evidence_snapshot', {}).get('technique_audit_table', []) + return packet + + +def _attach_base_charts_pack(packet: dict) -> dict: + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + birth = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else None + d1 = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + varga = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + if birth is None: + return packet + profile = packet.get('calculation_profile') or {} + pack = { + 'schema': 'pl9.base_charts_pack.v1', + 'profile': _json_safe_report_snapshot(profile), + 'sections': { + 'birth_parameters': { + 'status': 'partial_verified' if birth.get('birth_info') else 'blocked', + 'data': birth, + }, + 'd1_rasi': { + 'status': 'partial_verified' if d1.get('chart') or d1.get('planets') else 'blocked', + 'ascendant': d1.get('ascendant'), + 'planets': d1.get('planets'), + 'houses': d1.get('houses'), + 'chart': d1.get('chart'), + }, + 'moon_chart': { + 'status': 'partial_verified' if d1.get('moon_chart') else 'blocked', + 'data': d1.get('moon_chart'), + }, + 'bhava': { + 'status': 'partial_verified' if d1.get('bhava_chalit') or d1.get('houses') else 'blocked', + 'bhava_chalit': d1.get('bhava_chalit'), + 'houses': d1.get('houses'), + }, + 'varga': { + 'status': 'partial_verified' if varga.get('varga_full') or varga.get('varga_extended') else 'blocked', + 'varga_full': varga.get('varga_full'), + 'varga_extended': varga.get('varga_extended'), + 'special_lagnas': varga.get('special_lagnas'), + }, + 'upagraha': { + 'status': 'partial_verified' if varga.get('upagrahas') else 'blocked', + 'data': varga.get('upagrahas'), + }, + 'sudarshana': { + 'status': 'partial_verified' if varga.get('sudarshana') else 'blocked', + 'data': varga.get('sudarshana'), + }, + }, + 'audit': { + 'status': 'partial_verified', + 'pl9_pages': ['1-3', '6-10', '17', '125'], + 'blocked_policy': 'visual chart and PL9 numeric parity remain separate gates', + }, + 'exports': { + 'ai_evidence_bundle': { + 'schema': 'pl9.base_charts_pack.ai_evidence.v1', + 'profile_id': profile.get('profile_id'), + 'contains_private_pl9_text': False, + }, + }, + } + birth['base_charts_pack'] = _json_safe_report_snapshot(pack) + return packet + + +def _load_visual_chart_observations(args) -> dict | None: + path = getattr(args, 'visual_chart_observations', None) + if not path: + return None + artifact_path = Path(path).resolve() + try: + with artifact_path.open('r', encoding='utf-8') as handle: + payload = json.load(handle) + except Exception as exc: + return { + '__visual_chart_observations_error__': f'visual_chart_observations_load_failed:{exc}', + } + observations = payload.get('panel_observations') if isinstance(payload, dict) else None + if isinstance(observations, dict): + source_root = artifact_path + while source_root.name != 'artifacts' and source_root.parent != source_root: + source_root = source_root.parent + project_root = source_root.parent if source_root.name == 'artifacts' else artifact_path.parent + for panel_rows in observations.values(): + if not isinstance(panel_rows, list): + continue + for observation in panel_rows: + if not isinstance(observation, dict): + continue + source_crop = observation.get('source_crop') + if source_crop and not Path(str(source_crop)).is_absolute(): + observation['source_crop'] = str(project_root / str(source_crop)) + return observations + return { + '__visual_chart_observations_error__': 'visual_chart_observations_missing_panel_observations', + } + + +def _attach_visual_chart_pack(packet: dict, args=None) -> dict: + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else None + if divisional is None: + return packet + try: + from visual_chart_pack import build_visual_chart_pack + + observations = _load_visual_chart_observations(args) if args is not None else None + if isinstance(observations, dict) and '__visual_chart_observations_error__' in observations: + raise ValueError(observations['__visual_chart_observations_error__']) + divisional['visual_chart_pack'] = _json_safe_report_snapshot( + build_visual_chart_pack( + packet.get('calculation_profile') or {}, + panel_observations=observations, + ) + ) + except Exception as exc: + divisional['visual_chart_pack'] = { + 'schema': 'pl9.visual_chart_pack.v1', + 'status': 'blocked', + 'reason': str(exc), + } + return packet + + +def _attach_personal_report_producer(packet: dict) -> dict: + packet['personal_report_producer'] = _build_personal_report_producer(packet) + return packet + + +def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict: + try: + from professional_parity_closure import build_dasha_master_pack + + profile = { + **(packet.get('calculation_profile') or {}), + 'pl9_profile_status': 'parameter_sensitive', + } + chart_planets = modules.get('chart', {}).get('planets', {}) + functional_layer = modules.get('functional_benefic_malefic', {}) + owned_houses = functional_layer.get('owned_houses', {}) if isinstance(functional_layer, dict) else {} + functional_classification = {} + for label, members in ( + ('functional_benefic', functional_layer.get('functional_benefics', [])), + ('functional_malefic', functional_layer.get('functional_malefics', [])), + ('functional_neutral', functional_layer.get('functional_neutrals', [])), + ): + for planet in members: + functional_classification[planet] = label + lord_facts = { + planet: { + 'house': data.get('house'), + 'ownership': owned_houses.get(planet), + 'dignity': data.get('status'), + 'nakshatra': data.get('nakshatra'), + 'pada': data.get('nakshatra_pada'), + } + for planet, data in chart_planets.items() + if isinstance(data, dict) + } + return build_dasha_master_pack( + modules, + profile, + { + 'vimshottari': { + 'status': 'blocked', + 'reason': 'Formal external Dasha oracle contract remains incomplete.', + }, + }, + lord_facts, + functional_classification, + ) + except Exception as exc: + return {'schema': 'dasha_master_report_pack_v1', 'status': 'blocked', 'reason': str(exc)} + + +def _attach_profile_id_to_dasha_section(dasha: dict, profile: dict) -> dict: + if not isinstance(dasha, dict) or not isinstance(profile, dict): + return dasha + profiles = dasha.setdefault('profiles', {}) + if not isinstance(profiles, dict): + dasha['profiles'] = profiles = {} + profile_section = profiles.setdefault('profile', {}) + if isinstance(profile_section, dict): + profile_section.setdefault('profile_id', profile.get('profile_id')) + return dasha + + +def _build_pl9_full_annual_section(args, profile: dict | None = None) -> dict: + target_year = getattr(args, 'target_year', None) + if target_year is None and getattr(args, 'today', None): + try: + target_year = datetime.strptime(args.today, "%Y-%m-%d").year + setattr(args, 'target_year', target_year) + except Exception: + target_year = None + if target_year is None: + return {'schema': 'jyotish.annual_tajika_pack.v1', 'status': 'blocked', 'reason': 'target_year_missing'} + try: + from annual_tajika_pack import build_annual_tajika_pack + from annual_pyjhora_replay import replay_pyjhora_annual + + payload = _annual_payload_from_args(args, profile) + annual_profile = build_calculation_profile(payload) + birth_hour = float(args.hour) + float(args.minute) / 60 + float(_arg_second(args)) / 3600 + birth_jd = swe.julday(int(args.year), int(args.month), int(args.day), birth_hour - float(args.tz)) + age = getattr(args, 'age', None) + if age is None: + age = int(target_year) - int(args.year) + location = annual_profile.get('location') or {} + place = SimpleNamespace( + name=location.get('place') or 'birth_coordinates', + latitude=float(location.get('latitude', args.lat)), + longitude=float(location.get('longitude', args.lon)), + timezone=float(args.tz), + ) + replay = replay_pyjhora_annual( + annual_profile, + birth_julian_day=birth_jd, + place=place, + age=age, + ) + return build_annual_tajika_pack(payload, pyjhora_replay=replay) + except Exception as exc: + return {'schema': 'jyotish.annual_tajika_pack.v1', 'status': 'blocked', 'reason': str(exc)} + + +def _annual_payload_from_args(args, profile: dict | None = None) -> dict: + profile = profile or {} + timezone = profile.get('timezone') or {} + location = profile.get('location') or {} + return { + 'birth': { + 'date': f"{int(args.year):04d}-{int(args.month):02d}-{int(args.day):02d}", + 'time': f"{int(args.hour):02d}:{int(args.minute):02d}:{int(_arg_second(args)):02d}", + 'timezone': timezone.get('name'), + 'utc_offset': timezone.get('utc_offset') or _pl9_utc_offset_from_tz(getattr(args, 'tz', 0.0)), + 'place': location.get('place'), + 'latitude': location.get('latitude', args.lat), + 'longitude': location.get('longitude', args.lon), + 'coordinate_precision': profile.get('coordinate_precision', 'coordinates'), + }, + 'settings': { + 'ayanamsa': profile.get('ayanamsa', getattr(args, 'ayanamsa', 'raman')), + 'node_mode': profile.get('node_mode', getattr(args, 'node_mode', 'mean')), + 'house_system': profile.get('house_system', getattr(args, 'house_system', 'whole_sign')), + 'position_mode': profile.get('position_mode', getattr(args, 'position_mode', 'legacy')), + 'dasha_year_days': profile.get('dasha_year_days', getattr(args, 'dasha_year_days', 365.25)), + 'solar_return_location_mode': profile.get('solar_return_location_mode', getattr(args, 'solar_return_location_mode', 'birth_place')), + 'annual_year_policy': profile.get('annual_year_policy', getattr(args, 'annual_year_policy', 'solar_return_exact')), + }, + 'target_year': int(args.target_year), + 'age': getattr(args, 'age', None), + } + + +def _pl9_utc_offset_from_tz(tz_value) -> str: + hours_float = float(tz_value or 0.0) + sign = '+' if hours_float >= 0 else '-' + total_minutes = int(round(abs(hours_float) * 60)) + return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}" + + +def _is_domi_pl9_control_case(packet: dict) -> bool: + return False + +def _domi_pl9_page_gap_statuses() -> list[dict]: + return [] + +def _build_domi_pl9_current_gap_summary(dasha: dict, annual: dict) -> dict: + return { + "status": "blocked", + "reason": "control_case_appendix_not_imported", + } + +def _build_pl9_full_audit_appendix(packet: dict, dasha: dict, annual: dict) -> dict: + statuses = [ + 'partial_verified' if packet.get('calculation_profile_id') else 'blocked', + dasha.get('audit', {}).get('status') or dasha.get('status') or 'blocked', + annual.get('audit', {}).get('status') or annual.get('status') or 'blocked', + ] + appendix = { + 'status': 'partial_verified' if any(status == 'partial_verified' for status in statuses) else 'blocked', + 'section_statuses': { + 'profile': statuses[0], + 'dasha': statuses[1], + 'annual': statuses[2], + }, + 'blocked_policy': 'preserve blocked/conflict/parameter_sensitive fields in appendix', + } + if _is_domi_pl9_control_case(packet): + appendix['domi_pl9_current_gap_summary'] = _build_domi_pl9_current_gap_summary(dasha, annual) + appendix['pl9_page_gap_statuses'] = _domi_pl9_page_gap_statuses() + return appendix + + +def _load_ch10_ashtama_shani_anchor_correction() -> dict: + path = Path(ROOT_DIR) / 'references' / 'oracle' / 'ch10_unverified_anchor_audit_2026_08_31.json' + try: + audit = json.loads(path.read_text(encoding='utf-8')) + except Exception as exc: + return { + 'status': 'blocked', + 'source_path': str(path), + 'reason': f'ch10_anchor_audit_unavailable: {exc}', + } + ashtama = audit.get('ashtama_shani') if isinstance(audit.get('ashtama_shani'), dict) else {} + if not ashtama: + return { + 'status': 'blocked', + 'source_path': str(path), + 'reason': 'ashtama_shani_section_missing', + } + return { + 'status': 'corrective_audit_ready', + 'source_path': str(path), + 'definition': ashtama.get('definition'), + 'diagnosis': ashtama.get('diagnosis'), + 'report_claim_verdicts': ashtama.get('verdicts') or [], + 'dynamic_raman_periods': ashtama.get('dynamic_raman_periods') or [], + 'locked_22_3171_periods': ashtama.get('locked_22_3171_periods') or [], + 'lahiri_residual_periods': ashtama.get('lahiri_periods') or [], + 'same_event_as_sade_sati_pisces_exit': ashtama.get('same_event_as_sade_sati_pisces_exit'), + 'sade_sati_end_should_equal_ashtama_primary_end': ashtama.get('sade_sati_end_should_equal_ashtama_primary_end'), + 'report_policy': ( + 'Professional reports using the Raman/locked profile must not reuse the ' + 'Lahiri-aligned Ashtama Shani dates as active claims.' + ), + 'runtime_promotable': False, + } + + +def render_pl9_continuation_prompt() -> str: + """Return the user-copyable follow-up contract for a portable PL9 report.""" + lines = [ + '## 深度续读提示词', + '', + '> 注意:将本报告上传至外部 AI 会共享你的出生资料与报告中的解读内容,请仅在你信任的服务中使用。', + '', + '请上传或粘贴整份报告,然后在下方补充问题:', + '', + '【请在这里写下你的具体问题】', + '', + '请只依据本报告已提供的内容回答,并逐项保留证据库存状态:已执行 / blocked / not_applicable / 缺失。', + '不要把本机路径、内部文件名或未提供的资料当作可读取证据。', + '不得补造报告中不存在的度数、分盘、大运、行运、第三方事实或外部验证结果。', + '', + '按问题领域路由:事业使用 D10 + A10;财富使用 D2 / D11;婚恋使用 D9 + UL;应期至少交叉 Vimshottari + Narayana Dasha。比较、合盘、第三方事实确认或对具体第三方的断言,必须以对方已提供资料为边界;缺少资料时仅说明限制。', + '每项结论标注其证据等级:multi_system_consensus、single_system_inference、parameter_sensitive、unclosed_divisional_chart、user_history_verification_required 或 blocked。', + 'MEVG / Global Web Evidence 与 Real Case Calibration 必须分别说明状态;无法联网或未取得可核验外部资料时,两项均标为 blocked。', + '固定回答顺序:问题重述;证据盘点;专题分析;候选时间窗表(窗口、依据、强度、触发条件);冲突与限制;需要现实核验的问题;可执行建议;Technique Audit Table。', + ] + return '\n'.join(lines) + + +def _resolve_current_recursive_periods_from_family( + family: dict | None, + now_iso: str | None = None, +) -> dict: + """Extract current major/AD/PD rows from a normalized Dasha family packet.""" + if not isinstance(family, dict): + return {"major": None, "antardasha": None, "pratyantardasha": None} + current = family.get('current') if isinstance(family.get('current'), dict) else None + if not current: + return {"major": None, "antardasha": None, "pratyantardasha": None} + current_start = current.get('start_date') or current.get('start') + if not current_start: + return {"major": current, "antardasha": None, "pratyantardasha": None} + if now_iso is None: + now_iso = datetime.now().isoformat() + + for period in family.get('periods') or []: + if not isinstance(period, dict): + continue + raw = period.get('raw_period') if isinstance(period.get('raw_period'), dict) else {} + if raw.get('start_date') != current_start and raw.get('start') != current_start: + continue + antardashas = raw.get('antardasha') if isinstance(raw.get('antardasha'), list) else [] + current_antardasha = None + current_pratyantardasha = None + for child in antardashas: + if not isinstance(child, dict): + continue + child_start = child.get('start_date') or child.get('start') + child_end = child.get('end_date') or child.get('end') + if child_start and child_end and child_start <= now_iso < child_end: + current_antardasha = child + pratyantardashas = child.get('pratyantardasha') if isinstance(child.get('pratyantardasha'), list) else [] + for grandchild in pratyantardashas: + if not isinstance(grandchild, dict): + continue + grandchild_start = grandchild.get('start_date') or grandchild.get('start') + grandchild_end = grandchild.get('end_date') or grandchild.get('end') + if grandchild_start and grandchild_end and grandchild_start <= now_iso < grandchild_end: + current_pratyantardasha = grandchild + break + break + return { + "major": current, + "antardasha": current_antardasha, + "pratyantardasha": current_pratyantardasha, + } + return {"major": current, "antardasha": None, "pratyantardasha": None} + + +def _render_shared_report_identity_header(packet: dict) -> list[str]: + """Render the portable, non-sensitive identity summary for a full report.""" + authority = packet.get('shared_full_report_authority') if isinstance(packet, dict) else {} + if not isinstance(authority, dict) or not authority: + return [] + + report_id = str(authority.get('report_id') or '').strip() + report_version = str(authority.get('report_version') or '').strip() + lineage = authority.get('lineage') if isinstance(authority.get('lineage'), dict) else {} + result_hash = str(lineage.get('result_hash') or '').strip() + quality_gate = authority.get('quality_gate_reference') if isinstance(authority.get('quality_gate_reference'), dict) else {} + chart_identity = authority.get('chart_identity') if isinstance(authority.get('chart_identity'), dict) else {} + if not report_id or not report_version or not result_hash or not quality_gate: + return [] + + hash_summary = f"{result_hash[:16]}..." if len(result_hash) > 16 else result_hash + identity_status = ' / '.join( + [ + f"birth={chart_identity.get('birth_data_status') or 'unknown'}", + f"rectification={chart_identity.get('rectification_status') or 'unknown'}", + f"approval={chart_identity.get('approval_status') or 'unknown'}", + ] + ) + return [ + '## 报告身份与来源', + '', + f'- 报告 ID: `{report_id}`', + f'- 报告版本: `{report_version}`', + f'- 生成时间(UTC): `{authority.get("report_metadata", {}).get("generated_at") or "未记录"}`', + f'- 计算结果摘要: `{hash_summary}`', + f'- 质量检查: `{quality_gate.get("status") or "review_required"}`', + f'- Chart Identity: `{identity_status}`', + f'- 发布状态: `{authority.get("publication_status") or "pending_review"}`(当前仅为可复核报告,不自动进入公开发布。)', + '', + ] + + +def render_pl9_markdown(packet: dict) -> str: + """Render the full PL9 Markdown report.""" + birth = packet.get('birth_info', {}) if isinstance(packet, dict) else {} + core_chart = packet.get('core_chart', {}) if isinstance(packet, dict) else {} + report_layers = packet.get('report_layers', {}) if isinstance(packet, dict) else {} + report_catalog = packet.get('report_catalog', {}) if isinstance(packet, dict) else {} + report_index = packet.get('report_index', {}) if isinstance(packet, dict) else {} + worksheets = packet.get('worksheets', {}) if isinstance(packet, dict) else {} + raw_full_reading = packet.get('raw_full_reading', {}) if isinstance(packet, dict) else {} + parity = packet.get('reference_parity', {}) if isinstance(packet, dict) else {} + producer = packet.get('personal_report_producer', {}) if isinstance(packet, dict) else {} + full_report_pack = packet.get('full_report_pack', {}) if isinstance(packet, dict) else {} + ashtottari_reference_packet = ( + parity.get('ashtottari_pl9_boundary_packet') + if isinstance(parity.get('ashtottari_pl9_boundary_packet'), dict) + else {} + ) + dasha_boundary_packets = parity.get('dasha_boundary_packets') if isinstance(parity.get('dasha_boundary_packets'), dict) else {} + narayana_reference_packet = ( + dasha_boundary_packets.get('narayana') + if isinstance(dasha_boundary_packets.get('narayana'), dict) + else {} + ) + kalachakra_reference_packet = ( + dasha_boundary_packets.get('kala_chakra') + if isinstance(dasha_boundary_packets.get('kala_chakra'), dict) + else {} + ) + + def _unwrap(raw_value): + # 兼容 packet 字段的 response_envelope 升级: + # 真实数据可能直接是 list/dict,也可能被包成 {"result": ..., "response_envelope": {...}}。 + if ( + isinstance(raw_value, dict) + and 'result' in raw_value + and 'response_envelope' in raw_value + ): + return raw_value.get('result') + return raw_value + + def _envelope_list(raw_value): + raw_value = _unwrap(raw_value) + if isinstance(raw_value, list): + return raw_value + if isinstance(raw_value, tuple): + return list(raw_value) + # 期望 list 时,即使只有 result 键也解包,避免 nested planets 被空渲染成 '-'。 + if isinstance(raw_value, dict) and 'result' in raw_value: + nested = raw_value.get('result') + if isinstance(nested, list): + return nested + if isinstance(nested, tuple): + return list(nested) + return [] + + def _envelope_dict(raw_value): + raw_value = _unwrap(raw_value) + return raw_value if isinstance(raw_value, dict) else {} + + def _bool_text(value): + return '是' if value else '否' + + planet_label_cn = { + 'Sun': '太阳', + 'Moon': '月亮', + 'Mars': '火星', + 'Mercury': '水星', + 'Jupiter': '木星', + 'Venus': '金星', + 'Saturn': '土星', + 'Rahu': '北交点', + 'Ketu': '南交点', + } + + def _humanize_reader_token(token: str) -> str: + value = str(token or '').strip() + if not value: + return value + if value in SIGNS_CN: + return SIGNS_CN[value] + if value in planet_label_cn: + return planet_label_cn[value] + return value + + def _humanize_reader_group(raw_group: str) -> str: + seen = set() + parts = [] + for item in str(raw_group or '').split(','): + humanized = _humanize_reader_token(item) + if humanized and humanized not in seen: + seen.add(humanized) + parts.append(humanized) + return ' / '.join(parts) + + def _humanize_tajika_candidate(name: str) -> str: + return { + 'Ithasala_candidate': '趋近相位候选', + 'Easarapha_candidate': '分离相位候选', + }.get(str(name or ''), '年度相位候选') + + def _humanize_tajika_motion(motion: str) -> str: + return {'applying': '趋近', 'separating': '分离'}.get(str(motion or ''), str(motion or '-')) + + def _display_rashi_from_value(raw_value): + if isinstance(raw_value, int) and 0 <= raw_value < len(SIGNS): + return _humanize_reader_token(SIGNS[raw_value]) + return _humanize_reader_token(raw_value) + + def _render_boundary_packet_summary_line(packet: dict, summary_pairs: list[tuple[str, str]]) -> str: + summary = packet.get('summary') if isinstance(packet.get('summary'), dict) else {} + bits = [_md_cell(packet.get('packet_mode'))] + for key, suffix in summary_pairs: + value = summary.get(key) + if value not in (None, '', []): + bits.append(f"{_md_cell(value)}{suffix}") + return f"参考包摘要:{';'.join(bits)}。" + + def _humanize_reader_summary_line(summary_line: str) -> str: + summary_line = _unwrap(summary_line) + if isinstance(summary_line, dict): + summary_line = ( + summary_line.get('brief') + or summary_line.get('text') + or summary_line.get('summary') + or '' + ) + text = str(summary_line or '').strip() + if not text: + return text + if text == "年度层已经有可读壳层,但仍需要带着冲突标签阅读。": + return "这一年的年度主线已经形成。" + if text == "This pack converts Dasha trigger facts into report-ready interpretation seeds without final event claims.": + return "这层已经把 Dasha 触发事实压成可读提示,但仍不直接升级为具体事件断语。" + if text == "Source: dasha_master_pack.interpretation_triggers.": + return "当前解释仍然直接来自 dasha_master_pack.interpretation_triggers 的证据层。" + if text == "All claims remain evidence-labeled and parameter-sensitive where the source pack is unclosed.": + return "只要 source pack 还没闭环,相关结论就继续保持 evidence-labeled 与 parameter_sensitive。" + if text == "Tajika Yogas: annual candidate structures visible": + return "Tajika Yogas 当前已经能读到年度候选交互结构。" + if text == "Sahams: annual sensitive points visible": + return "Sahams 当前已经能读到年度敏感点。" + match = re.match(r'^([^:]+):\s*conflict between\s+(.+?)\s+vs\s+(.+)$', text) + if match: + topic = match.group(1).strip() + left = _humanize_reader_group(match.group(2)) + right = _humanize_reader_group(match.group(3)) + topic_label = { + 'Muntha': 'Muntha', + 'Year Lord': 'Year Lord', + 'Annual Asc': '返照上升', + }.get(topic, topic) + if left == right: + return f"{topic_label} 当前结果指向{left}。" + return f"{topic_label} 当前存在候选分歧:一组结果指向{left},另一组结果指向{right}。" + simple_match = re.match(r'^([^:]+):\s*(.+)$', text) + if simple_match: + topic = simple_match.group(1).strip() + value = _humanize_reader_group(simple_match.group(2)) + if topic == 'Year Lord' and value: + return f"Year Lord 当前优先候选指向{value}。" + if topic == 'Muntha' and value: + return f"Muntha 当前优先候选指向{value}。" + if topic == 'Tajika Yogas' and 'annual candidate structures visible' in str(simple_match.group(2)).lower(): + return "Tajika Yogas 当前已经能读到年度候选交互结构。" + if topic == 'Sahams' and 'annual sensitive points visible' in str(simple_match.group(2)).lower(): + return "Sahams 当前已经能读到年度敏感点。" + return text + + def _humanize_annual_highlight_line(highlight: str) -> str: + text = str(highlight or '').strip() + if not text: + return text + if text == "Solar Return / Tajika annual shell is available.": + return "本年度返照与 Tajika 时间结构已经生成。" + if text == "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.": + return "Muntha 和 Year Lord 现在都已经可见,但 Year Lord 仍要带着分歧标签阅读。" + if text == "The report should read annual structure first, then the evidence appendix.": + return "这一层适合从年度结构展开,再回头核对证据附录。" + return text + + def _humanize_annual_narrative_highlight(highlight: str) -> str: + return _humanize_annual_highlight_line(str(highlight)).rstrip('。') + + def _humanize_domain_title(title: str) -> str: + text = str(title or '').strip() + if not text: + return text + mapping = { + "Career and public direction": "事业与公众方向", + "Wealth and growth opportunities": "财富与增长机会", + "Relationship and alliance themes": "关系与合作主题", + "Health and vitality caution": "健康与精力提醒", + "Risk, delay, and friction markers": "阻力、延迟与摩擦提示", + "Timing windows and cross-check needs": "时间窗口与交叉核对重点", + } + return mapping.get(text, text) + + def _degree_text(value): + if value in (None, '', [], {}): + return '-' + try: + return f"{float(value):.4f}°" + except (TypeError, ValueError): + return str(value) + + def _source_dict(*candidates): + for candidate in candidates: + if isinstance(candidate, dict) and candidate: + return candidate + return {} + + def _render_basic_tables() -> list[str]: + birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {} + d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + birth_info = _source_dict(birth_sheet.get('birth_info'), birth) + ascendant = _source_dict(d1_sheet.get('ascendant'), core_chart.get('ascendant')) + planets = _source_dict(d1_sheet.get('planets'), core_chart.get('planets')) + houses = _source_dict(d1_sheet.get('houses'), core_chart.get('houses')) + meta = birth_sheet.get('meta') if isinstance(birth_sheet.get('meta'), dict) else {} + detail_blocks = birth_sheet.get('detail_blocks') if isinstance(birth_sheet.get('detail_blocks'), dict) else {} + calculation = detail_blocks.get('calculation_profile') if isinstance(detail_blocks.get('calculation_profile'), dict) else {} + + table_lines = [ + '', + '### 基础资料表', + '', + '#### Birth Particulars / Calculation Details', + '', + '| Field | Value |', + '|-------|-------|', + ] + birth_rows = [ + ('Date', birth_info.get('date')), + ('Time', birth_info.get('time')), + ('Latitude', birth_info.get('lat')), + ('Longitude', birth_info.get('lon')), + ('Time Zone', birth_info.get('tz')), + ('Ayanamsa', birth_info.get('ayanamsa_display') or birth_info.get('ayanamsa_name')), + ('Ayanamsa Value', _degree_text(birth_info.get('ayanamsa')) if birth_info.get('ayanamsa') is not None else None), + ('Node Mode', birth_info.get('node_mode')), + ('House System', calculation.get('house_system') or birth_info.get('house_system')), + ('Ascendant', ' '.join(part for part in [ascendant.get('sign_cn') or SIGNS_CN.get(ascendant.get('sign'), ascendant.get('sign', '')), _degree_text(ascendant.get('degree_in_sign') if ascendant.get('degree_in_sign') is not None else ascendant.get('degree'))] if part and part != '-')), + ('Ascendant Lord', _humanize_reader_token(ascendant.get('lord')) if ascendant.get('lord') else None), + ] + for field, value in birth_rows: + if value not in (None, '', [], {}): + table_lines.append(f"| {field} | {value} |") + + table_lines.extend([ + '', + '#### Planet Positions', + '', + '| Planet | Sign | Degree | House | Nakshatra | Pada | NL | Status | R |', + '|--------|------|--------|-------|-----------|------|----|--------|---|', + ]) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']: + row = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + if not row: + continue + table_lines.append( + '| ' + + ' | '.join([ + _humanize_reader_token(planet), + row.get('sign_cn') or SIGNS_CN.get(row.get('sign'), row.get('sign', '-')), + _degree_text(row.get('degree_in_sign') if row.get('degree_in_sign') is not None else row.get('degree')), + str(row.get('house', '-')), + str(row.get('nakshatra', '-')), + str(row.get('nakshatra_pada', '-')), + _humanize_reader_token(row.get('nakshatra_lord')) if row.get('nakshatra_lord') else '-', + str(row.get('status', '-')), + _bool_text(bool(row.get('retrograde'))), + ]) + + ' |' + ) + + bhava_chalit = d1_sheet.get('bhava_chalit') if isinstance(d1_sheet.get('bhava_chalit'), dict) else {} + bhava_raw = bhava_chalit.get('raw') if isinstance(bhava_chalit.get('raw'), dict) else {} + boundaries = bhava_raw.get('boundaries') if isinstance(bhava_raw.get('boundaries'), dict) else {} + boundary_rows = boundaries.get('houses') if isinstance(boundaries.get('houses'), list) else [] + if not boundary_rows: + table_lines.extend([ + '', + '#### Bhava Spashta / House Cusps', + '', + '| Bhava | Cusp Sign | Cusp Degree | Lord |', + '|-------|-----------|-------------|------|', + ]) + for idx in range(1, 13): + row = houses.get(f'house_{idx}') or houses.get(str(idx)) or houses.get(idx) or {} + if not isinstance(row, dict): + row = {} + table_lines.append( + f"| {idx} | {row.get('cusp_sign_cn') or SIGNS_CN.get(row.get('cusp_sign'), row.get('sign_cn') or SIGNS_CN.get(row.get('sign'), '-'))} | " + f"{_degree_text(row.get('cusp_degree') if row.get('cusp_degree') is not None else row.get('degree'))} | " + f"{_humanize_reader_token(row.get('lord')) if row.get('lord') else '-'} |" + ) + return table_lines + + def _reader_engine_boundary_notice_markdown() -> list[str]: + notice = ( + packet.get('reader_engine_boundary_notice') + or full_report_pack.get('reader_engine_boundary_notice') + or parity.get('reader_engine_boundary_notice') + ) + if not isinstance(notice, dict): + return [] + primary_zh = notice.get('primary_text_zh') or notice.get('text_zh') or notice.get('summary_zh') or '' + primary_en = notice.get('primary_text_en') or notice.get('text_en') or '' + display_rule = notice.get('display_rule') or '' + if not any([primary_zh, primary_en, display_rule]): + return [] + lines_out = ['', '## 多引擎口径说明', ''] + if primary_zh: + lines_out.append(str(primary_zh)) + lines_out.append('') + if primary_en: + lines_out.append(str(primary_en)) + lines_out.append('') + if display_rule: + lines_out.append(f"显示规则:{display_rule}") + lines_out.append('') + return lines_out + + lines = ['# 个人印度占星报告', ''] + lines.extend(_render_shared_report_identity_header(packet)) + lines.extend(_reader_engine_boundary_notice_markdown()) + + if isinstance(producer, dict) and producer: + main_body = producer.get('main_body') if isinstance(producer.get('main_body'), dict) else {} + rich_support = producer.get('rich_support') if isinstance(producer.get('rich_support'), dict) else {} + conflict_labeled = producer.get('conflict_labeled') if isinstance(producer.get('conflict_labeled'), dict) else {} + blocked_audit = producer.get('blocked_audit') if isinstance(producer.get('blocked_audit'), dict) else {} + conflict_details = conflict_labeled.get('segment_details') if isinstance(conflict_labeled.get('segment_details'), dict) else {} + blocked_details = blocked_audit.get('segment_details') if isinstance(blocked_audit.get('segment_details'), dict) else {} + support_topics = producer.get('support_topics') if isinstance(producer.get('support_topics'), dict) else {} + source_pack_contracts = producer.get('source_pack_contracts') if isinstance(producer.get('source_pack_contracts'), dict) else {} + modules = packet.get('modules') if isinstance(packet.get('modules'), dict) else {} + annual_topic = (producer.get('main_body_topics') or {}).get('annual') if isinstance(producer.get('main_body_topics'), dict) else {} + timing_sheet = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {} + advanced_sheet = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {} + annual = timing_sheet.get('annual_tajika_pack') if isinstance(timing_sheet.get('annual_tajika_pack'), dict) else {} + kp_sheet = advanced_sheet.get('kp') if isinstance(advanced_sheet.get('kp'), dict) else {} + has_kp_renderable_rows = bool( + isinstance(kp_sheet.get('planets'), dict) and kp_sheet.get('planets') + or isinstance(kp_sheet.get('houses'), dict) and kp_sheet.get('houses') + or isinstance(kp_sheet.get('ruling_planets'), dict) and kp_sheet.get('ruling_planets') + ) + include_pl9_evidence_tables = bool( + full_report_pack + or report_catalog + or report_layers + or packet.get('include_pl9_evidence_tables') + or has_kp_renderable_rows + ) + annual_profile = annual.get('profile') if isinstance(annual.get('profile'), dict) else {} + annual_target_year = annual_profile.get('target_year') or annual.get('target_year') or packet.get('target_year') + annual_heading = f"### {annual_target_year} 年度重点" if annual_target_year not in (None, '') else '### 年度重点' + dasha_interpretation_pack = timing_sheet.get('dasha_interpretation_pack') if isinstance(timing_sheet.get('dasha_interpretation_pack'), dict) else {} + jaimini = advanced_sheet.get('jaimini') if isinstance(advanced_sheet.get('jaimini'), dict) else {} + + segment_labels = { + 'birth_profile_1_3': '出生参数与计算口径已经稳定,是整份报告可信的基础底稿。', + 'd1_structure_1_10': '本命盘主结构已经就位,可以直接用来理解性格、人生主轴和基础趋势。', + 'vimshottari_core_62_74': 'Vimshottari 主大运链已经可读,可以直接拿来梳理时间主线。', + 'annual_shell_61_125_135': '流年主壳层已经可用,可以先用来理解这一年的主题主线。', + 'annual_asc_125_126': '返照上升可直接作为年度关注焦点使用。', + 'muntha_125_126': 'Muntha 可以直接作为年度推进方向参考。', + 'jaimini_core_89_91': 'Jaimini 核心指标已稳定,可作为正文里的补充主轴。', + 'divisional_overview_6_16': '分盘总览信息量高,适合放到主结论之后继续深读。', + 'varga_full_6_16': '正式分盘全表更适合在主结论后展开细看。', + 'special_lagnas_11_16': '特殊上升点可用于细化主题判断,放在补充阅读层更顺。', + 'upagrahas_17': '虚点与副点更适合放在补充层,避免过早打断主线阅读。', + 'strength_ashtakavarga_31_60': '力量、业力分与技术表适合附在正文之后,帮助你做第二轮深读。', + 'natal_sahams_support_126_155': 'Saham 相关信息适合保留为补充专题,放在正文之后再看更清楚。', + 'jaimini_special_points_89_91': 'Jaimini 特殊点位更适合在核心结论后继续展开。', + 'dasha_interpretation_190_204': '大运解释层已经可读,适合做主题扩展,同时保留证据标签会更稳妥。', + 'kp_123_124': 'KP 目前保留为专项补充阅读,不直接当作已经闭环的正文依据。这里的 A/B/C/D 不是吉凶评分,也不是“越靠前越一定发生”;它更像是结构记录,用来观察哪些宫位主题被反复碰到,再和本命、分盘、大运一起交叉核对。', + 'kalachakra_83_88': 'Kala Chakra 当前与参考口径未闭环,建议先作为条件性信息阅读。', + 'annual_year_lord_125_126': '年主星目前保留为条件性信息,适合带着分歧标签一起阅读。', + 'ashtottari_75_79': 'Ashtottari 当前仍未达到稳定判断条件,先作为核对信息保留。', + 'tajika_condition_status_136_138': 'Tajika 条件页仍作为核对信息保留,暂不用于直接断语。', + 'kp_boundary_123_124': 'KP 边界尚未闭环,因此只保留核对记录,不直接作为判断依据。', + } + + blocked_reason_labels = { + 'The native producer returned no major Dasha periods.': '本地引擎目前没有返回可用的大运主序列。', + 'The native producer reports this Dasha as inapplicable.': '本地引擎当前把这套大运标记为不适用,因此仍保留在核对层。', + 'Native result normalized; external profile parity remains unclosed.': '本地结果已标准化,但外部 profile 对照仍未闭环。', + 'interpretive annual narrative waits for conflict gate and external replay': '年度解释层仍等待冲突门和外部回放闭环。', + 'kp_event_timing_oracle_parity_not_available': 'KP 的 249 SubLord 分段结构已可按固定表核对;但事件应期、真实样本回放与 holdout 口径仍未闭环。', + } + + def _segment_sentence(segment_id: str) -> str: + detail = conflict_details.get(segment_id) or blocked_details.get(segment_id) + if segment_id == 'ashtottari_75_79' and isinstance(detail, dict) and detail.get('execution_status') == 'executed': + current = detail.get('current_period') if isinstance(detail.get('current_period'), dict) else {} + if current.get('lord') or current.get('years'): + return ( + f"Ashtottari 已经可以执行:当前周期落在{_humanize_reader_token(current.get('lord')) or '当前守护星未命名'}," + f"年限{current.get('years') or '未命名'},但仍保留候选规则标签,适合带着分歧阅读。" + ) + return 'Ashtottari 已经可以执行,但仍保留候选规则标签,适合带着分歧阅读。' + if segment_id == 'kalachakra_83_88' and isinstance(detail, dict): + current = detail.get('current_period') if isinstance(detail.get('current_period'), dict) else {} + if current.get('lord') or current.get('rashi'): + return ( + f"Kala Chakra 已进入可读状态:当前周期落在{_humanize_reader_token(current.get('lord')) or '当前守护星未命名'}," + f"对应{_humanize_reader_token(current.get('rashi')) or '当前星座未命名'},但仍带着算法冲突标签阅读。" + ) + return 'Kala Chakra 已进入可读状态,但仍带着算法冲突标签阅读。' + if segment_id == 'annual_year_lord_125_126' and isinstance(detail, dict): + values = detail.get('values') if isinstance(detail.get('values'), list) else [] + if values: + first = values[0].get('value') if isinstance(values[0], dict) else {} + if isinstance(first, dict): + lord = first.get('year_lord') or first.get('year_lord_sign') + sign = first.get('year_lord_sign') + if lord or sign: + bits = [] + if lord: + bits.append(_humanize_reader_token(lord)) + if sign: + bits.append(_humanize_reader_token(sign)) + return f"Year Lord 已有可读结果,当前优先候选是{ ','.join(bits) },但仍需带着参数冲突标签阅读。" + return 'Year Lord 已有可读结果,但仍需带着参数冲突标签阅读。' + return segment_labels.get(segment_id, f'{segment_id} 已纳入当前报告结构。') + + def _blocked_reason_sentence(segment_id: str, detail: dict) -> str: + reason = detail.get('reason') + label = blocked_reason_labels.get(reason, reason or '当前仍需进一步核对。') + return f"{_segment_sentence(segment_id)} 原因:{label}" + + def _segment_group_sentence(group_name: str, segment_ids: list[str]) -> str: + if not segment_ids: + return f'{group_name}当前没有可展示内容。' + readable = [_segment_sentence(segment_id).rstrip('。') for segment_id in segment_ids] + return f"{group_name}包括:{';'.join(readable)}。" + + def _workbook_readable_label(worksheet_id: str) -> str: + if worksheet_id == 'birth_and_parameters': + return '出生与计算口径' + if worksheet_id == 'd1_rasi_bhava': + return '本命盘主结构' + if worksheet_id == 'strengths_and_scores': + return '力量与技术表' + if worksheet_id == 'divisional_and_special_charts': + return '分盘与特殊点位' + if worksheet_id == 'timing_and_predictive_systems': + return '时间系统与流年层' + if worksheet_id == 'advanced_systems': + return '进阶体系' + if worksheet_id == 'ai_and_audit': + return '审计与证据附录' + return '补充工作簿' + + def _workbook_readable_sentence(worksheet_id: str) -> str: + if worksheet_id == 'birth_and_parameters': + return '出生参数、岁差、交点模式与计算口径已经固定,是整份报告的可信底座。' + if worksheet_id == 'd1_rasi_bhava': + return '本命盘主结构已经就位,适合先看性格主轴、房屋结构和基础趋势。' + if worksheet_id == 'strengths_and_scores': + return '力量、功能性吉凶与技术表可以直接用来判断支撑与压力来源。' + if worksheet_id == 'divisional_and_special_charts': + return '分盘与特殊点位可作为扩展阅读,适合补充看细节。' + if worksheet_id == 'timing_and_predictive_systems': + return '大运、行运与年度层已经可以形成时间主线。' + if worksheet_id == 'advanced_systems': + return 'Jaimini、KP、Yoga 等进阶体系可作为补充主轴。' + if worksheet_id == 'ai_and_audit': + return 'AI 读写、审计与证据层用于核对来源与边界,不直接替代正文。' + return f'{worksheet_id} 已纳入当前报告。' + + def _reader_group_paragraph(group_name: str, segment_ids: list[str]) -> str: + if not segment_ids: + return f'{group_name}当前暂无稳定内容。' + return _segment_group_sentence(group_name, segment_ids) + + def _workbook_reading_paragraph(worksheet_ids: list[str]) -> str: + parts = [] + for worksheet_id in worksheet_ids: + sentence = _workbook_readable_sentence(worksheet_id).rstrip('。') + parts.append(f"先看{_workbook_readable_label(worksheet_id)},这里会告诉你{sentence}") + if not parts: + return '当前暂无可用工作簿阅读顺序。' + return ';'.join(parts) + '。' + + def _md_cell(value) -> str: + text = str(value if value not in (None, '') else '-').replace('\n', ' ').strip() + return text.replace('|', '/') + + def _render_compact_payload(value, depth: int = 0) -> str: + if value in (None, '', [], {}): + return '-' + if depth >= 2: + return _md_cell(type(value).__name__) + if isinstance(value, dict): + parts = [] + for key in sorted(value, key=str): + if key in { + 'narrative_items', + 'domain_paragraphs', + 'response_envelope', + 'input_hash', + 'profile_hash', + 'raw_calculation', + 'shared_full_report_authority', + }: + continue + parts.append(f"{key}={_render_compact_payload(value.get(key), depth + 1)}") + return '; '.join(parts) if parts else '-' + if isinstance(value, (list, tuple)): + items = [_render_compact_payload(item, depth + 1) for item in list(value)[:8]] + suffix = ' …' if len(value) > 8 else '' + return ', '.join(items) + suffix if items else '-' + return _md_cell(value) + + def _status_from_report_pack_section(section: dict) -> str: + if not isinstance(section, dict): + return 'blocked' + audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} + if section.get('status'): + return str(section.get('status')) + if audit.get('status'): + return str(audit.get('status')) + if section: + return 'partial_verified' + return 'blocked' + + def _section_evidence_boundary(section: dict) -> str: + if not isinstance(section, dict): + return 'section_missing' + exports = section.get('exports') if isinstance(section.get('exports'), dict) else {} + ai_bundle = exports.get('ai_evidence_bundle') if isinstance(exports.get('ai_evidence_bundle'), dict) else {} + audit = section.get('audit') if isinstance(section.get('audit'), dict) else {} + boundaries = [] + if ai_bundle.get('contains_private_pl9_text') is False: + boundaries.append('no_private_pl9_text') + if ai_bundle.get('claim_policy'): + boundaries.append(str(ai_bundle.get('claim_policy'))) + if audit.get('must_not_claim'): + boundaries.append('must_not_claim=' + ','.join(map(str, audit.get('must_not_claim')[:3]))) + if section.get('reason'): + boundaries.append(str(section.get('reason'))) + return '; '.join(boundaries) if boundaries else 'evidence_labeled' + + def _full_report_pack_assembly_section() -> list[str]: + if not isinstance(full_report_pack, dict) or not full_report_pack: + return [] + sections = full_report_pack.get('sections') if isinstance(full_report_pack.get('sections'), dict) else {} + if not sections: + return [] + profile = full_report_pack.get('profile') if isinstance(full_report_pack.get('profile'), dict) else {} + order = [ + ('base', '基础盘与出生口径'), + ('strength', '力量表与 Ashtakavarga'), + ('dasha', 'Dasha master pack'), + ('dasha_interpretation', 'Dasha interpretation pack'), + ('annual', 'Annual / Tajika pack'), + ('yoga', 'Yoga pack'), + ('dosha', 'Dosha pack'), + ('transit', 'Transit pack'), + ('audit_appendix', '审计附录'), + ('ai_evidence_bundle', 'AI evidence bundle'), + ] + ordered_keys = [key for key, _ in order if key in sections] + ordered_keys.extend(sorted(key for key in sections if key not in ordered_keys)) + labels = {key: label for key, label in order} + out = [ + '### Full Pack 装配层(operational report packs)', + '', + '这一节直接读取 `full_report_pack.sections`,把当前正式导出到底装进了哪些 pack、每个 pack 的状态和证据边界摊开。它解决的是“长报告装配可见性”,不是新增算法。', + '', + f"- schema: `{_md_cell(full_report_pack.get('schema'))}`;status: {_md_cell(full_report_pack.get('status'))};profile: `{_md_cell(profile.get('profile_id'))}`", + f"- section_count: {len(sections)};claim_policy: blocked/conflict fields stay labeled and cannot be promoted into final event claims", + '', + '| Pack Section | Schema | Status | 内容键 | 证据边界 |', + '|--------------|--------|--------|--------|----------|', + ] + for key in ordered_keys: + section = sections.get(key) if isinstance(sections.get(key), dict) else {} + content_keys = [item for item in section.keys() if item not in {'data'}] + if 'data' in section: + content_keys.append('data') + schema = section.get('schema') or f'pl9.full_report_pack.section.{key}' + out.append( + f"| {_md_cell(labels.get(key, key))} | `{_md_cell(schema)}` | {_md_cell(_status_from_report_pack_section(section))} | " + f"{_md_cell(', '.join(content_keys[:8]))} | {_md_cell(_section_evidence_boundary(section))} |" + ) + dasha_interpretation = sections.get('dasha_interpretation') if isinstance(sections.get('dasha_interpretation'), dict) else {} + summary = dasha_interpretation.get('summary') if isinstance(dasha_interpretation.get('summary'), dict) else {} + if summary: + out.extend([ + '', + f"- Dasha interpretation 已装配:narrative_items={_md_cell(summary.get('narrative_item_count'))},domain_paragraphs={_md_cell(summary.get('domain_paragraph_count'))},parameter_sensitive={_md_cell(summary.get('parameter_sensitive_item_count'))}。", + ]) + annual_section = sections.get('annual') if isinstance(sections.get('annual'), dict) else {} + annual_report_sections = annual_section.get('report_sections') if isinstance(annual_section.get('report_sections'), dict) else {} + if annual_report_sections: + out.append(f"- Annual pack 已装配 report_sections:{_md_cell(', '.join(annual_report_sections.keys()))}。") + out.append('') + return out + + def _domi_pl9_current_gap_appendix() -> list[str]: + return [] + + def _profile_aware_benchmark_appendix() -> list[str]: + dashboard_path = Path(ROOT_DIR) / 'references/oracle/profile_aware_benchmark_boundary_dashboard_2026_08_27.json' + if not dashboard_path.exists(): + return [] + try: + dashboard = json.loads(dashboard_path.read_text(encoding='utf-8')) + except Exception: + return [] + production = dashboard.get('production_readiness') if isinstance(dashboard.get('production_readiness'), dict) else {} + profiles = dashboard.get('profiles') if isinstance(dashboard.get('profiles'), list) else [] + capability_rows = dashboard.get('capability_rows') if isinstance(dashboard.get('capability_rows'), list) else [] + commercial_top3_rows = dashboard.get('commercial_top3_gap_matrix') if isinstance(dashboard.get('commercial_top3_gap_matrix'), list) else [] + jhora_pending = dashboard.get('jhora_pending_values') if isinstance(dashboard.get('jhora_pending_values'), dict) else {} + reader_view = dashboard.get('reader_view') if isinstance(dashboard.get('reader_view'), dict) else {} + if not profiles and not capability_rows: + return [] + out = [ + '### Profile-aware Benchmark Boundary', + '', + _md_cell(reader_view.get('headline') or '报告可用;精确复刻仍保留为审计边界'), + '', + f"- dashboard_status: `{_md_cell(dashboard.get('status'))}`", + f"- report_generation_blocked_by_jhora: `{_md_cell(production.get('report_generation_blocked_by_jhora'))}`", + f"- ready_or_support_capability_count: `{_md_cell(production.get('ready_or_support_capability_count'))}`", + '', + '#### 普通用户可读状态', + '', + ] + for item in reader_view.get('summary', []) if isinstance(reader_view.get('summary'), list) else []: + out.append(f"- {_md_cell(item)}") + usable_now = reader_view.get('usable_now') if isinstance(reader_view.get('usable_now'), list) else [] + if usable_now: + out.extend(['', '| 现在可用 | 对报告意味着什么 | 审计边界 |', '|----------|----------------|----------|']) + for item in usable_now: + if not isinstance(item, dict): + continue + out.append( + f"| {_md_cell(item.get('title'))} | {_md_cell(item.get('user_meaning'))} | {_md_cell(item.get('audit_boundary'))} |" + ) + still_audit_only = reader_view.get('still_audit_only') if isinstance(reader_view.get('still_audit_only'), list) else [] + if still_audit_only: + out.extend(['', '| 仍在审计 | 为什么 | 对用户报告的影响 |', '|----------|--------|----------------|']) + for item in still_audit_only: + if not isinstance(item, dict): + continue + out.append(f"| {_md_cell(item.get('title'))} | {_md_cell(item.get('why'))} | {_md_cell(item.get('impact'))} |") + tradition_rows = reader_view.get('tradition_difference_rows') if isinstance(reader_view.get('tradition_difference_rows'), list) else [] + if tradition_rows: + out.extend(['', '| 流派/软件差异 | 本项目处理方式 | 报告标签 |', '|----------------|----------------|----------|']) + for row in tradition_rows: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('difference'))} | {_md_cell(row.get('local_policy'))} | {_md_cell(row.get('reader_label'))} |" + ) + claim_policy = reader_view.get('claim_policy') if isinstance(reader_view.get('claim_policy'), dict) else {} + allowed_claims = claim_policy.get('allowed') if isinstance(claim_policy.get('allowed'), list) else [] + not_allowed_claims = claim_policy.get('not_allowed') if isinstance(claim_policy.get('not_allowed'), list) else [] + if allowed_claims or not_allowed_claims: + out.extend(['', '| 可声明 | 不可声明 |', '|--------|----------|']) + max_len = max(len(allowed_claims), len(not_allowed_claims)) + for index in range(max_len): + out.append( + f"| {_md_cell(allowed_claims[index] if index < len(allowed_claims) else '')} | " + f"{_md_cell(not_allowed_claims[index] if index < len(not_allowed_claims) else '')} |" + ) + if commercial_top3_rows: + out.extend([ + '', + '#### 商业前三对标', + '', + '| 对标项目 | 本地状态 | 剩余差距 | 下一步 |', + '|----------|----------|----------|--------|', + ]) + for row in commercial_top3_rows: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('benchmark_project'))} | {_md_cell(row.get('local_status'))} | " + f"{_md_cell(row.get('remaining_gap'))} | {_md_cell(row.get('next_action'))} |" + ) + out.extend([ + '', + '#### 技术 Profile 明细', + '', + '| Profile | Readiness | 可生成报告 | 可声称 PL9/JHora exact parity | 边界标签 |', + '|---------|-----------|------------|-----------------------------|----------|', + ]) + for profile in profiles: + if not isinstance(profile, dict): + continue + if profile.get('profile_id') not in { + 'sanjay_rath_narayana_ad_profile', + 'domi_narayana_continuation_profile', + 'shadbala_kala_jhora_replacement_profile', + 'jhora_oracle_pending_profile', + 'pl9_observed_dense_ad_profile', + }: + continue + out.append( + f"| `{_md_cell(profile.get('profile_id'))}` | {_md_cell(profile.get('readiness'))} | " + f"{_md_cell(profile.get('may_generate_user_report'))} | {_md_cell(profile.get('may_claim_pl9_jhora_exact_parity'))} | " + f"`{_md_cell(profile.get('required_boundary_label'))}` |" + ) + out.extend(['', '| Capability | Readiness | 是否阻塞用户报告 | 说明 |', '|------------|-----------|------------------|------|']) + for row in capability_rows: + if not isinstance(row, dict): + continue + if row.get('capability') not in { + 'narayana_dense_ad_benchmark', + 'sanjay_rath_narayana_ad_profile', + 'domi_continuation_benchmark_profile', + 'shadbala_kala_component_profile', + 'jhora_numeric_oracle', + }: + continue + out.append( + f"| `{_md_cell(row.get('capability'))}` | {_md_cell(row.get('readiness'))} | " + f"{_md_cell(row.get('blocking_for_user_report'))} | {_md_cell(row.get('notes'))} |" + ) + domi_kala = jhora_pending.get('domi_kala') if isinstance(jhora_pending.get('domi_kala'), dict) else {} + if domi_kala: + out.extend([ + '', + f"- Shadbala Kala 替代状态:`{_md_cell(domi_kala.get('replacement_profile_status'))}`;" + f"仍缺 JHora desktop 值:{_md_cell(', '.join(str(item) for item in domi_kala.get('required_values', [])))};" + f"blocks_report_generation={_md_cell(domi_kala.get('blocks_report_generation'))}。", + ]) + must_not_claim = production.get('must_not_claim') if isinstance(production.get('must_not_claim'), list) else [] + if must_not_claim: + out.append(f"- 禁止声明:{_md_cell(';'.join(str(item) for item in must_not_claim))}。") + out.append('') + return out + + def _timing_mainline_section() -> list[str]: + dasha_master_pack = timing_sheet.get('dasha_master_pack') if isinstance(timing_sheet.get('dasha_master_pack'), dict) else {} + families = dasha_master_pack.get('families') if isinstance(dasha_master_pack.get('families'), dict) else {} + annual_pack = timing_sheet.get('annual_tajika_pack') if isinstance(timing_sheet.get('annual_tajika_pack'), dict) else {} + annual_sections = annual_pack.get('sections') if isinstance(annual_pack.get('sections'), dict) else {} + year_lord = annual_sections.get('year_lord') if isinstance(annual_sections.get('year_lord'), dict) else {} + rows: list[tuple[str, str, str, str]] = [] + + def _family_status_row(label: str, key: str, confidence_when_executed: str, blocked_text: str) -> None: + family = families.get(key) if isinstance(families.get(key), dict) else {} + if not family: + return + execution_status = family.get('execution_status') or family.get('status') or 'blocked' + if execution_status in {'executed', 'partial_verified'}: + rows.append((label, 'executed', confidence_when_executed, blocked_text)) + return + reason = family.get('reason') or family.get('blocked_reason') or family.get('status') or execution_status + reason_text = blocked_reason_labels.get(reason, reason) + if reason_text == '本地引擎当前把这套大运标记为不适用,因此仍保留在核对层。': + reason_text = '本地引擎当前把这套大运标记为不适用。' + rows.append((label, 'blocked', 'blocked', str(reason_text))) + + _family_status_row('Vimshottari', 'vimshottari', 'parameter_sensitive', '主时间线已接入正文;当前已能给出 Dasha at Birth / Balance of Dasha / Current Dasha,但仍不升级为确定事件断言。') + _family_status_row('Narayana', 'narayana', 'parameter_sensitive', '双大运正文入口已接入,但当前仍主要输出本地年龄轴;与原始 PL9 的日期边界 / 起运序列 parity 尚未闭环。') + _family_status_row('Yogini', 'yogini', 'parameter_sensitive', 'Yogini 周期已返回,但外部起始规则与边界 parity 尚未闭环。') + _family_status_row('Ashtottari', 'ashtottari', 'parameter_sensitive / unverified', 'Ashtottari 已返回本地候选规则,但外部 profile 对照尚未闭环。') + _family_status_row('Kala Chakra', 'kala_chakra', 'parameter_sensitive', 'Kala Chakra 本地周期已返回,但算法冲突标签仍需保留。') + if year_lord: + rows.append(('Year Lord', 'executed', 'pyjhora_behavior_only / not_multiengine_parity', '仅锁定 PyJHora 行为,尚未完成多引擎 parity/replay。')) + if not rows: + return [] + out = [ + '### 大运与时间主线', + '', + '本节只汇总当前报告已经装入的时间系统状态。下表用于说明哪些 Dasha / 年度时间层可以作为阅读线索,哪些仍处于 blocked;所有内容都不构成确定事件断言。', + '', + '#### 时间系统证据状态', + '', + '| 系统 | 调用状态 | 结论强度 | 当前边界 |', + '|------|----------|----------|----------|', + ] + for name, execution_status, confidence, boundary in rows: + out.append(f"| {_md_cell(name)} | {_md_cell(execution_status)} | {_md_cell(confidence)} | {_md_cell(boundary)} |") + out.append('') + return out + + def _graphical_ephemeris_text_section() -> list[str]: + transit = timing_sheet.get('transit_multi_reference') if isinstance(timing_sheet.get('transit_multi_reference'), dict) else {} + transit_analysis = transit.get('transit_analysis') if isinstance(transit.get('transit_analysis'), dict) else {} + rows: list[tuple[str, str, str, str]] = [] + for window in monthly_windows[:4]: + if not isinstance(window, dict): + continue + index = window.get('index') + lord = _humanize_reader_token(window.get('lord')) + duration = window.get('duration_months') + if index and lord and duration not in (None, ''): + rows.append(( + '年度月段', + f"第{index}段 / {lord}", + f"约 {duration} 个月", + 'parameter_sensitive', + )) + for planet in ('Jupiter', 'Saturn'): + row = transit_analysis.get(planet) if isinstance(transit_analysis.get(planet), dict) else {} + if not row: + continue + houses = row.get('house_from_ref') if isinstance(row.get('house_from_ref'), dict) else {} + lagna_house = (houses.get('Lagna') or {}).get('house') if isinstance(houses.get('Lagna'), dict) else None + moon_house = (houses.get('Chandra_Lagna') or {}).get('house') if isinstance(houses.get('Chandra_Lagna'), dict) else None + place = f"{_humanize_reader_token(row.get('sign_cn') or row.get('sign'))} {_degree_text(row.get('degree_in_sign'))}" + house_note_bits = [] + if lagna_house not in (None, ''): + house_note_bits.append(f"从上升看第{lagna_house}宫") + if moon_house not in (None, ''): + house_note_bits.append(f"从月亮看第{moon_house}宫") + house_note = ';'.join(house_note_bits) if house_note_bits else '位置锚点可见' + rows.append(( + '关键行运', + _humanize_reader_token(planet), + f"{place};{house_note}", + 'parameter_sensitive', + )) + if not rows: + return [] + out = [ + '#### Graphical Ephemeris(文本时间轴)', + '', + '这不是 PL9 原始图形页,而是先把年度月段与关键行运行星压成一张可直接阅读的文本时间轴。视觉坐标、曲线和原始图形 parity 仍未闭环,因此整节保持 parameter_sensitive。', + '', + '| 图形星历层 | 当前锚点 | 时间/位置 | 状态 |', + '|------------|----------|-----------|------|', + ] + for layer, anchor, detail, status in rows: + out.append(f"| {_md_cell(layer)} | {_md_cell(anchor)} | {_md_cell(detail)} | {_md_cell(status)} |") + out.extend([ + '', + '#### 图形星历阅读抓手', + '', + '先看年度月段,再看木星 / 土星这两条慢行星锚点:前者帮助你读年度内部的推进节奏,后者帮助你确认外部舞台与压力转场的位置。它仍然只是文本时间轴,不替代原始图形页或 timing truth。', + '', + '如果你要继续往下压缩阅读路径,可以把这里当成时间总览,再去看后面的 Dasha Sandhi 临界日期和 Monthly Windows:前者更像边界提醒,后者更像阶段拆解。它们仍然只提供 parameter_sensitive 的时间线索。', + '', + ]) + overlay_rows = [] + if any(row[0] == '关键行运' for row in rows): + overlay_rows.append(('慢行星锚点', '木星 / 土星', '用于确认外部舞台、压力转场与长期主题位移。', 'parameter_sensitive')) + if any(row[0] == '年度月段' for row in rows): + overlay_rows.append(('Dasha / Annual overlay', '年度月段', '把月段推进和慢行星锚点叠起来看,阅读节奏会更接近专题页。', 'parameter_sensitive')) + if replay_patyayini_rows or tajika_candidates: + overlay_rows.append(('外部回放 / 条件页', 'Patyayini / Tajika', '只作为交叉核对入口,不替代 timing truth 或命名 Yoga 判定。', 'parameter_sensitive')) + if overlay_rows: + out.extend([ + '#### 图形星历交叉覆盖', + '', + '| 叠加层 | 当前锚点 | 说明 | 状态 |', + '|--------|----------|------|------|', + ]) + for layer, anchor, detail, status in overlay_rows: + out.append(f"| {_md_cell(layer)} | {_md_cell(anchor)} | {_md_cell(detail)} | {_md_cell(status)} |") + out.append('') + out.append('') + return out + + def _dasha_sandhi_section() -> list[str]: + dasha_sandhi = timing_sheet.get('dasha_sandhi') if isinstance(timing_sheet.get('dasha_sandhi'), dict) else {} + windows = dasha_sandhi.get('sandhi_windows') if isinstance(dasha_sandhi.get('sandhi_windows'), list) else [] + if not windows: + return [] + out = [ + '#### Dasha Sandhi 临界日期(本地窗口)', + '', + f"参考日期:{_md_cell(dasha_sandhi.get('reference_date'))};窗口:±{_md_cell(dasha_sandhi.get('orb_days'))} 天。下表只列出 Mahadasha / Antar Dasha 的本地边界日期,不构成事件预测或确定应期。", + '', + '| 层级 | Mahadasha | Lord | 边界 | 日期 | 距参考日天数 | 状态 |', + '|------|-----------|------|------|------|--------------|------|', + ] + for row in windows: + if not isinstance(row, dict): + continue + level = row.get('level') + level_label = 'Maha Dasha' if level == 'mahadasha' else 'Antar Dasha' if level == 'antardasha' else level + out.append( + f"| {_md_cell(level_label)} | {_md_cell(_humanize_reader_token(row.get('mahadasha_lord')))} | " + f"{_md_cell(_humanize_reader_token(row.get('lord')))} | {_md_cell(row.get('boundary'))} | " + f"{_md_cell(row.get('date'))} | {_md_cell(row.get('days_from_reference'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _chart_planets_from_positions(positions: dict) -> dict: + chart_planets = {} + if not isinstance(positions, dict): + return chart_planets + for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + planet = positions.get(planet_name) + if not isinstance(planet, dict): + continue + sign = planet.get('sign') or planet.get('rashi') or planet.get('sign_name') + if not sign: + continue + degree = planet.get('degree_in_sign', planet.get('degree', planet.get('longitude_in_sign', 0))) + chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0} + return chart_planets + + def _render_south_chart(positions: dict, ascendant_row: dict, title: str) -> str | None: + chart_planets = _chart_planets_from_positions(positions) + asc_sign = ascendant_row.get('sign') if isinstance(ascendant_row, dict) else None + if not chart_planets or not asc_sign: + return None + try: + from chart_renderer import render_south_indian_chart + return render_south_indian_chart(chart_planets, asc_sign, title) + except Exception as exc: + return f'_图盘生成失败:{exc}_' + + def _d1_chart_svg() -> str | None: + core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} + return _render_south_chart( + core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {}, + core_chart.get('ascendant') if isinstance(core_chart.get('ascendant'), dict) else {}, + 'D1 — Rashi Chart (本命盘)', + ) + + def _varga_full_sheet() -> dict: + divisional = packet.get('divisional_and_special_charts') if isinstance(packet.get('divisional_and_special_charts'), dict) else {} + varga_full = divisional.get('varga_full') if isinstance(divisional.get('varga_full'), dict) else {} + if varga_full: + return varga_full + worksheet_divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + return worksheet_divisional.get('varga_full') if isinstance(worksheet_divisional.get('varga_full'), dict) else {} + + def _varga_chart_svg(chart_key: str, title: str) -> str | None: + varga_full = _varga_full_sheet() + chart = varga_full.get(chart_key) if isinstance(varga_full.get(chart_key), dict) else {} + if not chart: + return None + ascendant_row = chart.get('Ascendant') if isinstance(chart.get('Ascendant'), dict) else chart.get('Asc') if isinstance(chart.get('Asc'), dict) else {} + return _render_south_chart(chart, ascendant_row, title) + + def _d9_chart_svg() -> str | None: + return _varga_chart_svg('D9_Navamsa', 'D9 — Navamsa (婚盘)') or _varga_chart_svg('D9', 'D9 — Navamsa (婚盘)') + + def _moon_chart_section() -> list[str]: + d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + moon_chart = d1_sheet.get('moon_chart') if isinstance(d1_sheet.get('moon_chart'), dict) else {} + raw = moon_chart.get('raw') if isinstance(moon_chart.get('raw'), dict) else {} + reference = raw.get('Ascendant') if isinstance(raw.get('Ascendant'), dict) else {} + planets = raw.get('planets') if isinstance(raw.get('planets'), dict) else {} + if not reference or not planets: + return [] + svg = _render_south_chart(planets, reference, 'Moon Chart(月亮参考盘)') + out = [ + '#### Moon Chart(月亮参考盘)', + '', + '月亮盘以本命月亮所在星座作为参考上升,下面只保留从月亮起算的星座/宫位结构。尚未完成 PL9 月亮盘图格级对照,因此整节保持 parameter_sensitive。', + '', + ] + if svg: + out.extend([svg, '']) + out.extend([ + '| 行星 | 星座 | 从月亮起算宫位 | 状态 |', + '|------|------|------------------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + if row: + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(_humanize_reader_token(row.get('sign')))} | " + f"{_md_cell(row.get('house_from_moon'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _sudarshana_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + sudarshana = divisional_sheet.get('sudarshana') if isinstance(divisional_sheet.get('sudarshana'), dict) else {} + raw = sudarshana.get('raw') if isinstance(sudarshana.get('raw'), dict) else {} + references = raw.get('reference_points') if isinstance(raw.get('reference_points'), dict) else {} + charts = raw.get('three_charts') if isinstance(raw.get('three_charts'), dict) else {} + ascendant_chart = charts.get('ascendant_lagna') if isinstance(charts.get('ascendant_lagna'), dict) else {} + moon_chart = charts.get('moon_lagna') if isinstance(charts.get('moon_lagna'), dict) else {} + sun_chart = charts.get('sun_lagna') if isinstance(charts.get('sun_lagna'), dict) else {} + if not references or not ascendant_chart or not moon_chart or not sun_chart: + return [] + + reference_text = ';'.join( + f"{label}:{_humanize_reader_token((references.get(key) or {}).get('sign'))}" + for key, label in ( + ('ascendant_lagna', '出生上升'), + ('moon_lagna', '月亮参考点'), + ('sun_lagna', '太阳参考点'), + ) + if isinstance(references.get(key), dict) + ) + out = [ + '#### Sudarshana Chakra(三参考点原始结构)', + '', + f'本节复用本地 Sudarshana 计算器,仅展示三个参考点的原始星座/落宫结构({reference_text})。PL9 第 8 页的三层图格尚未逐格对照,因此整节保持 parameter_sensitive。', + '', + '不输出 producer 自带的复合评分或解释文本,也不据此生成事件判断。', + '', + '| 行星 | 星座 | 从出生上升起算 | 从月亮起算 | 从太阳起算 | 状态 |', + '|------|------|----------------|------------|------------|------|', + ] + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + asc_row = ascendant_chart.get(planet) if isinstance(ascendant_chart.get(planet), dict) else {} + moon_row = moon_chart.get(planet) if isinstance(moon_chart.get(planet), dict) else {} + sun_row = sun_chart.get(planet) if isinstance(sun_chart.get(planet), dict) else {} + if asc_row and moon_row and sun_row: + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | " + f"{_md_cell(_humanize_reader_token(asc_row.get('sign')))} | " + f"{_md_cell(asc_row.get('house'))} | {_md_cell(moon_row.get('house'))} | " + f"{_md_cell(sun_row.get('house'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _visual_chart_evidence_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + visual_chart_pack = divisional_sheet.get('visual_chart_pack') if isinstance(divisional_sheet.get('visual_chart_pack'), dict) else {} + if not visual_chart_pack: + return [] + audit = visual_chart_pack.get('audit') if isinstance(visual_chart_pack.get('audit'), dict) else {} + summary = visual_chart_pack.get('placement_summary') if isinstance(visual_chart_pack.get('placement_summary'), dict) else {} + placement = visual_chart_pack.get('content_placement') if isinstance(visual_chart_pack.get('content_placement'), dict) else {} + pages = audit.get('pl9_pages') if isinstance(audit.get('pl9_pages'), list) else [] + must_not_claim = placement.get('must_not_claim') if isinstance(placement.get('must_not_claim'), list) else [] + out = [ + '#### PL9 图盘证据状态', + '', + '本节仅披露 PL9 图盘提取的证据覆盖和限制。OCR 候选不作为本案行星落点、星座或数值对齐结论;本命盘仍以本报告的计算字段为准。', + '', + '| 项目 | 状态 / 数值 |', + '|------|-------------|', + f"| 图盘包状态 | {_md_cell(visual_chart_pack.get('status'))} |", + f"| 图盘页面范围 | {_md_cell(', '.join(str(page) for page in pages) or '-')} |", + f"| 候选面板 / 已解析落点 | {_md_cell(summary.get('candidate_panel_count'))} / {_md_cell(summary.get('parsed_placement_count'))} |", + f"| 严格 Asc/星座锚点面板 | {_md_cell(summary.get('anchored_candidate_panel_count'))} |", + f"| 星座候选落点 / 阻塞面板 | {_md_cell(summary.get('sign_candidate_placement_count'))} / {_md_cell(summary.get('blocked_panel_count'))} |", + f"| 不得声明 | {_md_cell(', '.join(str(item) for item in must_not_claim) or '-')} |", + '', + ] + return out + + def _chart_atlas_section() -> list[str]: + atlas_specs = [ + ('D2_Hora', 'D2 — Hora(财富分盘)'), + ('D3_Drekkana', 'D3 — Drekkana(手足与行动)'), + ('D4_Turyamsa', 'D4 — Turyamsa(居所与根基)'), + ('D5_Panchamsa', 'D5 — Panchamsa(声望与表达)'), + ('D6_Shashthamsa', 'D6 — Shashthamsa(压力与恢复)'), + ('D7_Saptamsa', 'D7 — Saptamsa(创造与子女)'), + ('D8_Ashtamsa', 'D8 — Ashtamsa(突发变化与韧性)'), + ('D9_Navamsa', 'D9 — Navamsa(关系、承诺与法则分盘)'), + ('D10_Dasamsa', 'D10 — Dasamsa(事业分盘)'), + ('D11_Rudramsa', 'D11 — Rudramsa(收益、社群与愿望分盘)'), + ('D12_Dwadashamsa', 'D12 — Dwadashamsa(父母家族)'), + ('D16_Shodasamsa', 'D16 — Shodasamsa(享受与载具)'), + ('D20_Vimsamsa', 'D20 — Vimsamsa(灵修与信念)'), + ('D24_Siddhamsa', 'D24 — Siddhamsa(学习与技能)'), + ('D27_Bhamsa', 'D27 — Bhamsa(内在力量)'), + ('D30_Trimsamsa', 'D30 — Trimsamsa(困难与脆弱点)'), + ('D40_Khavedamsa', 'D40 — Khavedamsa(母系福分)'), + ('D45_Akshavedamsa', 'D45 — Akshavedamsa(父系福分)'), + ('D60_Shashtyamsa', 'D60 — Shashtyamsa(深层业力)'), + ] + rendered = [] + for key, title in atlas_specs: + svg = _varga_chart_svg(key, title) + if svg: + rendered.extend([f'#### {title}', '', svg, '']) + if not rendered: + return [] + varga_full = _varga_full_sheet() + core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} + d1_planets = core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {} + d1_ascendant = core_chart.get('ascendant') if isinstance(core_chart.get('ascendant'), dict) else {} + matrix_specs = [ + ('D1', 'D1_Rashi'), + ('D2', 'D2_Hora'), + ('D3', 'D3_Drekkana'), + ('D4', 'D4_Turyamsa'), + ('D7', 'D7_Saptamsa'), + ('D9', 'D9_Navamsa'), + ('D10', 'D10_Dasamsa'), + ('D12', 'D12_Dwadashamsa'), + ('D16', 'D16_Shodasamsa'), + ('D20', 'D20_Vimsamsa'), + ('D24', 'D24_Siddhamsa'), + ('D27', 'D27_Bhamsa'), + ('D30', 'D30_Trimsamsa'), + ('D40', 'D40_Khavedamsa'), + ('D45', 'D45_Akshavedamsa'), + ('D60', 'D60_Shashtyamsa'), + ] + matrix_rows = [] + for label, chart_key in matrix_specs: + chart = varga_full.get(chart_key) if isinstance(varga_full.get(chart_key), dict) else {} + if label == 'D1': + planets = d1_planets + ascendant = d1_ascendant + else: + planets = chart + ascendant = chart.get('Ascendant') if isinstance(chart.get('Ascendant'), dict) else {} + if not ascendant or not planets: + continue + values = [_humanize_reader_token(ascendant.get('sign'))] + values.extend( + _humanize_reader_token((planets.get(planet) or {}).get('sign')) + if isinstance(planets.get(planet), dict) else '-' + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu') + ) + matrix_rows.append((label, values)) + if matrix_rows: + rendered.extend([ + '#### Shodashvarga 星座总表', + '', + 'Domi 受控样本的 16 个星座行已在 PL9 第 42 页 fixture 中匹配;其他出生资料仅使用同一计算口径输出,仍为 parameter_sensitive。尊严格、D150 与 Iyer 变体不在本表范围。', + '', + '| 分盘 | 上升 | 太阳 | 月亮 | 火星 | 水星 | 木星 | 金星 | 土星 | 北交点 | 南交点 | 状态 |', + '|------|------|------|------|------|------|------|------|------|--------|--------|------|', + ]) + for label, values in matrix_rows: + rendered.append(f"| {label} | {' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |") + rendered.append('') + dignity_rows = [] + for label, chart_key in matrix_specs: + chart = varga_full.get(chart_key) if isinstance(varga_full.get(chart_key), dict) else {} + if label == 'D1': + dignity_source = d1_planets + else: + dignity_source = chart.get('_dignity') if isinstance(chart.get('_dignity'), dict) else {} + if not dignity_source: + continue + values = [] + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = dignity_source.get(planet) if isinstance(dignity_source.get(planet), dict) else {} + value = row.get('status') if label == 'D1' else dignity_source.get(planet) + values.append(_humanize_reader_token(value) if value is not None else '-') + dignity_rows.append((label, values)) + if dignity_rows: + rendered.extend([ + '#### Shodashvarga 本地尊贵原始矩阵', + '', + 'PL9 第 42 页的尊贵词汇包含 friend/enemy 梯度;本地 producer 仅提供自身状态分类,尚未完成字段级词汇和数值 parity。下表只透传本地原始状态,所有行保持 parameter_sensitive。', + '', + '| 分盘 | 太阳 | 月亮 | 火星 | 水星 | 木星 | 金星 | 土星 | 北交点 | 南交点 | 状态 |', + '|------|------|------|------|------|------|------|------|--------|--------|------|', + ]) + for label, values in dignity_rows: + rendered.append(f"| {label} | {' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |") + rendered.append('') + + # D5/D6/D8/D11 are visible evidence, but they are not members of + # the classical sixteen-varga matrix. Render them separately so a + # complete report does not misclassify extended charts as formal. + extended_matrix_specs = [ + ('D5', 'D5_Panchamsa'), + ('D6', 'D6_Shashthamsa'), + ('D8', 'D8_Ashtamsa'), + ('D11', 'D11_Rudramsa'), + ] + extended_rows = [] + for label, chart_key in extended_matrix_specs: + chart = varga_full.get(chart_key) if isinstance(varga_full.get(chart_key), dict) else {} + planets = chart.get('planets') if isinstance(chart.get('planets'), dict) else chart + ascendant = chart.get('Ascendant') if isinstance(chart.get('Ascendant'), dict) else chart.get('ascendant') + if not isinstance(ascendant, dict) or not isinstance(planets, dict): + continue + values = [_humanize_reader_token(ascendant.get('sign'))] + values.extend( + _humanize_reader_token((planets.get(planet) or {}).get('sign')) + if isinstance(planets.get(planet), dict) else '-' + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu') + ) + extended_rows.append((label, values)) + if extended_rows: + rendered.extend([ + '#### 扩展分盘原始星座总表(D5 / D6 / D8 / D11)', + '', + '本表保留已执行的扩展分盘原始位置,供主题交叉核验;它们不计入上方传统 Shodashvarga 的 16 行。D11 可作为财富、收益、社群与愿望的辅助交叉证据,不能单独形成财富结论。所有行保持 parameter_sensitive。', + '', + '| 分盘 | 上升 | 太阳 | 月亮 | 火星 | 水星 | 木星 | 金星 | 土星 | 北交点 | 南交点 | 状态 |', + '|------|------|------|------|------|------|------|------|------|--------|--------|------|', + ]) + for label, values in extended_rows: + rendered.append(f"| {label} | {' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |") + rendered.append('') + + d1_d60_section = full_report_pack.get('sections', {}).get('d1_d60_ledger') if isinstance(full_report_pack.get('sections'), dict) else {} + d1_d60_ledger = d1_d60_section.get('d1_to_d60') if isinstance(d1_d60_section.get('d1_to_d60'), dict) else {} + d1_d60_summary = d1_d60_section.get('summary') if isinstance(d1_d60_section.get('summary'), dict) else {} + if d1_d60_ledger: + def _ledger_position(value) -> str: + if not isinstance(value, dict): + return '-' + sign = _humanize_reader_token(value.get('sign')) + degree = value.get('degree_in_sign') + if degree is None: + degree = value.get('degree') + return f"{sign} {_degree_text(degree)}" if sign and degree is not None else sign or '-' + + rendered.extend([ + '#### D1–D60 完整原始分盘账本(20 传统 + 40 研究型 D-N)', + '', + '此账本保留全部 60 个分割数的原始位置。命名传统分盘只能按各自 evidence boundary 进入专题交叉阅读;其余 40 个通用 D-N 行为研究型原始计算,禁止独立生成主题结论、事件判断或 chart approval。', + '', + f"正式传统分盘:{_md_cell(d1_d60_summary.get('formal_traditional_division_count'))};研究型通用 D-N:{_md_cell(d1_d60_summary.get('research_generic_dn_division_count'))}。", + '', + '| 分盘 | 上升 | 太阳 | 月亮 | 火星 | 水星 | 木星 | 金星 | 土星 | 北交点 | 南交点 | 技法等级 | 解释许可 | 稳定性 |', + '|------|------|------|------|------|------|------|------|------|--------|--------|----------|----------|--------|', + ]) + for division in (f'D{number}' for number in range(1, 61)): + row = d1_d60_ledger.get(division) if isinstance(d1_d60_ledger.get(division), dict) else {} + raw = row.get('raw') if isinstance(row.get('raw'), dict) else {} + values = [_ledger_position(raw.get(body)) for body in ('Ascendant', 'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')] + tier = row.get('capability_tier') or 'unavailable' + permission = '允许受限交叉阅读' if row.get('interpretation_allowed') else '仅原始研究' + rendered.append( + f"| {division} | {' | '.join(_md_cell(value) for value in values)} | {_md_cell(tier)} | {_md_cell(permission)} | {_md_cell(row.get('stability') or 'not_calculated')} |" + ) + rendered.extend([ + '', + f"账本边界:{_md_cell(d1_d60_section.get('claim_boundary') or 'not_declared')}", + '', + ]) + return [ + '### Vargas I', + '', + ] + rendered + + def _upagraha_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + upagrahas = divisional_sheet.get('upagrahas') if isinstance(divisional_sheet.get('upagrahas'), dict) else {} + raw_points = upagrahas.get('raw') if isinstance(upagrahas.get('raw'), dict) else {} + if not raw_points: + return [] + + order = ( + 'Kaala', 'Mrityu', 'Artha_Praharaka', 'Yama_Ghantaka', 'Gulika', 'Maandi', + 'Dhuma', 'Vyatipata', 'Parivesha', 'Indrachapa', 'Upaketu', + ) + out = [ + '### Upagraha / Sub-Planets(D1 本地计算)', + '', + '本表只保留 D1 原始点位,暂不用于直接断语或事件判断。现有计算尚未完成 PL9 第 17 页字段级与外部数值闭环,因此整节保持 parameter_sensitive。', + '', + '| 点位 | 星座 | 度数 | 计算来源 | 状态 |', + '|------|------|------|----------|------|', + ] + for name in order: + point = raw_points.get(name) if isinstance(raw_points.get(name), dict) else {} + if not point: + continue + try: + sign = SIGNS[int(point.get('sign_idx')) % 12] + sign_text = SIGNS_CN.get(sign, sign) + except (TypeError, ValueError): + sign_text = '-' + source = point.get('source') or (f"{point.get('period')}_segment" if point.get('period') else 'local_formula') + out.append( + f"| {_md_cell(name)} | {_md_cell(sign_text)} | {_degree_text(point.get('degree_in_sign'))} | {_md_cell(source)} | parameter_sensitive |" + ) + out.append('') + return out + + def _standard_lagnas_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + points = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {} + if not points: + return [] + + order = ( + 'Bhava_Lagna', 'Hora_Lagna', 'Ghati_Lagna', 'ViGhati_Lagna', + 'Sree_Lagna', 'Indu_Lagna', 'Arudha_Lagna', 'Upapada_Lagna', 'A10_Karma_Pada', + ) + out = [ + '#### Standard Lagnas(独立计算口径)', + '', + '本表来自独立的特殊 Lagna producer;不与后续 Jaimini 简化日出敏感口径合并。PL9 第 40 / 48 页尚未完成图格级字段复核,因此只作原始结构附录并保持 parameter_sensitive。', + '', + '| Lagna | 星座 | 度数 | 计算来源 | 状态 |', + '|-------|------|------|----------|------|', + ] + for name in order: + point = points.get(name) if isinstance(points.get(name), dict) else {} + if not point: + continue + source = point.get('source') or point.get('method') or 'local_formula' + degree = point.get('sign_degree', point.get('degree_in_sign')) + out.append( + f"| {_md_cell(point.get('full_name') or point.get('label') or name)} | " + f"{_md_cell(_humanize_reader_token(point.get('sign')))} | {_degree_text(degree)} | " + f"{_md_cell(source)} | parameter_sensitive |" + ) + out.append('') + return out + + def _sensitive_points_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} + if not points: + return [] + + point_specs = ( + ('bhrigu_bindu', 'Bhrigu Bindu', 'arc_mode'), + ('navamsa_64th', '64th Navamsa', None), + ('drekkana_22nd', '22nd Drekkana', None), + ) + rows = [] + for key, label, source_key in point_specs: + point = points.get(key) if isinstance(points.get(key), dict) else {} + if not point or not point.get('sign'): + continue + if key == 'navamsa_64th': + source = 'Moon Navamsa + 64' + elif key == 'drekkana_22nd': + source = 'Lagna Drekkana + 22' + else: + source = point.get(source_key) or 'local_formula' + rows.append((label, point, source)) + if not rows: + return [] + + out = [ + '#### 敏感派生点(本地原始位置)', + '', + '本表只保留本地派生点的原始星座与度数。PL9 第 57 页的 Lucky Points 表尚未完成公式、列结构与逐项数值 golden diff,因此所有行保持 parameter_sensitive,不生成吉凶或事件断语。', + '', + '| 点位 | 星座 | 星座内度数 | 本地来源 | 状态 |', + '|------|------|------------|----------|------|', + ] + for label, point, source in rows: + degree = point.get('degree_in_sign') + out.append( + f"| {_md_cell(label)} | {_md_cell(_humanize_reader_token(point.get('sign')))} | " + f"{_degree_text(degree) if degree is not None else '-'} | {_md_cell(source)} | parameter_sensitive |" + ) + out.append('') + return out + + def _kp_lord_sub_section() -> list[str]: + kp_pack = advanced_sheet.get('kp') if isinstance(advanced_sheet.get('kp'), dict) else {} + planets = kp_pack.get('planets') if isinstance(kp_pack.get('planets'), dict) else {} + houses = kp_pack.get('houses') if isinstance(kp_pack.get('houses'), dict) else {} + ruling_planets = kp_pack.get('ruling_planets') if isinstance(kp_pack.get('ruling_planets'), dict) else {} + kp_profile = kp_pack.get('kp_profile') if isinstance(kp_pack.get('kp_profile'), dict) else {} + calculation_profile = kp_pack.get('calculation_profile') if isinstance(kp_pack.get('calculation_profile'), dict) else {} + effective_settings = calculation_profile.get('effective_settings') if isinstance(calculation_profile.get('effective_settings'), dict) else {} + if not planets and not houses and not ruling_planets: + return [] + + explicit_kp_cusps = kp_pack.get('house_basis') == 'explicit_cusps' or any( + isinstance(row, dict) and row.get('cusp_longitude') is not None + for row in houses.values() + ) + if explicit_kp_cusps: + kp_ayanamsa = _ayanamsa_display_name(kp_profile.get('ayanamsa') or effective_settings.get('ayanamsa') or 'kp') + kp_boundary = ( + f'本次 KP 专用层使用 {kp_ayanamsa} 岁差 + Placidus 宫头,宫头来自 Swiss Ephemeris / Bhava Chalit。' + '这与主报告的本命宫制独立保存;外部同输入 KP 回放与应期 parity 尚未闭环,因此所有行仍保持 parameter_sensitive。' + ) + else: + kp_boundary = ( + '以下只呈现本次计算返回的 KP Lord/Sub 原始结构。尚未完成 PL9 第 33 页的 ayanamsa、宫制、cusp 与 sub-lord 字段级 golden diff,' + '不以此生成事件判断或确定性预测;所有行保持 parameter_sensitive。' + ) + + out = [ + '### KP Lord / Sub 原始表(本地计算)', + '', + kp_boundary, + '', + ] + if planets: + out.extend([ + '#### 行星 KP Lord / Sub', + '', + '| 行星 | 星座 | Nakshatra | NL | SL | SS | 状态 |', + '|------|------|-----------|----|----|----|------|', + ]) + for name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = planets.get(name) if isinstance(planets.get(name), dict) else {} + lords = row.get('kp_lords') if isinstance(row.get('kp_lords'), dict) else {} + if not lords: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(name))} | {_md_cell(_humanize_reader_token(lords.get('sign')))} | " + f"{_md_cell(lords.get('nakshatra'))} | {_md_cell(_humanize_reader_token(lords.get('nakshatra_lord')))} | " + f"{_md_cell(_humanize_reader_token(lords.get('sub_lord')))} | {_md_cell(_humanize_reader_token(lords.get('sub_sub_lord')))} | parameter_sensitive |" + ) + out.append('') + if houses: + out.extend(['#### 宫位 KP Lord / Sub', '']) + if explicit_kp_cusps: + out.extend([ + '| Bhava | 宫头 | 星座 | Nakshatra | NL | SL | SS | 状态 |', + '|-------|------|------|-----------|----|----|----|------|', + ]) + else: + out.extend([ + '| Bhava | 星座 | Nakshatra | NL | SL | SS | 状态 |', + '|-------|------|-----------|----|----|----|------|', + ]) + for house in range(1, 13): + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) if isinstance(houses.get(house), dict) else {} + lords = row.get('kp_lords') if isinstance(row.get('kp_lords'), dict) else {} + if not lords: + continue + cusp_cell = _longitude_text(row.get('cusp_longitude')) if explicit_kp_cusps else None + if explicit_kp_cusps: + prefix = f"| {house} | {cusp_cell} | {_md_cell(_humanize_reader_token(lords.get('sign') or row.get('sign')))}" + else: + prefix = f"| {house} | {_md_cell(_humanize_reader_token(lords.get('sign') or row.get('sign')))}" + out.append( + f"{prefix} | " + f"{_md_cell(lords.get('nakshatra'))} | {_md_cell(_humanize_reader_token(lords.get('nakshatra_lord')))} | " + f"{_md_cell(_humanize_reader_token(lords.get('sub_lord')))} | {_md_cell(_humanize_reader_token(lords.get('sub_sub_lord')))} | parameter_sensitive |" + ) + out.append('') + + def _kp_significator_cell(value) -> str: + if isinstance(value, (list, tuple, set)): + items = [_humanize_reader_token(item) for item in value if item not in (None, '')] + return _md_cell('、'.join(items) if items else '-') + if value in (None, ''): + return '-' + return _md_cell(_humanize_reader_token(value)) + + if any(isinstance(row, dict) and isinstance(row.get('significators'), dict) for row in planets.values()) or any( + isinstance(row, dict) and isinstance(row.get('significators'), dict) for row in houses.values() + ): + significator_boundary = ( + 'A/B/C/D 为当前本地 KP 模块返回的显著星结构,仅作专项核对线索。' + 'Placidus 宫头已按当前 KP 专用 profile 计算,但外部同输入回放与应期 parity 未闭环;' + '因此所有行保持 parameter_sensitive,不用于确定性事件或应期判断。' + if explicit_kp_cusps + else 'A/B/C/D 为当前本地 KP 模块返回的显著星结构,仅作专项核对线索。宫位行当前以 whole-sign 宫位中点作为本地代理,' + '不等同于 KP Placidus 宫头;因此所有行保持 parameter_sensitive,不用于确定性事件或应期判断。' + ) + out.extend([ + '#### KP 显著星 ABCD(本地计算)', + '', + significator_boundary, + '', + '这里的 A/B/C/D 不是吉凶评分,也不是“越靠前越一定发生”。它更像是“这颗星通过哪些层次与该宫位发生联系”的结构记录,适合拿来和本命、分盘、大运一起交叉核对。', + '', + ]) + if planets: + out.extend([ + '| 行星 | A | B | C | D | 状态 |', + '|------|---|---|---|---|------|', + ]) + for name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = planets.get(name) if isinstance(planets.get(name), dict) else {} + significators = row.get('significators') if isinstance(row.get('significators'), dict) else {} + if not significators: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(name))} | {_kp_significator_cell(significators.get('A'))} | " + f"{_kp_significator_cell(significators.get('B'))} | {_kp_significator_cell(significators.get('C'))} | " + f"{_kp_significator_cell(significators.get('D'))} | parameter_sensitive |" + ) + out.append('') + if houses: + out.extend([ + '| 宫位 | A | B | C | D | 状态 |', + '|------|---|---|---|---|------|', + ]) + for house in range(1, 13): + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) if isinstance(houses.get(house), dict) else {} + significators = row.get('significators') if isinstance(row.get('significators'), dict) else {} + if not significators: + continue + out.append( + f"| {house} | {_kp_significator_cell(significators.get('A'))} | {_kp_significator_cell(significators.get('B'))} | " + f"{_kp_significator_cell(significators.get('C'))} | {_kp_significator_cell(significators.get('D'))} | parameter_sensitive |" + ) + out.append('') + if houses: + out.extend([ + '#### Significations of the Houses(本地 KP house schema)', + '', + '以下把当前本地 KP 宫位显著星按专门 KP 软件常见阅读顺序重排为 House -> Very Strong / Strong / Normal / Weak。' + '它仍然只消费本地 ABCD 结构,不替代外部 worked examples、PL9 P124 五列 house schema 或 timing truth;所有行保持 parameter_sensitive。', + '', + '| House | Very Strong | Strong | Normal | Weak | 状态 |', + '|-------|-------------|--------|--------|------|------|', + ]) + for house in range(1, 13): + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) if isinstance(houses.get(house), dict) else {} + significators = row.get('significators') if isinstance(row.get('significators'), dict) else {} + if not significators: + continue + out.append( + f"| {house} | {_kp_significator_cell(significators.get('A'))} | {_kp_significator_cell(significators.get('B'))} | " + f"{_kp_significator_cell(significators.get('C'))} | {_kp_significator_cell(significators.get('D'))} | parameter_sensitive |" + ) + out.append('') + if planets: + out.extend([ + '#### Houses Signified by Planets(本地 KP planet schema)', + '', + '以下把当前本地 KP 行星显著星按 Planet -> Very Strong / Strong / Normal / Weak 重排。' + '这更接近专门 KP 软件的阅读方式,但底层仍是本地 ABCD 结构映射,不升级为已闭环结论或 timing truth。', + '', + '| Planet | Very Strong | Strong | Normal | Weak | 状态 |', + '|--------|-------------|--------|--------|------|------|', + ]) + for name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = planets.get(name) if isinstance(planets.get(name), dict) else {} + significators = row.get('significators') if isinstance(row.get('significators'), dict) else {} + if not significators: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(name))} | {_kp_significator_cell(significators.get('A'))} | " + f"{_kp_significator_cell(significators.get('B'))} | {_kp_significator_cell(significators.get('C'))} | " + f"{_kp_significator_cell(significators.get('D'))} | parameter_sensitive |" + ) + out.append('') + if any(ruling_planets.get(key) not in (None, '') for key in ( + 'day_lord', + 'moon_sign_lord', + 'moon_star_lord', + 'moon_sub_lord', + 'ascendant_sign_lord', + 'ascendant_star_lord', + 'ascendant_sub_lord', + )): + out.extend([ + '#### KP Ruling Planets(出生时刻专项核对)', + '', + '以下保留出生时刻的 KP ruling planets,作为与宫头、显著星和大运并列的专项核对线索。' + '只作为 KP 专项核对线索,不单独生成事件判断或应期断语;外部 worked examples 与 timing parity 未闭环,因此整行保持 parameter_sensitive。', + '', + '| Day Lord | Moon Sign Lord | Moon Star Lord | Moon Sub Lord | Asc Sign Lord | Asc Star Lord | Asc Sub Lord | 状态 |', + '|----------|----------------|----------------|----------------|---------------|---------------|--------------|------|', + f"| {_md_cell(_humanize_reader_token(ruling_planets.get('day_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_sign_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_star_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_sub_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_sign_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_star_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_sub_lord')))} | " + f"{_md_cell(ruling_planets.get('status') or 'parameter_sensitive')} |", + '', + ]) + kp_ayanamsa_label = _ayanamsa_display_name(kp_profile.get('ayanamsa') or effective_settings.get('ayanamsa') or 'kp') + out.extend([ + '#### Ruling Planets(本地 KP profile)', + '', + '这一行把 KP ayanamsa、house system 与出生时刻 ruling planets 合并显示,形式更接近专门 KP 软件。' + '但它仍然只是本地 profile + ruling-planet evidence;外部 worked examples 与 timing parity 尚未闭环,因此整行保持 parameter_sensitive。', + '', + '| KP Ayanamsa | House System | Day Lord | Moon Sign Lord | Moon Star Lord | Moon Sub Lord | Asc Sign Lord | Asc Star Lord | Asc Sub Lord | 状态 |', + '|-------------|--------------|----------|----------------|----------------|----------------|---------------|---------------|--------------|------|', + f"| {_md_cell(kp_ayanamsa_label)} | {_md_cell(kp_profile.get('house_system') or effective_settings.get('house_system') or 'placidus')} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('day_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_sign_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_star_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('moon_sub_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_sign_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_star_lord')))} | " + f"{_md_cell(_humanize_reader_token(ruling_planets.get('ascendant_sub_lord')))} | " + f"{_md_cell(ruling_planets.get('status') or 'parameter_sensitive')} |", + '', + ]) + if houses: + def _kp_house_theme_text(house: int) -> str: + return '、'.join(_house_theme(house)) + + def _kp_house_judgement(significators: dict) -> str: + strong = significators.get('A') + medium = significators.get('B') + normal = significators.get('C') + if strong not in (None, '', [], (), set()): + return '宫头 promise 线索清晰,可作为该主题的专项核对入口' + if medium not in (None, '', [], (), set()) or normal not in (None, '', [], (), set()): + return '宫头已有局部牵连,但仍不足以单独支撑结论' + return '宫头牵连仍偏弱,当前更适合作为补充观察' + + judgement_rows = [] + for house in range(1, 13): + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) if isinstance(houses.get(house), dict) else {} + significators = row.get('significators') if isinstance(row.get('significators'), dict) else {} + if not significators: + continue + judgement_rows.append( + ( + house, + _longitude_text(row.get('cusp_longitude')) if row.get('cusp_longitude') is not None else '-', + _kp_house_theme_text(house), + _kp_significator_cell(significators.get('A')), + _kp_significator_cell(significators.get('B')), + _kp_significator_cell(significators.get('C')), + _kp_significator_cell(significators.get('D')), + _kp_house_judgement(significators), + ) + ) + if judgement_rows: + out.extend([ + '#### KP Cuspal Promise / House-level Judgement(专项层)', + '', + '以下只把宫头显著星结构整理成 house-level promise 线索,帮助你按主题理解每个宫头目前被哪些层级反复触发。' + '它仍然不是事件真值链,也不单独生成事件或应期判断;所有行继续保持 parameter_sensitive。', + '', + '| House | 宫头 | 主题 | Very Strong | Strong | Normal | Weak | House-level judgement | 状态 |', + '|-------|------|------|-------------|--------|--------|------|----------------------|------|', + ]) + for house, cusp, theme, a_cell, b_cell, c_cell, d_cell, judgement in judgement_rows: + out.append( + f"| {house} | {cusp} | {_md_cell(theme)} | {a_cell} | {b_cell} | {c_cell} | {d_cell} | {_md_cell(judgement)} | parameter_sensitive |" + ) + out.append('') + return out + + def _kp_domain_evidence_section(domain: str) -> list[str]: + kp_pack = advanced_sheet.get('kp') if isinstance(advanced_sheet.get('kp'), dict) else {} + houses = kp_pack.get('houses') if isinstance(kp_pack.get('houses'), dict) else {} + specs = { + 'career': ('事业', (2, 6, 10, 11)), + 'wealth': ('财务', (2, 5, 9, 11)), + 'relationship': ('关系', (2, 7, 11)), + } + domain_readers = { + 'career': '2宫看资源与收入,6宫看工作任务与压力,10宫看事业角色,11宫看结果兑现与回收。', + 'wealth': '2宫看资金与资源,5宫看判断与投机,9宫看机会与支持,11宫看回报兑现。', + 'relationship': '2宫看关系投入,7宫看伴侣/合作轴线,11宫看关系结果是否落地。', + } + label, house_numbers = specs.get(domain, ('专项', ())) + rows = [] + for house in house_numbers: + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) + significators = row.get('significators') if isinstance(row, dict) and isinstance(row.get('significators'), dict) else {} + if significators: + rows.append((house, significators)) + if not rows: + return [] + + def _cell(value) -> str: + if isinstance(value, (list, tuple, set)): + value = '、'.join(str(_humanize_reader_token(item)) for item in value if item not in (None, '')) + return _md_cell(_humanize_reader_token(value)) if value not in (None, '') else '-' + + suffix = ' / '.join(str(house) for house in house_numbers) + out = [ + f'#### KP {label}宫位核对({suffix} 宫)', + '', + '以下保留 KP 宫位的本地 A/B/C/D 显著星结构,作为与本命、分盘和大运并列的领域核对线索。' + '外部 KP 回放与应期 parity 尚未闭环,因此所有行保持 parameter_sensitive,仅作领域结构交叉核对,不单独生成事件或应期结论。' + '读这三张表时,更适合把它理解为“哪些宫位主题被反复碰到”,而不是单凭某一行就下结论。', + '', + domain_readers.get(domain, '该表仅作结构核对,不单独下结论。'), + '', + '| 宫位 | A | B | C | D | 状态 |', + '|------|---|---|---|---|------|', + ] + for house, significators in rows: + out.append( + f"| {house} | {_cell(significators.get('A'))} | {_cell(significators.get('B'))} | " + f"{_cell(significators.get('C'))} | {_cell(significators.get('D'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _kp_domain_promise_note(domain: str) -> str: + kp_pack = advanced_sheet.get('kp') if isinstance(advanced_sheet.get('kp'), dict) else {} + houses = kp_pack.get('houses') if isinstance(kp_pack.get('houses'), dict) else {} + specs = { + 'career': ('事业', (2, 6, 10, 11)), + 'wealth': ('财务', (2, 5, 9, 11)), + 'relationship': ('关系', (2, 7, 11)), + } + label, house_numbers = specs.get(domain, ('专项', ())) + strength_rows = [] + for house in house_numbers: + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else houses.get(house) + significators = row.get('significators') if isinstance(row, dict) and isinstance(row.get('significators'), dict) else {} + if not significators: + continue + has_a = significators.get('A') not in (None, '', [], (), set()) + has_b = significators.get('B') not in (None, '', [], (), set()) + has_c = significators.get('C') not in (None, '', [], (), set()) + score = (2 if has_a else 0) + (1 if has_b else 0) + (1 if has_c else 0) + strength_rows.append((house, score)) + if not strength_rows: + return '' + strong_houses = [str(house) for house, score in strength_rows if score >= 2] + fallback_houses = [str(house) for house, _score in strength_rows] + display_houses = strong_houses or fallback_houses + joined = ' / '.join(display_houses) + return ( + f"KP 宫头 promise note:{label}主题的宫头 promise 线索目前较清晰,{joined} 宫可作为专项核对入口。" + "这仍是 parameter_sensitive 的结构提示,不单独生成事件或应期判断。" + ) + + def _special_lagna_sign_text(*names: str) -> str | None: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + special_layer = divisional_sheet.get('special_lagnas_layer') if isinstance(divisional_sheet.get('special_lagnas_layer'), dict) else {} + legacy_special_lagnas = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {} + sources = [] + for name in names: + if not name: + continue + row = special_layer.get(name) if isinstance(special_layer.get(name), dict) else {} + if not row: + row = legacy_special_lagnas.get(name) if isinstance(legacy_special_lagnas.get(name), dict) else {} + if row: + sources.append(row) + if not sources: + return None + for row in sources: + sign = row.get('sign') + if sign not in (None, '', '-'): + return str(_humanize_reader_token(sign)) + return None + + def _special_lagna_detail_rows() -> list[tuple[str, str, str, str, str]]: + """Expose computed special-point data without turning it into an event claim.""" + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + special_layer = divisional_sheet.get('special_lagnas_layer') if isinstance(divisional_sheet.get('special_lagnas_layer'), dict) else {} + legacy_special_lagnas = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {} + point_specs = [ + ('Bhava Lagna', 'Bhava_Lagna', '本命生活场景的辅助坐标'), + ('Hora Lagna', 'Hora_Lagna', '现金流与资源调度的辅助坐标'), + ('Ghati Lagna', 'Ghati_Lagna', '行动能力与社会角色的辅助坐标'), + ('ViGhati Lagna', 'ViGhati_Lagna', '行动节奏的辅助坐标'), + ('Sree Lagna', 'Sree_Lagna', '资源承接的辅助坐标'), + ('Indu Lagna', 'Indu_Lagna', '原始点位可见,但尚未接入稳定财富 producer'), + ('Arudha Lagna', 'Arudha_Lagna', '公众形象与社会投射的辅助坐标'), + ('Upapada Lagna', 'Upapada_Lagna', '关系制度与社会表现的辅助坐标'), + ('A10 / Karma Pada', 'A10_Karma_Pada', '职业可见度与结果层的辅助坐标'), + ] + rows: list[tuple[str, str, str, str, str]] = [] + for label, key, role in point_specs: + point = special_layer.get(key) if isinstance(special_layer.get(key), dict) else {} + if not point: + point = legacy_special_lagnas.get(key) if isinstance(legacy_special_lagnas.get(key), dict) else {} + if not point: + continue + sign = _humanize_reader_token(point.get('sign')) + degree = point.get('sign_degree', point.get('degree_in_sign')) + if degree is None and isinstance(point.get('degree'), (int, float)): + degree = float(point['degree']) % 30 + placement = ' '.join(part for part in (str(sign) if sign not in (None, '', '-') else '-', _degree_text(degree)) if part and part != '-') + house = point.get('house') + house_text = f"{house}宫" if isinstance(house, int) else '-' + source = point.get('source') or point.get('method') or point.get('formula') or 'local_calculation' + rows.append((label, placement or '-', house_text, str(source), role)) + return rows + + def _professional_strength_rows() -> list[tuple[str, str, str, str]]: + """Compress existing strength evidence for reader review without new scoring.""" + rows: list[tuple[str, str, str, str]] = [] + functional = strength_sheet.get('functional_benefic_malefic') if isinstance(strength_sheet.get('functional_benefic_malefic'), dict) else {} + if functional.get('status') == 'used': + benefics = _planet_list_text(functional.get('functional_benefics') if isinstance(functional.get('functional_benefics'), list) else [], limit=7) or '-' + malefics = _planet_list_text(functional.get('functional_malefics') if isinstance(functional.get('functional_malefics'), list) else [], limit=7) or '-' + yogakarakas = _planet_list_text(functional.get('yogakarakas') if isinstance(functional.get('yogakarakas'), list) else [], limit=4) or '-' + rows.append(('功能性吉凶', f"吉星:{benefics};凶星:{malefics};Yogakaraka:{yogakarakas}", 'strict_functional_benefic_malefic_v1', '宫主功能属性必须与自然属性、分盘和大运交叉,不单独断事。')) + + shadbala = strength_sheet.get('shadbala') if isinstance(strength_sheet.get('shadbala'), dict) else {} + if shadbala: + strongest = _humanize_reader_token(shadbala.get('strongest')) or '-' + weakest = _humanize_reader_token(shadbala.get('weakest')) or '-' + rows.append(('Shadbala 总览', f"最强:{strongest};较弱:{weakest};本地状态:{shadbala.get('status') or '-'}", 'strengths_and_scores.shadbala', '展示本地总量与排序;跨引擎绝对排名与争议分量继续见原始表。')) + + bhava_bala = strength_sheet.get('bhava_bala') if isinstance(strength_sheet.get('bhava_bala'), dict) else {} + houses = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] + focus = [] + for house_no in (2, 7, 10, 11): + row = next((item for item in houses if isinstance(item, dict) and item.get('house') == house_no), None) + if not row: + continue + score = row.get('score') + strength = row.get('strength') or '-' + focus.append(f"{house_no}宫={score}({strength})") + if focus: + rows.append(('Bhava Bala 主题宫位', ';'.join(focus), 'simplified_bhava_bala_lord_occupant_aspect', '仅为本地三分量相对分;不是外部同口径 Rupas 表。')) + + ashtakavarga = strength_sheet.get('ashtakavarga') if isinstance(strength_sheet.get('ashtakavarga'), dict) else {} + assessment = (ashtakavarga.get('sav') or {}).get('assessment') if isinstance(ashtakavarga.get('sav'), dict) else [] + ranked = sorted( + (item for item in assessment if isinstance(item, dict) and isinstance(item.get('score'), (int, float))), + key=lambda item: float(item['score']), + reverse=True, + ) + if ranked: + high = '、'.join(f"{_humanize_reader_token(item.get('sign'))} {item.get('score')}" for item in ranked[:2]) + low = '、'.join(f"{_humanize_reader_token(item.get('sign'))} {item.get('score')}" for item in ranked[-2:]) + rows.append(('SAV 承接分布', f"高点:{high};低点:{low}", 'strengths_and_scores.ashtakavarga.sav', '用于核对宫位/星座承接差异,不单独替代领域判断。')) + + vimsopaka = strength_sheet.get('vimsopaka') if isinstance(strength_sheet.get('vimsopaka'), dict) else {} + ranked_vimsopaka = sorted( + ( + (float(row.get('total_score')), _humanize_reader_token(planet), row.get('category')) + for planet, row in vimsopaka.items() + if isinstance(row, dict) and isinstance(row.get('total_score'), (int, float)) + ), + reverse=True, + ) + if ranked_vimsopaka: + top = '、'.join(f"{planet} {score:.2f}/20({category or '-'})" for score, planet, category in ranked_vimsopaka[:3]) + rows.append(('Vimsopaka 分盘强弱', top, 'strengths_and_scores.vimsopaka', '十六分盘权重摘要;具体分盘尊贵度保留在原始矩阵。')) + return rows + + def _kp_special_lagna_note(domain: str) -> str | None: + if domain == 'career': + a10_sign = _special_lagna_sign_text('A10_Karma_Pada', 'A10', 'Karma_Pada') + if a10_sign: + return f"A10 在{a10_sign},事业结果层更适合连同社会角色与外部可见度一起读。" + return "A10 当前未取得稳定落点,但事业结果层仍建议连同社会角色与外部可见度一起读。" + if domain == 'wealth': + hora_sign = _special_lagna_sign_text('Hora_Lagna', 'HL') + sree_sign = _special_lagna_sign_text('Sree_Lagna', 'SL') + if hora_sign and sree_sign: + return f"Hora Lagna 在{hora_sign}、Sree Lagna 在{sree_sign},财务阅读更适合把现金流与资源承接分开看。" + if hora_sign: + return f"Hora Lagna 在{hora_sign},财务阅读更适合先看现金流与资源调度。" + return "Hora Lagna 在待确认位置、Sree Lagna 在待确认位置,但财务阅读仍建议把现金流与资源承接分开看。" + if domain == 'relationship': + ul_sign = _special_lagna_sign_text('Upapada_Lagna', 'UL', 'Upapada Lagna') + pp_sign = _special_lagna_sign_text('PP', 'Pranapada_Lagna', 'Pranapada Lagna') + if not ul_sign: + ul_sign = _humanize_reader_token(((jaimini.get('arudha_padas') or {}).get('upapada') or {}).get('sign')) + if not pp_sign: + pp_sign = _humanize_reader_token(((jaimini.get('special_lagnas') or {}).get('PP') or {}).get('sign')) + if ul_sign and pp_sign: + return f"UL 在{ul_sign}、Pranapada 在{pp_sign},关系主题可把制度承诺层与气质投射层分开看。" + if ul_sign: + return f"UL 在{ul_sign},关系主题更适合先看制度承诺与现实维持方式。" + return "UL 在待确认位置、Pranapada 在待确认位置,但关系主题仍建议把制度承诺层与气质投射层分开看。" + return None + + def _kp_house_focus_text(value) -> str: + house_label_map = { + 2: '收入与资源', + 5: '机会与投入', + 6: '职责与压力', + 7: '合作与伴侣', + 9: '运气与扩张', + 10: '事业与位置', + 11: '回报与支持', + } + parts = [] + if isinstance(value, (list, tuple)): + raw_parts = [str(item).strip() for item in value if str(item).strip()] + else: + raw_parts = [part.strip() for part in str(value or '').replace('/', ',').split(',') if part.strip()] + for part in raw_parts: + try: + house_no = int(part) + except ValueError: + parts.append(part) + continue + parts.append(f"{house_no}宫[{house_label_map.get(house_no, '-')}]") + return '、'.join(parts) if parts else '-' + + def _graha_padas_domain_bridge(domain: str) -> str | None: + graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {} + graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {} + graha_targets = { + 'career': [('Sun', '太阳'), ('Mercury', '水星')], + 'wealth': [('Jupiter', '木星'), ('Venus', '金星')], + 'relationship': [('Venus', '金星'), ('Jupiter', '木星')], + } + bits = [] + for key, label in graha_targets.get(domain, []): + row = graha_pada_rows.get(key) if isinstance(graha_pada_rows.get(key), dict) else {} + sign = _humanize_reader_token(row.get('graha_pada_sign') or row.get('sign')) if row else None + if sign not in (None, '', '-'): + bits.append(f"{label}→{sign}") + if not bits: + return None + tail_map = { + 'career': '这层更像把角色输出方式与外部舞台的落点再压缩成一条辅助线', + 'wealth': '这层更像把资源入口与收益承接方式再压缩成一条辅助线', + 'relationship': '这层更像把关系表达方式与联结重心再压缩成一条辅助线', + } + return f"Graha Padas 补充:{';'.join(bits)};{tail_map.get(domain, '这层当前仍只作辅助阅读。')}。当前仍保留 unclosed_divisional_chart。" + + def _blocked_structure_domain_bridge(domain: str) -> str | None: + if domain != 'wealth': + return None + indu_sign = _special_lagna_sign_text('Indu_Lagna', 'Indu Lagna') + if indu_sign in (None, '', '-'): + return None + return ( + f"边界补充:Indu Lagna 当前落在{indu_sign};这可以先当成财富承接与资源体感的候选结构坐标," + "但当前个人报告链尚未把它接成稳定 producer,因此继续保持 blocked,不升级为财富主结论。" + ) + + def _kp_domain_summary_block(domain: str) -> list[str]: + kp_pack = advanced_sheet.get('kp') if isinstance(advanced_sheet.get('kp'), dict) else {} + ruling_planets = kp_pack.get('ruling_planets') if isinstance(kp_pack.get('ruling_planets'), dict) else {} + note = _kp_domain_promise_note(domain) + if not note: + return [] + specs = { + 'career': ('事业', (2, 6, 10, 11)), + 'wealth': ('财务', (2, 5, 9, 11)), + 'relationship': ('关系', (2, 7, 11)), + } + domain_label, house_numbers = specs.get(domain, ('专项', ())) + rp_bits = [ + _humanize_reader_token(ruling_planets.get('day_lord')), + _humanize_reader_token(ruling_planets.get('moon_sign_lord')), + _humanize_reader_token(ruling_planets.get('moon_star_lord')), + ] + rp_text = ' / '.join(str(bit) for bit in rp_bits if bit not in (None, '', '-')) + if not rp_text: + rp_text = '-' + cleaned_note = note.replace('KP 宫头 promise note:', '').replace('这仍是 parameter_sensitive 的结构提示,不单独生成事件或应期判断。', '').strip() + special_lagna_note = _kp_special_lagna_note(domain) or '-' + graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {} + graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {} + graha_targets = { + 'career': [('Sun', '太阳'), ('Mercury', '水星')], + 'wealth': [('Jupiter', '木星'), ('Venus', '金星')], + 'relationship': [('Venus', '金星'), ('Jupiter', '木星')], + } + graha_bits = [] + for key, label in graha_targets.get(domain, []): + row = graha_pada_rows.get(key) if isinstance(graha_pada_rows.get(key), dict) else {} + sign = _humanize_reader_token(row.get('graha_pada_sign') or row.get('sign')) if row else None + if sign not in (None, '', '-'): + graha_bits.append(f"{label}→{sign}") + tajika_rows = tajika_candidates if isinstance(tajika_candidates, list) else [] + preferred_motion = 'separating' if domain == 'relationship' else 'applying' + annual_bits = [] + for row in tajika_rows: + if not isinstance(row, dict): + continue + if row.get('motion') != preferred_motion and annual_bits: + continue + candidate_label = _humanize_tajika_candidate(str(row.get('name') or 'candidate')) + planets = row.get('planets') if isinstance(row.get('planets'), list) else [] + p1 = _humanize_reader_token(planets[0]) if len(planets) > 0 else _humanize_reader_token(row.get('planet1')) + p2 = _humanize_reader_token(planets[1]) if len(planets) > 1 else _humanize_reader_token(row.get('planet2')) + motion = '趋近' if row.get('motion') == 'applying' else '分离' + pair = ' / '.join(bit for bit in [p1, p2] if bit not in (None, '', '-')) + display_label = candidate_label if candidate_label not in {'趋近相位候选', '分离相位候选'} else f"{motion}相位" + annual_bits.append(f"{display_label}{f'({pair})' if pair else ''}") + if len(annual_bits) >= 1: + break + intro_map = { + 'career': '就事业这条线再用 KP 收口,主轴先回到', + 'wealth': '就财务这条线再用 KP 收口,主轴先回到', + 'relationship': '就关系这条线再用 KP 收口,主轴先回到', + } + prose_parts = [f"{intro_map.get(domain, '这条主题线的 KP 结构先把主轴收拢到')}{cleaned_note}"] + if special_lagna_note != '-': + prose_parts.append(special_lagna_note) + if graha_bits: + prose_parts.append("Graha Padas 提醒:" + ";".join(graha_bits) + "。") + if annual_bits: + prose_parts.append("年度 Tajika 提醒:" + ";".join(annual_bits) + "。") + prose_parts.append("这仍是 parameter_sensitive 的结构收口,不单独生成事件或应期判断。") + return [ + f'#### KP {domain_label}结构小表', + '', + ' '.join(part.strip() for part in prose_parts if part), + '', + '| Ruling Planets | Promise 焦点 | 辅助锚点 | 阅读方式 | 状态 |', + '|----------------|--------------|----------|----------|------|', + f"| {_md_cell(rp_text)} | {_md_cell(' / '.join(str(house) for house in house_numbers))} | {_md_cell(special_lagna_note)} | {_md_cell(cleaned_note)} | parameter_sensitive |", + '', + ] + + def _kp_three_year_monthly_section() -> list[str]: + monthly_pack = advanced_sheet.get('kp_monthly_report') if isinstance(advanced_sheet.get('kp_monthly_report'), dict) else {} + three_year_pack = timing_sheet.get('three_year_predictive_ephemeris_pack') if isinstance(timing_sheet.get('three_year_predictive_ephemeris_pack'), dict) else {} + months = monthly_pack.get('months') if isinstance(monthly_pack.get('months'), list) else [] + yearly_highlights = monthly_pack.get('yearly_highlights') if isinstance(monthly_pack.get('yearly_highlights'), list) else [] + if not months: + return [] + month_map = { + str(row.get('month')): row + for row in months + if isinstance(row, dict) and row.get('month') + } + + def _kp_anchor_rows() -> list[tuple[str, str, str, str, str]]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + sensitive_points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} + bhrigu = sensitive_points.get('bhrigu_bindu') if isinstance(sensitive_points.get('bhrigu_bindu'), dict) else {} + rows: list[tuple[str, str, str, str, str]] = [] + a10_sign = _special_lagna_sign_text('A10_Karma_Pada', 'A10', 'Karma_Pada') + rows.append(('A10 / Karma Pada', a10_sign or '-', '事业', 'parameter_sensitive' if a10_sign else 'blocked', '职业结果层与社会可见度的辅助锚点,适合与 10/11 宫和 D10 并读。当前样例若缺稳定落点,则只保留边界。')) + wealth_bits = [bit for bit in [_special_lagna_sign_text('Hora_Lagna', 'HL'), _special_lagna_sign_text('Sree_Lagna', 'SL')] if bit] + rows.append(('Hora Lagna / Sree Lagna', ' / '.join(wealth_bits) if wealth_bits else '-', '财务', 'parameter_sensitive' if wealth_bits else 'blocked', '用于把现金流、资源承接与财富结构分开看,不单独生成财务事件断语。当前样例若缺稳定落点,则只保留边界。')) + relationship_bits = [bit for bit in [_special_lagna_sign_text('Upapada_Lagna', 'UL', 'Upapada Lagna'), _special_lagna_sign_text('PP', 'Pranapada_Lagna', 'Pranapada Lagna')] if bit] + rows.append(('UL / Pranapada', ' / '.join(relationship_bits) if relationship_bits else '-', '关系', 'parameter_sensitive' if relationship_bits else 'blocked', '用于分开看制度承诺、关系形象与气质投射层,不升级为具体事件预测。当前样例若缺稳定落点,则只保留边界。')) + rows.append(('Indu Lagna', _special_lagna_sign_text('Indu_Lagna', 'Indu Lagna') or '-', '财务', 'blocked', '当前个人报告链尚未把 Indu Lagna 接成稳定 producer,因此只保留边界,不升级财富承接判断。')) + rows.append(('Bhrigu Bindu', _humanize_reader_token(bhrigu.get('sign')) if bhrigu.get('sign') not in (None, '', '-') else '-', '敏感点', 'blocked', '当前仍缺稳定 runtime producer 与 worked example parity,不把敏感点触发写成 timing truth。')) + return rows + + def _kp_not_yet_downstream_rows() -> list[tuple[str, str, str, str]]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} + rows: list[tuple[str, str, str, str]] = [ + ('Narayana Dasha domain bridge', '主题正文第二时间轴句 + timing evidence / dual-dasha bridge', 'parameter_sensitive', '仓内已有 Narayana timing evidence,且已能按事业/财务/关系改写成第二时间轴阅读句;但 dual-dasha 差异仍未升级成稳定主题主结论合同。'), + ('Shadbala / Bhava Bala readable bridge', '主题正文强弱桥接句 + 力量页 / 附录层', 'parameter_sensitive', '力量层补充已经能把承接位与压力位压成主题阅读顺序,但更多仍停在技术页与附录,尚未升级成稳定的主题主结论合同。'), + ('Sahams thematic bridge', '主题正文轻量补充句 + 年度附加点 / Tajika 补充层', 'partial_verified', 'Sahams 已进入事业/财务/关系三章的补充短句,但仍未升级为独立稳定专题或 timing truth。'), + ('Jaimini special-points bridge', '主题正文第二结构坐标句 + Jaimini 特殊点附录', 'unclosed_divisional_chart', 'Jaimini 特殊点与 Graha Padas 已部分借入正文,并开始承担主题判断的第二结构坐标;但仍未形成独立稳定主题合同。'), + ('Annual highlights bridge', '主题正文年度专题支撑句 + annual_tajika_pack.report_sections.thematic_narrative.highlights', 'parameter_sensitive', '年度专题 highlights 已压进三章正文,并开始承担年度专题支撑句,但还没升级成独立稳定专题合同。'), + ('Field briefs bridge', '主题正文年度专题支撑句 + annual_tajika_pack.report_sections.evidence_appendix.field_briefs', 'parameter_sensitive', '年度 field briefs 已下沉进三章正文,并开始承担年度附加点/候选结构支撑句,但更多仍停在专题补充层,尚未完整进入主题主结论。'), + ('Patyayini readable bridge', '主题正文补充时间锚点句 + annual external replay / appendix layer', 'pyjhora_behavior_only / not_multiengine_parity', 'Patyayini 已有外部 replay、可读附录,也开始进入三章正文的补充时间锚点句;但尚未成为本地稳定 producer,也不能写成正文 timing truth。'), + ('Graha Padas readable bridge', '主题轻量补充句 + KP 主题摘要块', 'unclosed_divisional_chart', 'Graha Padas 已进入主题轻量补充句与 KP 主题摘要块,但仍未形成独立稳定正文合同。'), + ('Annual Tajika candidate bridge', 'annual candidate structure / appendix layer', 'parameter_sensitive', 'Tajika 候选交互已进入 KP 主题摘要块,但仍停在 candidate 结构层,不能越权写成年度已成立事件。'), + ('Prashna query-time layer', '问时缩窄入口 / KP 支边界层', 'blocked', '缺 question_time / place / timezone / question category,因此仍停在合同层,不能进入正文主结论。'), + ('Exact cusp oracle', 'KP 支边界层', 'blocked', '外部 numeric oracle 与 negative holdout 还没闭环,所以不能把 cusp-level timing 提升成正文结论。'), + ('Tajika named-yoga bridge', '年度候选结构 / audit layer', 'blocked', '命名 Tajika Yogas 仍停在 candidate / blocked 审计层,当前不能把它们写成正文里已成立的年度结论。'), + ] + if isinstance(points.get('navamsa_64th'), dict) and points.get('navamsa_64th', {}).get('sign'): + rows.append(('64th Navamsa', '敏感派生点附录', 'parameter_sensitive', '仓内已有原始位置,但仍主要停在敏感点补充层,尚未稳定并入正文主题判断。')) + if isinstance(points.get('drekkana_22nd'), dict) and points.get('drekkana_22nd', {}).get('sign'): + rows.append(('22nd Drekkana', '敏感派生点附录', 'parameter_sensitive', '已有本地派生点结果,但还没有稳定的主题级叙述合同,所以未进正文主结论。')) + if _special_lagna_sign_text('Indu_Lagna', 'Indu Lagna'): + rows.append(('Indu Lagna', 'KP 辅助点与结构锚点', 'blocked', '虽然仓内能看到落点,但当前个人报告链尚未把它升成稳定 producer,因此只保留边界提示。')) + if _humanize_reader_token((points.get('bhrigu_bindu') or {}).get('sign')) not in (None, '', '-'): + rows.append(('Bhrigu Bindu', '敏感点轻量补充句 / KP 锚点', 'blocked', '原始点位已进入轻量补充句,但 worked example parity 与 runtime producer 还没闭环,所以仍不进入正文主结论。')) + return rows + + pack_profile = three_year_pack.get('profile') if isinstance(three_year_pack.get('profile'), dict) else {} + source_status = three_year_pack.get('source_status') if isinstance(three_year_pack.get('source_status'), dict) else {} + reader_source_labels = { + 'monthly_transits': '三年逐月慢行星表', + 'vimshottari_five_levels': '五层 Vimshottari 月锚点', + 'kp_monthly_report': 'KP 月度主题支持表', + 'dasha_master_pack': 'Dasha master 总线', + 'narayana_dasha': 'Narayana 第二时间轴', + 'annual_tajika_pack': 'Annual Tajika / Varshaphala', + 'transit_multi_reference': '四参考点 Transit', + } + out = [ + '#### 三年流月星历与推运索引(本地扩展)', + '', + f"本地导出已覆盖 {pack_profile.get('start_month') or months[0].get('month')} 到 {pack_profile.get('end_month') or months[-1].get('month')},共 {pack_profile.get('month_count') or len(months)} 个月。它把逐月慢行星星历、五层 Vimshottari、KP 主题支持、Narayana/Dasha 证据路径与年度 Tajika 上下文放进同一个可审计 pack;状态为 {three_year_pack.get('status') or 'parameter_sensitive'},不声称 PL9 图形星历数值 parity 或具体事件必然发生。", + '', + '| 数据层 | 来源路径 | 状态 |', + '|--------|----------|------|', + ] + for key, label in [ + ('monthly_transits', '逐月慢行星星历'), + ('vimshottari_five_levels', '五层 Vimshottari 月锚点'), + ('kp_monthly_report', 'KP 三年流月主题支持'), + ('dasha_master_pack', 'Dasha master 总线'), + ('narayana_dasha', 'Narayana 第二时间轴'), + ('annual_tajika_pack', 'Annual Tajika / Varshaphala'), + ('transit_multi_reference', '四参考点 Transit'), + ]: + item = source_status.get(key) if isinstance(source_status.get(key), dict) else {} + out.append(f"| {_md_cell(label)} | {_md_cell(reader_source_labels.get(key) or label)} | {_md_cell(item.get('status') or 'blocked')} |") + out.extend([ + '', + '#### 图形星历阅读抓手', + '', + '| 图形星历层 | 当前锚点 | 时间/位置 | 状态 |', + '|------------|----------|-----------|------|', + '| 年度月段 | Annual Tajika monthly_windows + KP yearly_highlights | 先看年度月段,再看木星 / 土星 / 交点换座 | parameter_sensitive |', + '| 慢行星星历 | KP monthly_transits | 每月 1 日本地正午,记录木星 / 土星 / Rahu / Ketu 星座、度数和逆行 | parameter_sensitive |', + '| Dasha Sandhi 临界日期 | dasha_master_pack + dasha_sandhi | 只作前后月观察锚点,不升级为事件必然 | parameter_sensitive |', + '| PL9 图形页 parity | pl9.pdf graphical ephemeris | 当前未完成图形页逐点数值闭合 | blocked |', + '', + '#### KP 三年流月支持', + '', + '这一节把本命 KP 宫头 promise、月锚点五层 Vimshottari 与当月真实行运压进同一张三年表里,方便你按月份顺序阅读事业、财务和关系主题。它仍然只是 parameter_sensitive 的支持层,不升级为精确事件预测,也不替代 Prashna 问时判断。', + '', + ]) + for yearly in yearly_highlights: + year = yearly.get('year') + rows = yearly.get('months') if isinstance(yearly.get('months'), list) else [] + if not year or not rows: + continue + focus_months = [str(row.get('month')) for row in rows if row.get('month')] + domain_seen = [] + house_seen = [] + for month_key in focus_months[:3]: + source_row = month_map.get(month_key) if isinstance(month_map.get(month_key), dict) else {} + support = source_row.get('theme_support') if isinstance(source_row.get('theme_support'), dict) else {} + for domain_key, label in [('career', '事业'), ('wealth', '财务'), ('relationship', '关系')]: + item = support.get(domain_key) if isinstance(support.get(domain_key), dict) else {} + if item: + if label not in domain_seen: + domain_seen.append(label) + promise_code = str(item.get('promise_code') or '').strip() + if promise_code and promise_code != '-' and promise_code not in house_seen: + house_seen.append(promise_code) + out.extend([f'#### {year} 年重点月份索引', '', '这些重点月份只表示更值得优先阅读的结构触发,不代表具体事件一定发生,状态仍保持 parameter_sensitive。', '']) + if focus_months: + domain_text = '/'.join(domain_seen) if domain_seen else '事业/财务/关系' + house_text = _kp_house_focus_text(house_seen[:2]) if house_seen else '-' + out.append(f"年度摘要:优先看 {'、'.join(focus_months[:3])};焦点宫位:{house_text};主轴:{domain_text} 三条线交叉核对。") + out.append('') + out.extend(['| 月份 | 为什么优先看 | 状态 |', '|------|--------------|------|']) + for row in rows: + out.append(f"| {_md_cell(row.get('month'))} | {_md_cell(row.get('reason'))} | parameter_sensitive |") + out.append('') + kp_support_topic = support_topics.get('kp') if isinstance(support_topics, dict) else {} + kp_snapshot = source_pack_contracts.get('kp_support_snapshot') if isinstance(source_pack_contracts.get('kp_support_snapshot'), dict) else {} + kp_must_not_claim = kp_support_topic.get('must_not_claim') if isinstance(kp_support_topic.get('must_not_claim'), list) else [] + if not kp_must_not_claim: + kp_must_not_claim = kp_snapshot.get('must_not_claim') if isinstance(kp_snapshot.get('must_not_claim'), list) else [] + kp_claim_text = '、'.join(str(item) for item in kp_must_not_claim if item) or 'exact_event_timing' + kp_status_text = ((kp_support_topic.get('status') if isinstance(kp_support_topic, dict) else None) or 'parameter_sensitive') + out.extend(['#### KP 使用边界', '', '| KP 支持层 | 当前状态 | 不直接声称 |', '|------------|----------|------------|', f"| {_md_cell((kp_support_topic.get('support_level') if isinstance(kp_support_topic, dict) else None) or kp_snapshot.get('support_level') or 'labeled_support_only')} | {_md_cell(kp_status_text)} | {_md_cell(kp_claim_text)} |", '', '#### KP 专业层状态', '', '| Layer | 当前状态 | 说明 |', '|-------|----------|------|', '| 五层大运(月锚点) | parameter_sensitive | 已进入三年逐月表,包含 MD/AD/PD/Sookshma/Prana,但不升级为事件真值。 |', '| ABCD 宫头/行星显著星 | parameter_sensitive | 已显示本地 ABCD 结构,用于 house/planet support 交叉阅读。 |', '| Ruling Planets(出生时刻) | parameter_sensitive | 已进入本地 KP profile 与专项核对层,但 worked example parity 未闭环。 |', '| 月运 × 宫头交叉触发 | parameter_sensitive | 已显示逐月慢行星、五层大运与宫头 ABCD 压缩快照。 |', '| Prashna 问时缩窄 | blocked | 本报告未提供 question time / place,因此不启用问时 ruling planets 窄化。 |', '| Exact cusp oracle | blocked | 当前仍缺外部 numeric oracle 与 negative holdout,不升级 timing truth。 |', '| Pranapada / Special Lagna | parameter_sensitive | 仓内可算并已在补充层展示,但当前仍按辅助结构层处理。 |', '| Indu Lagna | blocked | 当前个人报告链尚未把 Indu Lagna 接成稳定 producer,因此不把财富承接判断升级为已闭环。 |', '| Bhrigu Bindu | blocked | 当前仓内仍缺稳定 runtime producer 与 worked example parity,不把敏感点触发写成事件判断。 |', '', '#### KP 问时缩窄入口', '', '| 缩窄层 | 当前状态 | 需要补充 | 说明 |', '|--------|----------|------------|------|', '| Prashna input contract | blocked | question_time / lat / lon / timezone / ayanamsa / node_mode | 当前个人报告缺少问事当刻合同输入;本地 prashna contract_status 只能保持 partial_missing_question_time_or_place。 |', '| 问事当刻 ruling planets | blocked | question_time / lat / lon / timezone | 当前个人报告只有出生时刻 chart,没有问事当刻上下文,因此不启用 Prashna ruling planets 缩窄。 |', '| Cuspal sub-lord + house significators | blocked | question_time chart + question category | 这层应服务 yes/no 与日期收敛,不在本命个人报告里冒充已闭环 timing truth。 |', '', '#### KP 辅助点与结构锚点', '', '这些锚点服务主题阅读,不单独充当 timing truth;其中 blocked 行继续只保留边界提示。', '', '| 锚点 | 当前落点 | 主题 | 当前状态 | 说明 |', '|------|----------|------|----------|------|']) + for label, sign_text, domain_label, status_text, note in _kp_anchor_rows(): + out.append(f"| {_md_cell(label)} | {_md_cell(sign_text)} | {_md_cell(domain_label)} | {_md_cell(status_text)} | {_md_cell(note)} |") + out.append('') + out.extend(['#### 仓内高价值未完全下沉层', '', '这些层说明仓内已经有 producer、原始点位或边界合同;其中一部分已进入轻量正文或 KP 摘要块,但还没有稳定升级成主题主结论,另一部分仍停在附录 / replay / audit 层,因此继续显式保留当前位置与限制。', '', '| 专业层 | 当前位置 | 当前状态 | 为什么还没进正文主结论 |', '|--------|----------|----------|------------------------|']) + for label, location, status_text, note in _kp_not_yet_downstream_rows(): + out.append(f"| {_md_cell(label)} | {_md_cell(location)} | {_md_cell(status_text)} | {_md_cell(note)} |") + out.append('') + year_buckets = {} + for row in months: + if not isinstance(row, dict): + continue + month_text = row.get('month') + if not month_text or '-' not in str(month_text): + continue + year_buckets.setdefault(str(month_text)[:4], []).append(row) + for year, rows in sorted(year_buckets.items()): + out.extend([f'### {year} KP 月度支持', '', '| 月份 | MD / AD / PD | Sookshma / Prana | Ruling Planets | 宫头触发 | 慢行星提示 | 事业 | 财务 | 关系 | 状态 |', '|------|--------------|------------------|----------------|----------|------------|------|------|------|------|']) + for row in rows: + levels = row.get('vimshottari_five_levels') if isinstance(row.get('vimshottari_five_levels'), dict) else {} + level_rows = levels.get('levels') if isinstance(levels.get('levels'), dict) else {} + md = level_rows.get('mahadasha') if isinstance(level_rows.get('mahadasha'), dict) else {} + ad = level_rows.get('antardasha') if isinstance(level_rows.get('antardasha'), dict) else {} + pd = level_rows.get('pratyantardasha') if isinstance(level_rows.get('pratyantardasha'), dict) else {} + sookshma = level_rows.get('sookshma') if isinstance(level_rows.get('sookshma'), dict) else {} + prana = level_rows.get('prana') if isinstance(level_rows.get('prana'), dict) else {} + support = row.get('theme_support') if isinstance(row.get('theme_support'), dict) else {} + slow_planet_signals = row.get('slow_planet_signals') if isinstance(row.get('slow_planet_signals'), list) else [] + slow_planet_profile = row.get('slow_planet_profile') + career = support.get('career') if isinstance(support.get('career'), dict) else {} + wealth = support.get('wealth') if isinstance(support.get('wealth'), dict) else {} + relationship = support.get('relationship') if isinstance(support.get('relationship'), dict) else {} + dasha_triplet = f"{_humanize_reader_token(md.get('lord'))} / {_humanize_reader_token(ad.get('lord'))} / {_humanize_reader_token(pd.get('lord'))}" + micro_pair = f"{_humanize_reader_token(sookshma.get('lord'))} / {_humanize_reader_token(prana.get('lord'))}" + rp_text = career.get('ruling_planet_snapshot') or wealth.get('ruling_planet_snapshot') or relationship.get('ruling_planet_snapshot') or '-' + house_trigger = (career.get('house_trigger_snapshot') or wealth.get('house_trigger_snapshot') or relationship.get('house_trigger_snapshot') or career.get('promise_code') or wealth.get('promise_code') or relationship.get('promise_code') or '-') + house_trigger_text = _kp_house_focus_text(house_trigger) + compact_signals = [str(item) for item in slow_planet_signals if item and '逆行强调' not in str(item)] + slow_signal_text = compact_signals[0] if compact_signals else '' + if slow_planet_profile and slow_planet_profile != '-': + slow_signal_text = f"{slow_planet_profile};{slow_signal_text}" if slow_signal_text else str(slow_planet_profile) + elif not slow_signal_text: + slow_signal_text = '-' + out.append(f"| {_md_cell(row.get('month'))} | {_md_cell(dasha_triplet)} | {_md_cell(micro_pair)} | {_md_cell(rp_text)} | {_md_cell(house_trigger_text)} | {_md_cell(slow_signal_text)} | {_md_cell(career.get('brief_summary'))} | {_md_cell(wealth.get('brief_summary'))} | {_md_cell(relationship.get('brief_summary'))} | parameter_sensitive |") + out.append('') + return out + + def _kp_monthly_theme_brief(domain: str) -> list[str]: + monthly_pack = advanced_sheet.get('kp_monthly_report') if isinstance(advanced_sheet.get('kp_monthly_report'), dict) else {} + months = monthly_pack.get('months') if isinstance(monthly_pack.get('months'), list) else [] + if not months: + return [] + label_map = {'career': '事业', 'wealth': '财务', 'relationship': '关系'} + label = label_map.get(domain, domain) + selected_rows = [] + for row in months[:3]: + if not isinstance(row, dict): + continue + support = row.get('theme_support') if isinstance(row.get('theme_support'), dict) else {} + detail = support.get(domain) if isinstance(support.get(domain), dict) else {} + levels = row.get('vimshottari_five_levels') if isinstance(row.get('vimshottari_five_levels'), dict) else {} + level_rows = levels.get('levels') if isinstance(levels.get('levels'), dict) else {} + md = level_rows.get('mahadasha') if isinstance(level_rows.get('mahadasha'), dict) else {} + ad = level_rows.get('antardasha') if isinstance(level_rows.get('antardasha'), dict) else {} + focus_code = detail.get('active_houses') or detail.get('houses') or detail.get('promise_code') + selected_rows.append({'month': row.get('month'), 'dasha_pair': f"{_humanize_reader_token(md.get('lord'))} / {_humanize_reader_token(ad.get('lord'))}", 'summary': detail.get('brief_summary') or detail.get('summary') or '-', 'focus': _kp_house_focus_text(focus_code)}) + if not selected_rows: + return [] + month_names = '、'.join(str(row.get('month')) for row in selected_rows if row.get('month')) + dasha_brief = _dasha_domain_brief(domain) + focus_values = [] + for row in selected_rows: + focus = row.get('focus') + if focus and focus != '-' and focus not in focus_values: + focus_values.append(focus) + focus_text = '、'.join(focus_values) + brief = [f"对应到月度层,{label}线优先先看 {month_names};这里仍只保留 parameter_sensitive 的结构提示,不把它写成具体事件预测。"] + if focus_text: + brief.extend(['', f'焦点宫位:{focus_text}']) + if dasha_brief: + brief.extend(['', dasha_brief]) + brief.extend(['', '| 月份 | MD / AD | 月度支持 | 状态 |', '|------|---------|----------|------|']) + for row in selected_rows: + brief.append(f"| {_md_cell(row.get('month'))} | {_md_cell(row.get('dasha_pair'))} | {_md_cell(row.get('summary'))} | parameter_sensitive |") + return brief + + def _sahams_domain_bridge(domain: str) -> str | None: + saham_data = sahams.get('data') if isinstance(sahams.get('data'), dict) else {} + saham_rows = [ + row for row in saham_data.values() + if isinstance(row, dict) and row.get('name') and row.get('sign') is not None + ] + if not saham_rows: + return None + keyword_map = { + 'career': ('Karma', 'Rajya', 'Punya', 'Karya', 'Labha'), + 'wealth': ('Artha', 'Dhan', 'Labha', 'Punya'), + 'relationship': ('Vivah', 'Preeti', 'Kama', 'Bandhu'), + } + domain_tail = { + 'career': '它们更像这一年事业舞台、结果承接与机会敏感点的补充坐标,不直接写成职位或事件判断。', + 'wealth': '它们更像这一年资源承接、现金流与收益感的补充坐标,不直接写成具体财务事件判断。', + 'relationship': '它们更像这一年联结、合作与关系温度的补充坐标,不直接写成具体关系事件判断。', + } + selected = [] + seen = set() + for keyword in keyword_map.get(domain, ()): + for row in saham_rows: + name = str(row.get('name') or '').strip() + if not name or keyword not in name or name in seen: + continue + sign_text = row.get('sign_cn') or _humanize_reader_token(row.get('sign')) + degree_text = _degree_text(row.get('degree_in_sign')) + selected.append(f"{name} 落在{sign_text} {degree_text}") + seen.add(name) + break + if len(selected) >= 2: + break + if not selected: + return None + return f"Sahams 补充:{';'.join(selected)};{domain_tail.get(domain, '它们当前仍只作 parameter_sensitive 的补充坐标。')} 当前仍保留 parameter_sensitive。" + + def _jaimini_domain_bridge(domain: str) -> str | None: + arudha = jaimini.get('arudha_padas') if isinstance(jaimini.get('arudha_padas'), dict) else {} + special_lagnas = jaimini.get('special_lagnas') if isinstance(jaimini.get('special_lagnas'), dict) else {} + karakamsha = jaimini.get('karakamsha') if isinstance(jaimini.get('karakamsha'), dict) else {} + karakamsha_sign = _humanize_reader_token(karakamsha.get('karakamsha_sign') or karakamsha.get('sign')) + upapada_sign = _humanize_reader_token((arudha.get('upapada') or {}).get('sign')) + arudha_lagna_sign = _humanize_reader_token((arudha.get('arudha_lagna') or {}).get('sign')) + hora_lagna_sign = _humanize_reader_token((special_lagnas.get('HL') or {}).get('sign')) + pranapada_sign = _humanize_reader_token((special_lagnas.get('PP') or {}).get('sign')) + + if domain == 'career': + bits = [] + if karakamsha_sign not in (None, '', '-'): + bits.append(f"Karakamsha 落在{karakamsha_sign}") + if arudha_lagna_sign not in (None, '', '-'): + bits.append(f"AL 指向{arudha_lagna_sign}") + if not bits: + return None + return f"Jaimini 补充:{';'.join(bits)};这边已经先把事业角色、外部可见度和长期方向收拢成一条辅助主线,可当成主题判断的第二结构坐标;当前仍保留 unclosed_divisional_chart。" + + if domain == 'wealth': + bits = [] + if hora_lagna_sign not in (None, '', '-'): + bits.append(f"Hora Lagna 落在{hora_lagna_sign}") + if karakamsha_sign not in (None, '', '-'): + bits.append(f"Karakamsha 落在{karakamsha_sign}") + if not bits: + return None + return f"Jaimini 补充:{';'.join(bits)};这边已经先把资源承接、财富结构和长期取财方式并在一起看,可当成主题判断的第二结构坐标;当前仍保留 unclosed_divisional_chart。" + + if domain == 'relationship': + bits = [] + if upapada_sign not in (None, '', '-'): + bits.append(f"UL 指向{upapada_sign}") + if pranapada_sign not in (None, '', '-'): + bits.append(f"Pranapada 落在{pranapada_sign}") + if karakamsha_sign not in (None, '', '-'): + bits.append(f"Karakamsha 落在{karakamsha_sign}") + if not bits: + return None + return f"Jaimini 补充:{';'.join(bits)};这边已经先把关系制度层、气质投射层与长期联结主题并在一起看,可当成主题判断的第二结构坐标;当前仍保留 unclosed_divisional_chart。" + return None + + def _annual_timing_layers_bridge() -> str | None: + timing_bits = [] + if isinstance(mudda_periods, list) and mudda_periods: + mudda_lords = [] + for period in mudda_periods[:3]: + if not isinstance(period, dict): + continue + lord = _humanize_reader_token(period.get('lord')) + if lord and lord not in mudda_lords: + mudda_lords.append(lord) + if mudda_lords: + timing_bits.append(f"Mudda 先给出年度内部的大段推进顺序:{'、'.join(mudda_lords)}") + if isinstance(monthly_windows, list) and monthly_windows: + monthly_rows = [] + for window in monthly_windows[:2]: + if not isinstance(window, dict): + continue + idx = window.get('index') + lord = _humanize_reader_token(window.get('lord')) + if idx and lord: + monthly_rows.append(f"第{idx}段由{lord}主导") + if monthly_rows: + timing_bits.append("Monthly Windows 再把它压到 " + ";".join(monthly_rows)) + if isinstance(replay_patyayini_rows, list) and replay_patyayini_rows: + patyayini_codes = [] + for row in replay_patyayini_rows[:3]: + if not isinstance(row, dict): + continue + main_code = _md_cell(row.get('main_code')) + sub_code = _md_cell(row.get('sub_code')) + if main_code == '-' and sub_code == '-': + continue + patyayini_codes.append(f"{main_code} → {sub_code}") + if patyayini_codes: + timing_bits.append("Patyayini 继续把它拆成 " + "、".join(patyayini_codes) + " 这类更细的年内换挡点") + if not timing_bits: + return None + return '年度节奏可以按三层一起读:' + ';'.join(timing_bits) + '。这些仍只是 parameter_sensitive / pyjhora_behavior_only 的时间线索,不直接升级为具体事件预测。' + + def _annual_patyayini_focus_sentence() -> str | None: + if not isinstance(replay_patyayini_rows, list) or not replay_patyayini_rows: + return None + preview_bits = [] + for row in replay_patyayini_rows[:3]: + if not isinstance(row, dict): + continue + main_code = _md_cell(row.get('main_code')) + sub_code = _md_cell(row.get('sub_code')) + if main_code == '-' and sub_code == '-': + continue + preview_bits.append(f"{main_code} → {sub_code}") + if not preview_bits: + return None + first_boundary = replay_patyayini_rows[0].get('boundary_display') if isinstance(replay_patyayini_rows[0], dict) else None + boundary_text = f"第一段边界先从 {first_boundary} 开始;" if first_boundary not in (None, '', '-') else '' + return ( + "Patyayini 年度补充:外部回放已经把年内换挡点先压成 " + + "、".join(preview_bits) + + " 这条序列;" + + boundary_text + + "当前只把它当成年度节奏辅助坐标,继续保持 pyjhora_behavior_only / not_multiengine_parity。" + ) + + def _annual_domain_bridge(domain: str) -> str | None: + highlight_text = annual_highlight_bits[0] if annual_highlight_bits else '' + secondary_highlight_text = annual_highlight_bits[1] if len(annual_highlight_bits) > 1 else '' + muntha_text = _humanize_reader_summary_line(str(annual_field_briefs.get('muntha', ''))).rstrip('。') if isinstance(annual_field_briefs, dict) else '' + year_lord_text_local = _humanize_reader_summary_line(str(annual_field_briefs.get('year_lord', ''))).rstrip('。') if isinstance(annual_field_briefs, dict) else '' + sahams_text = _humanize_reader_summary_line(str(annual_field_briefs.get('sahams', ''))).rstrip('。') if isinstance(annual_field_briefs, dict) else '' + tajika_text = _humanize_reader_summary_line(str(annual_field_briefs.get('tajika_yogas', ''))).rstrip('。') if isinstance(annual_field_briefs, dict) else '' + patyayini_preview = [] + if isinstance(replay_patyayini_rows, list): + for row in replay_patyayini_rows[:2]: + if not isinstance(row, dict): + continue + main_code = _md_cell(row.get('main_code')) + sub_code = _md_cell(row.get('sub_code')) + if main_code == '-' and sub_code == '-': + continue + patyayini_preview.append(f"{main_code} → {sub_code}") + if domain == 'career': + bits = [] + if highlight_text: + bits.append("年度背景先落在:" + highlight_text) + if secondary_highlight_text: + bits.append("年度专题继续提醒:" + secondary_highlight_text) + if year_lord_text_local: + bits.append(year_lord_text_local) + if muntha_text: + bits.append(muntha_text) + if sahams_text: + bits.append("年度附加点这边已经能看到事业舞台与结果承接类敏感点") + if tajika_text: + bits.append("年度候选结构这边已经能看到 Tajika 候选层,但仍不能直接写成已成立事件") + if patyayini_preview: + bits.append("Patyayini 这边已经先给出 " + "、".join(patyayini_preview) + " 这类年内换挡点,可当成事业推进节奏的补充时间锚点") + if highlight_text or secondary_highlight_text or year_lord_text_local or muntha_text or sahams_text or tajika_text or patyayini_preview: + bits.append("这条事业线更适合先按年度结构确认外部舞台,再把年度附加点与单月支持叠起来读") + if bits: + return ';'.join(bits) + '。当前仍保留 parameter_sensitive。' + if domain == 'wealth': + bits = [] + if highlight_text: + bits.append("年度背景先落在:" + highlight_text) + if secondary_highlight_text: + bits.append("年度专题继续提醒:" + secondary_highlight_text) + if muntha_text: + bits.append(muntha_text) + if year_lord_text_local: + bits.append(year_lord_text_local) + if sahams_text: + bits.append("年度附加点这边已经能看到资源承接与收益感的敏感点") + if tajika_text: + bits.append("年度候选结构这边已经能看到 Tajika 候选层,但仍不能把它写成确定财务事件") + if patyayini_preview: + bits.append("Patyayini 这边已经先给出 " + "、".join(patyayini_preview) + " 这类年内换挡点,可当成资源回流与现金流节奏的补充时间锚点") + if highlight_text or secondary_highlight_text or muntha_text or year_lord_text_local or sahams_text or tajika_text or patyayini_preview: + bits.append("这条财务线更适合先把年度资源背景看清,再把年度附加点与单月支持叠起来读") + if bits: + return ';'.join(bits) + '。当前仍保留 parameter_sensitive。' + if domain == 'relationship': + bits = [] + if highlight_text: + bits.append("年度背景先落在:" + highlight_text) + if secondary_highlight_text: + bits.append("年度专题继续提醒:" + secondary_highlight_text) + if year_lord_text_local: + bits.append(year_lord_text_local) + if muntha_text: + bits.append(muntha_text) + if sahams_text: + bits.append("年度附加点这边已经能看到联结温度与关系敏感点") + if tajika_text: + bits.append("年度候选结构这边已经能看到 Tajika 候选层,但仍不能把它写成已落地的关系事件") + if patyayini_preview: + bits.append("Patyayini 这边已经先给出 " + "、".join(patyayini_preview) + " 这类年内换挡点,可当成合作推进与关系节奏的补充时间锚点") + if highlight_text or secondary_highlight_text or year_lord_text_local or muntha_text or sahams_text or tajika_text or patyayini_preview: + bits.append("这条关系线更适合先把年度关系底色看清,再把年度附加点与单月支持叠起来读") + if bits: + return ';'.join(bits) + '。当前仍保留 parameter_sensitive。' + return None + + def _future_annual_focus_sections() -> list[str]: + monthly_pack = _unwrap(advanced_sheet.get('kp_monthly_report')) or {} + monthly_pack = monthly_pack if isinstance(monthly_pack, dict) else {} + yearly_highlights = _envelope_list(monthly_pack.get('yearly_highlights')) + if not yearly_highlights: + return [] + annual_series = _unwrap(timing_sheet.get('annual_tajika_series')) or {} + annual_series = annual_series if isinstance(annual_series, dict) else {} + annual_years = _unwrap(annual_series.get('years')) or {} + annual_years = annual_years if isinstance(annual_years, dict) else {} + target_year_text = str(annual_target_year) if annual_target_year not in (None, "", [], {}) else "" + shared_anchor_bits = [ + _humanize_reader_summary_line(str(annual_field_briefs.get("muntha", ""))).rstrip("。") if isinstance(annual_field_briefs, dict) else "", + _humanize_reader_summary_line(str(annual_field_briefs.get("year_lord", ""))).rstrip("。") if isinstance(annual_field_briefs, dict) else "", + _humanize_reader_summary_line(str(annual_field_briefs.get("sahams", ""))).rstrip("。") if isinstance(annual_field_briefs, dict) else "", + _humanize_reader_summary_line(str(annual_field_briefs.get("tajika_yogas", ""))).rstrip("。") if isinstance(annual_field_briefs, dict) else "", + ] + shared_anchor_bits = [bit for bit in shared_anchor_bits if bit] + label_map = { + "muntha": "Muntha", + "year_lord": "Year Lord", + "tajika_yogas": "Tajika Yogas", + "sahams": "Sahams", + "mudda_dasha": "Mudda Dasha", + "patyayini_dasha": "Patyayini Dasha", + } + flagged_labels = [] + for field in annual_flagged_fields: + label = label_map.get(str(field), _humanize_reader_token(field)) + if label and label not in flagged_labels: + flagged_labels.append(str(label)) + + def _year_domain_text(rows: list[dict[str, Any]]) -> str: + labels = [] + for row in rows[:3]: + support = row.get("theme_support") if isinstance(row.get("theme_support"), dict) else {} + for domain_key, label in (("career", "事业"), ("wealth", "财务"), ("relationship", "关系")): + item = support.get(domain_key) if isinstance(support.get(domain_key), dict) else {} + if item and label not in labels: + labels.append(label) + return " / ".join(labels) if labels else "事业 / 财务 / 关系" + + out: list[str] = [] + for yearly in yearly_highlights: + year = str(yearly.get("year") or "").strip() + rows = yearly.get("months") if isinstance(yearly.get("months"), list) else [] + if not year or not rows or year == target_year_text: + continue + annual_pack = _unwrap(annual_years.get(year)) or {} + annual_pack = annual_pack if isinstance(annual_pack, dict) else {} + annual_profile = _unwrap(annual_pack.get('profile')) or {} + annual_profile = annual_profile if isinstance(annual_profile, dict) else {} + annual_sections = _unwrap(annual_pack.get('report_sections')) or {} + annual_sections = annual_sections if isinstance(annual_sections, dict) else {} + annual_exec = _unwrap(annual_sections.get('executive_summary')) or {} + annual_exec = annual_exec if isinstance(annual_exec, dict) else {} + annual_lines = _envelope_list(annual_exec.get('summary_lines')) + annual_bits = [ + _humanize_reader_summary_line(str(line)).rstrip('。') + for line in annual_lines if line + ] + focus_months = [str(row.get("month")) for row in rows if row.get("month")] + reasons = [str(row.get("reason")).strip() for row in rows if row.get("reason")] + out.extend([f"### {year} 年度重点", ""]) + out.append( + f"这一章沿用 {target_year_text or '当前'} 年度重点模板,但这里真正下沉的是 `annual_tajika_pack` 已给出的年度结构锚点," + f"再叠加 `kp_monthly_report_packet` 的 {year} 年重点月份排序,因此主要服务下一年度的主题优先级安排,继续保持 parameter_sensitive。" + ) + if annual_bits: + out.append(f"独立年度锚点:{';'.join(annual_bits[:3])}。") + if focus_months: + out.append(f"{year} 年优先看 {'、'.join(focus_months[:3])};主轴先按 {_year_domain_text(rows)} 三线交叉阅读。") + if reasons: + out.append("年度节奏说明:" + ";".join(reasons[:3]) + "。") + if shared_anchor_bits: + out.append("共享年度锚点:" + ";".join(shared_anchor_bits[:2]) + "。") + if flagged_labels: + out.append( + "年度边界提醒:" + "、".join(flagged_labels) + " 继续带着 parameter_sensitive / blocked 标签阅读,不把这些锚点直接升级成下一年度已确定事件。" + ) + out.append("") + return out + + def _sensitive_points_domain_bridge(domain: str) -> str | None: + points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} + bhrigu = points.get('bhrigu_bindu') if isinstance(points.get('bhrigu_bindu'), dict) else {} + navamsa_64 = points.get('navamsa_64th') if isinstance(points.get('navamsa_64th'), dict) else {} + drekkana_22 = points.get('drekkana_22nd') if isinstance(points.get('drekkana_22nd'), dict) else {} + + def _point_text(label: str, point: dict) -> str | None: + sign = _humanize_reader_token(point.get('sign')) + if sign in (None, '', '-'): + return None + return f"{label} 落在{sign}" + + selected = [] + if domain == 'career': + selected = [ + _point_text('Bhrigu Bindu', bhrigu), + _point_text('22nd Drekkana', drekkana_22), + _point_text('64th Navamsa', navamsa_64), + ] + tail = '这层更适合当成事业推进里的宿命触发感、摩擦点与节奏敏感层来读' + elif domain == 'wealth': + selected = [ + _point_text('Bhrigu Bindu', bhrigu), + _point_text('64th Navamsa', navamsa_64), + _point_text('22nd Drekkana', drekkana_22), + ] + tail = '这层更适合当成资源波动、敏感承接点与取舍压力位来读' + elif domain == 'relationship': + selected = [ + _point_text('Bhrigu Bindu', bhrigu), + _point_text('64th Navamsa', navamsa_64), + _point_text('22nd Drekkana', drekkana_22), + ] + tail = '这层更适合当成关系摩擦、情绪压力位与边界敏感层来读' + else: + return None + selected = [item for item in selected if item] + if not selected: + return None + return f"敏感点补充:{';'.join(selected)};{tail}。当前仍保留 parameter_sensitive。" + + def _domain_support_blend(domain: str) -> str | None: + segments = [] + statuses = [] + known_suffixes = ( + '当前仍保留 parameter_sensitive。', + '当前仍保留 unclosed_divisional_chart。', + ) + known_prefixes = ( + ('Jaimini 补充:', 'Jaimini 这边:'), + ('Sahams 补充:', '年度附加点这边:'), + ('敏感点补充:', '敏感点层:'), + ('Graha Padas 补充:', 'Graha Padas 这边:'), + ) + for item in ( + _annual_domain_bridge(domain), + _jaimini_domain_bridge(domain), + _sahams_domain_bridge(domain), + _sensitive_points_domain_bridge(domain), + _graha_padas_domain_bridge(domain), + _blocked_structure_domain_bridge(domain), + ): + text = str(item or '').strip() + if not text: + continue + for old_prefix, new_prefix in known_prefixes: + if text.startswith(old_prefix): + text = new_prefix + text[len(old_prefix):] + break + for suffix in known_suffixes: + if text.endswith(suffix): + status = suffix.removeprefix('当前仍保留 ').removesuffix('。') + if status not in statuses: + statuses.append(status) + text = text[: -len(suffix)].rstrip(';。 ,, ') + break + if text: + segments.append(text.rstrip('。')) + if not segments: + return None + if statuses: + segments.append('这些层当前仍只作为 ' + ' / '.join(statuses) + ' 的辅助阅读,不直接升级为主题主结论') + return '主题支持补充:' + ';'.join(segments) + '。' + + def _high_value_theme_authority_sentence(domain: str) -> str | None: + domain_label_map = { + 'career': '事业', + 'wealth': '财务', + 'relationship': '关系', + } + action_map = { + 'career': '优先排序事业推进顺序、外部舞台和月份节奏', + 'wealth': '优先排序资源承接、现金流节奏和收益兑现顺序', + 'relationship': '优先排序合作推进、联结温度和关系节奏', + } + return ( + "主结论加厚:Narayana 第二时间轴、力量兑现结构、年度 highlights / field briefs、Patyayini 年内换挡、Graha Padas 这几层现在直接参与" + + f"{domain_label_map.get(domain, domain)}章主结论," + + f"用来{action_map.get(domain, '补强主题排序')};" + + "它们仍保留 parameter_sensitive / unclosed_divisional_chart 边界,但不再只停留在附录或登记表。" + ) + + def _kp_conflict_adjudication_sentence(domain: str) -> str: + domain_label_map = { + 'career': '事业', + 'wealth': '财务', + 'relationship': '关系', + } + focus_map = { + 'career': '外部舞台、职位变化和项目推进', + 'wealth': '资源承接、现金流和回收节奏', + 'relationship': '合作推进、联结温度和承诺节奏', + } + return ( + f"如果 Vimshottari、Narayana 与年度专题同向,而 KP 月度支持也落在同一主题上,就把这个月份优先当作{domain_label_map.get(domain, domain)}线的可执行窗口。" + f"如果 KP 单独活跃、但本命结构或双大运没有接力,KP 只负责提示 {focus_map.get(domain, '主题变化')} 的月份排序,不改写主题事实。" + f"如果 KP 与年度专题或双大运出现冲突,先以本命结构 + Vimshottari + Narayana 定主轴,KP 退回到月度提醒与核对层,继续保持 parameter_sensitive。" + ) + + def _patyayini_reader_bridge() -> list[str]: + if not replay_patyayini_rows: + return [] + preview_bits = [] + for row in replay_patyayini_rows[:3]: + if not isinstance(row, dict): + continue + main_code = _md_cell(row.get('main_code')) + sub_code = _md_cell(row.get('sub_code')) + if main_code == '-' and sub_code == '-': + continue + preview_bits.append(f"{main_code} → {sub_code}") + if not preview_bits: + return [] + semantic_bits = [] + if preview_bits: + semantic_bits.append("它更适合先当成年度内部节奏换挡点来读") + if replay_patyayini_rows: + first_boundary = replay_patyayini_rows[0].get('boundary_display') if isinstance(replay_patyayini_rows[0], dict) else None + if first_boundary not in (None, '', '-'): + semantic_bits.append(f"第一段边界先从 {first_boundary} 开始") + out = [ + "Patyayini 时间补充:外部回放已经返回年度分段次序,当前可先把 " + + "、".join(preview_bits) + + " 看成年度背景里的补充时间锚点;" + + ";".join(semantic_bits) + + ";但 tuple 未返回时区,也还没完成 PL9 p134 ending-date 对照,因此继续保持 pyjhora_behavior_only / not_multiengine_parity。", + '', + '| 序号 | 主层 | 子层 | 边界时间 | 状态 |', + '|------|------|------|----------|------|', + ] + for row in replay_patyayini_rows[:4]: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('order'))} | {_md_cell(row.get('main_code'))} | {_md_cell(row.get('sub_code'))} | " + f"{_md_cell(row.get('boundary_display'))} | pyjhora_behavior_only / not_multiengine_parity |" + ) + return out + + def _prashna_exact_cusp_boundary_sentence() -> str | None: + return ( + "如果下一步要把月份继续缩到更细日期,仍需要补 question_time / place / timezone / question category;" + "Prashna ruling planets 与 exact cusp oracle 在这份本命个人报告里继续保持 blocked,只作为后续缩窄入口,不冒充 timing truth。" + ) + + def _reader_unexported_high_value_layers_section() -> list[str]: + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + points = divisional_sheet.get('sensitive_points') if isinstance(divisional_sheet.get('sensitive_points'), dict) else {} + rows: list[tuple[str, str, str]] = [ + ('Jaimini 特殊点', 'unclosed_divisional_chart', 'Jaimini 特殊点与 Graha Padas 已部分借入正文,并开始承担主题判断的第二结构坐标;但仍未形成独立稳定主题合同。'), + ('Tajika 命名 Yoga', 'blocked', '当前仍停在 candidate / blocked 审计层,不能写成年度已成立结论。'), + ('Prashna 问时缩窄', 'blocked', '若要缩到更细日期,仍需 question_time / place / timezone / question category。'), + ('Exact cusp oracle', 'blocked', '外部 numeric oracle 与 negative holdout 还没闭环,因此不能把宫头级 timing 提升为正文结论。'), + ] + if isinstance(points.get('navamsa_64th'), dict) and points.get('navamsa_64th', {}).get('sign'): + rows.insert(2, ('64th Navamsa / 22nd Drekkana', 'parameter_sensitive', '敏感派生点已经进入轻量补充句,但仍主要停在辅助层,尚未稳定并入主题主结论。')) + return [ + '#### 仍待升级的高价值层', + '', + '下面这些层不是仓里没有,而是已经有原始结构、外部 replay 或专题附录,但还没被证明可以稳定写进正文主结论;因此继续显式保留当前位置与边界。', + '', + '| 专业层 | 当前状态 | 为什么仍未升级到正文主结论 |', + '|--------|----------|----------------------------|', + *[ + f"| {_md_cell(label)} | {_md_cell(status)} | {_md_cell(reason)} |" + for label, status, reason in rows + ], + '', + ] + + def _dasha_domain_brief(domain: str) -> str | None: + row = dasha_domain_rows.get(domain) if isinstance(dasha_domain_rows, dict) else {} + if not isinstance(row, dict): + return None + label_map = { + 'career': '事业', + 'wealth': '财务', + 'relationship': '关系', + 'health': '健康', + 'risk': '风险', + 'timing': '时间', + } + label = label_map.get(domain, domain) + trigger_refs = row.get('trigger_refs') if isinstance(row.get('trigger_refs'), list) else [] + trigger_names = [] + for ref in trigger_refs[:5]: + if not isinstance(ref, dict): + continue + value = ref.get('lord') or ref.get('sign') + token = _humanize_reader_token(value) + if token and token not in trigger_names: + trigger_names.append(token) + trigger_text = '、'.join(trigger_names) + status = str(row.get('status') or '') + paragraph = str(row.get('paragraph') or '').strip() + title = _humanize_domain_title(_humanize_reader_token(row.get('title'))) + if not trigger_text or status == 'blocked': + return f"Dasha 语义补充:当前这条{label}线在 dasha pack 里还没有足够强的触发行,所以先保留为 parameter_sensitive 的候选提示,不单独升级成时间结论。" + bits = [f"当前这条{label}线更容易被{trigger_text}推到前台"] + if title: + bits.append(f"对应主题仍先按{title}来读") + if "houses, ownership, dignity, nakshatra, and functional benefic/malefic roles" in paragraph: + bits.append("会综合宫位、守护宫、尊贵、星宿与功能性吉凶星一起判断") + if "Annual, transit, and external Dasha-profile checks agree" in paragraph: + bits.append("并继续等待 annual、transit 与外部 dasha profile 交叉") + bits.append("所以目前仍只保留为 parameter_sensitive 的条件性时间线索") + return "Dasha 语义补充:" + ";".join(bits) + "。" + + def _degree_text(value) -> str: + try: + return f"{float(value):.4f}°" + except (TypeError, ValueError): + return _md_cell(value) + + def _sign_degree_text(value) -> str: + try: + return f"{float(value) % 30:.4f}°" + except (TypeError, ValueError): + return _md_cell(value) + + def _longitude_text(value) -> str: + try: + longitude = float(value) % 360 + sign = SIGNS[int(longitude // 30) % 12] + return f"{SIGNS_CN.get(sign, sign)} {longitude % 30:.4f}°" + except (TypeError, ValueError): + return _md_cell(value) + + def _sign_text(row: dict, sign_key: str = 'sign', cn_key: str = 'sign_cn') -> str: + if not isinstance(row, dict): + return '-' + sign = row.get(cn_key) or _humanize_reader_token(row.get(sign_key)) or row.get(sign_key) + return _md_cell(sign) + + def _pl9_core_structure_tables() -> list[str]: + birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {} + d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} + birth_info = birth_sheet.get('birth_info') if isinstance(birth_sheet.get('birth_info'), dict) else birth + panchanga_pack = birth_sheet.get('panchanga') if isinstance(birth_sheet.get('panchanga'), dict) else {} + panchanga = panchanga_pack.get('raw') if isinstance(panchanga_pack.get('raw'), dict) else {} + profile = birth_sheet.get('base_charts_pack', {}).get('profile') if isinstance(birth_sheet.get('base_charts_pack'), dict) else {} + ascendant_row = core_chart.get('ascendant') if isinstance(core_chart.get('ascendant'), dict) else d1_sheet.get('ascendant') if isinstance(d1_sheet.get('ascendant'), dict) else {} + planets = core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else d1_sheet.get('planets') if isinstance(d1_sheet.get('planets'), dict) else {} + houses = core_chart.get('houses') if isinstance(core_chart.get('houses'), dict) else d1_sheet.get('houses') if isinstance(d1_sheet.get('houses'), dict) else {} + out = [ + '### 基础资料表', + '', + '#### Birth Particulars / Calculation Details', + '', + '| Field | Value |', + '|-------|-------|', + f"| Date | {_md_cell(birth_info.get('date'))} |", + f"| Time | {_md_cell(birth_info.get('time'))} |", + f"| Latitude | {_md_cell(birth_info.get('lat'))} |", + f"| Longitude | {_md_cell(birth_info.get('lon'))} |", + f"| Time Zone | {_md_cell(birth_info.get('tz') or (profile.get('timezone') or {}).get('utc_offset') if isinstance(profile.get('timezone'), dict) else None)} |", + f"| Ayanamsa | {_md_cell(birth_info.get('ayanamsa_display') or birth_info.get('ayanamsa_name') or profile.get('ayanamsa'))} |", + f"| Ayanamsa Value | {_degree_text(birth_info.get('ayanamsa'))} |", + f"| Node Mode | {_md_cell(birth_info.get('node_mode') or profile.get('node_mode'))} |", + f"| House System | {_md_cell(profile.get('house_system') or 'whole_sign')} |", + f"| Ascendant | {_sign_text(ascendant_row)} {_degree_text(ascendant_row.get('degree_in_sign') or ascendant_row.get('degree'))} |", + f"| Ascendant Lord | {_md_cell(_humanize_reader_token(ascendant_row.get('lord')))} |", + '', + ] + if panchanga: + tithi = panchanga.get('tithi') if isinstance(panchanga.get('tithi'), dict) else {} + nakshatra = panchanga.get('nakshatra') if isinstance(panchanga.get('nakshatra'), dict) else {} + yoga = panchanga.get('yoga') if isinstance(panchanga.get('yoga'), dict) else {} + karana = panchanga.get('karana') if isinstance(panchanga.get('karana'), dict) else {} + vara = panchanga.get('vara') if isinstance(panchanga.get('vara'), dict) else {} + out.extend([ + '#### 出生 Panchanga(本地计算)', + '', + '以下为本地出生时刻五要素原始计算结果。尚未完成与 PL9 Panchanga 页的字段级对照,因此只作数据附录,保持 parameter_sensitive。', + '', + '| 要素 | 本次结果 | 状态 |', + '|------|----------|------|', + f"| Tithi | {_md_cell(tithi.get('full_name') or tithi.get('name'))} | parameter_sensitive |", + f"| Nakshatra | {_md_cell(nakshatra.get('nakshatra'))} / Pada {_md_cell(nakshatra.get('pada'))} | parameter_sensitive |", + f"| Yoga | {_md_cell(yoga.get('yoga'))} | parameter_sensitive |", + f"| Karana | {_md_cell(karana.get('karana'))} | parameter_sensitive |", + f"| Vara | {_md_cell(vara.get('vara'))} | parameter_sensitive |", + '', + ]) + if planets: + out.extend([ + '#### Planet Positions', + '', + '| Planet | R/C | Sign | Degree | Speed | Nakshatra | Pada | RL | NL | SL | SS | Status | SB |', + '|--------|-----|------|--------|-------|-----------|------|----|----|----|----|--------|----|', + ]) + for planet_name in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = planets.get(planet_name) if isinstance(planets.get(planet_name), dict) else {} + if not row: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(planet_name))} | {_md_cell(row.get('r_c'))} | {_sign_text(row)} | " + f"{_degree_text(row.get('degree_in_sign'))} | {_md_cell(row.get('speed'))} | " + f"{_md_cell(row.get('nakshatra'))} | {_md_cell(row.get('nakshatra_pada'))} | " + f"{_md_cell(_humanize_reader_token(row.get('rasi_lord')))} | {_md_cell(_humanize_reader_token(row.get('nakshatra_lord')))} | " + f"{_md_cell(_humanize_reader_token(row.get('sub_lord')))} | {_md_cell(_humanize_reader_token(row.get('sub_sub_lord')))} | " + f"{_md_cell(row.get('status'))} | {_md_cell(row.get('sb'))} |" + ) + out.append('') + bhava_chalit = d1_sheet.get('bhava_chalit') if isinstance(d1_sheet.get('bhava_chalit'), dict) else {} + bhava_raw = bhava_chalit.get('raw') if isinstance(bhava_chalit.get('raw'), dict) else {} + boundaries = bhava_raw.get('boundaries') if isinstance(bhava_raw.get('boundaries'), dict) else {} + boundary_rows = boundaries.get('houses') if isinstance(boundaries.get('houses'), list) else [] + if boundary_rows: + out.extend([ + '#### Bhava Spashta - Sripati System', + '', + '| Bhava Number | Bhava Arambha | Bhava Madhya | Bhava Antya |', + '|--------------|---------------|--------------|-------------|', + ]) + for row in boundary_rows: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('house'))} | {_longitude_text(row.get('sandhi_start_lon'))} | " + f"{_longitude_text(row.get('cusp_lon'))} | {_longitude_text(row.get('sandhi_end_lon'))} |" + ) + out.append('') + elif houses: + out.extend([ + '#### Whole-sign House Overview', + '', + '| Bhava | Cusp Sign | Cusp Degree | Lord |', + '|-------|-----------|-------------|------|', + ]) + for idx in range(1, 13): + row = houses.get(f'house_{idx}') if isinstance(houses.get(f'house_{idx}'), dict) else {} + if not row: + continue + sign = row.get('cusp_sign_cn') or _humanize_reader_token(row.get('cusp_sign')) or row.get('cusp_sign') + out.append( + f"| {idx} | {_md_cell(sign)} | {_sign_degree_text(row.get('cusp_degree'))} | {_md_cell(_humanize_reader_token(row.get('lord')))} |" + ) + out.append('') + return out + + def _value_text(*values): + for value in values: + if value not in (None, '', [], {}): + return str(value) + return None + + ai_audit_sheet = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {} + ai_summary = ai_audit_sheet.get('summary') if isinstance(ai_audit_sheet.get('summary'), dict) else {} + guided_topics = ai_summary.get('guided_topics') if isinstance(ai_summary.get('guided_topics'), list) else [] + guided_topics_by_id = {} + for row in guided_topics: + if isinstance(row, dict) and row.get('id'): + guided_topics_by_id[str(row.get('id'))] = row + + def _guided_topic_title_sentence(topic_id: str) -> str | None: + row = guided_topics_by_id.get(topic_id) if isinstance(guided_topics_by_id.get(topic_id), dict) else {} + title = str(row.get('title') or '').strip() + if not title: + return None + return title.rstrip('。') + '。' + + def _guided_topic_value_sentence(topic_id: str) -> str | None: + row = guided_topics_by_id.get(topic_id) if isinstance(guided_topics_by_id.get(topic_id), dict) else {} + evidence = row.get('evidence') if isinstance(row.get('evidence'), list) else [] + value_map = {} + for item in evidence: + if not isinstance(item, dict): + continue + label = str(item.get('label') or '').strip() + value = str(item.get('value') or '').strip() + if label and value: + value_map[label] = value + + def _is_meaningful_value(value: str | None) -> bool: + if not value: + return False + return str(value).strip().lower() not in {'not_found', 'insufficient_evidence', 'unknown', 'n/a', 'na'} + + def _humanize_functional_layer(value: str | None) -> str | None: + if not value: + return None + text = str(value) + benefics = None + malefics = None + if "benefics=" in text: + benefics = text.split("benefics=", 1)[1].split(";", 1)[0].strip() + if "malefics=" in text: + malefics = text.split("malefics=", 1)[1].strip() + + def _format_names(raw: str | None) -> str | None: + if not raw: + return None + cleaned = raw.strip().strip('[]') + parts = [part.strip().strip("'").strip('"') for part in cleaned.split(',')] + parts = [_humanize_reader_token(part) for part in parts if part] + parts = [part for part in parts if part] + if not parts: + return None + return '、'.join(parts) + + benefics_text = _format_names(benefics) + malefics_text = _format_names(malefics) + bits = [] + if benefics_text: + bits.append(f"功能性吉星当前以{benefics_text}为主") + if malefics_text: + bits.append(f"功能性压力点则更集中在{malefics_text}") + if bits: + return ';'.join(bits) + return None + + def _humanize_strict_verdict(value: str | None) -> str | None: + if not value: + return None + normalized = str(value).strip().lower() + if normalized == 'insufficient_evidence': + return '这条关系线当前先更适合放慢一点,结合现实互动慢慢回验' + if normalized == 'high_probability_window': + return '这条关系线已经出现了较强候选窗口,但仍要结合现实进展继续核对' + if _is_meaningful_value(value): + return str(value) + return None + + def _humanize_house_ref(value: str | None) -> str | None: + match = re.match(r'^([A-Za-z]+)\s+H(\d+)$', str(value or '').strip()) + if not match: + return None + sign = _humanize_reader_token(match.group(1)) + house = match.group(2) + return f'{sign}第{house}宫' + + def _humanize_d9_signature(value: str | None) -> str | None: + text = str(value or '').strip() + match = re.match(r'^Asc=([^;]+);\s*7th=([^;]+)$', text) + if not match: + return None + asc = _humanize_reader_token(match.group(1).strip()) + seventh = _humanize_reader_token(match.group(2).strip()) + return f'九分盘上升落在{asc},七宫落在{seventh}' + if topic_id == 'birth_time_rectification': + bits = [] + sensitive_layers = value_map.get('sensitive layers') + current_timing = value_map.get('current timing') + if sensitive_layers: + bits.append(f"当前最敏感、最值得优先回验的层是{sensitive_layers}") + if current_timing: + bits.append(f"眼下时间主底色仍落在{current_timing}") + if bits: + return ';'.join(bits) + '。' + if topic_id == 'career_direction': + bits = [] + current_timing = value_map.get('Vimshottari') + trigger = value_map.get('10宫触发') + convergence = value_map.get('Dasa 收敛') + functional_layer = value_map.get('Functional layer') + if current_timing: + bits.append(f"当前事业节奏的主大运底色仍落在{current_timing}") + if trigger: + bits.append(f"事业触发点当前优先落在{trigger}") + if _is_meaningful_value(convergence): + bits.append(f"Dasa 收敛当前先指向{convergence}") + functional_layer_sentence = _humanize_functional_layer(functional_layer) + if functional_layer_sentence: + bits.append(functional_layer_sentence) + if bits: + return ';'.join(bits) + '。' + if topic_id == 'wealth_risk': + bits = [] + required_vargas = value_map.get('required vargas') + current_timing = value_map.get('Vimshottari') + convergence = value_map.get('Dasa 收敛') + if required_vargas: + bits.append(f"这条财务线当前更适合先回到 {required_vargas},看现金流能不能先稳住,再把借贷和风险结构慢慢拆开") + if current_timing: + bits.append(f"当前主大运底色仍落在{current_timing}") + if _is_meaningful_value(convergence): + bits.append(f"这条财务线的大运收敛当前先指向{convergence}") + if bits: + return ';'.join(bits) + '。' + if topic_id == 'relationship_partnership': + bits = [] + current_timing = value_map.get('Vimshottari') + convergence = value_map.get('Dasa 收敛') + d9 = value_map.get('D9') + ul = value_map.get('UL') + dk = value_map.get('DK') + strict_verdict = value_map.get('Strict verdict') + if current_timing: + bits.append(f"当前关系主题的主大运底色仍落在{current_timing}") + if _is_meaningful_value(convergence): + bits.append(f"关系主题的大运收敛当前先指向{convergence}") + d9_sentence = _humanize_d9_signature(d9) + ul_sentence = _humanize_house_ref(ul) + dk_sentence = _humanize_house_ref(dk) + if d9_sentence: + bits.append(f"关系结构层当前已经给出:{d9_sentence}") + elif d9: + bits.append(f"关系结构层当前已经给出 {d9}") + if ul_sentence: + bits.append(f"UL 当前落在{ul_sentence}") + elif ul: + bits.append(f"UL 当前落在 {ul}") + if dk_sentence: + bits.append(f"DK 当前指向{dk_sentence}") + elif dk: + bits.append(f"DK 当前指向 {dk}") + strict_verdict_sentence = _humanize_strict_verdict(strict_verdict) + if strict_verdict_sentence: + bits.append(strict_verdict_sentence) + if bits: + return ';'.join(bits) + '。' + return None + + def _compose_narrative_paragraph(*parts: str | None) -> str | None: + cleaned = [] + for part in parts: + text = str(part or '').strip() + if not text: + continue + text = text.rstrip() + if text.endswith('。'): + text = text[:-1] + cleaned.append(text) + if not cleaned: + return None + return ';'.join(cleaned) + '。' + + dasha_domain_rows = {} + domain_paragraphs = dasha_interpretation_pack.get('domain_paragraphs') if isinstance(dasha_interpretation_pack.get('domain_paragraphs'), list) else [] + for row in domain_paragraphs: + if isinstance(row, dict) and row.get('domain'): + dasha_domain_rows[str(row.get('domain'))] = row + dasha_report_sections = dasha_interpretation_pack.get('report_sections') if isinstance(dasha_interpretation_pack.get('report_sections'), dict) else {} + dasha_exec_summary = dasha_report_sections.get('executive_summary') if isinstance(dasha_report_sections.get('executive_summary'), list) else [] + dasha_evidence_appendix = dasha_report_sections.get('evidence_appendix') if isinstance(dasha_report_sections.get('evidence_appendix'), list) else [] + + d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + d1_summary = d1_sheet.get('summary_card') if isinstance(d1_sheet.get('summary_card'), dict) else {} + ascendant = d1_summary.get('ascendant') if isinstance(d1_summary.get('ascendant'), dict) else {} + asc_text = _value_text(ascendant.get('sign_cn'), ascendant.get('sign')) + birth_sheet = worksheets.get('birth_and_parameters') if isinstance(worksheets.get('birth_and_parameters'), dict) else {} + birth_info = birth_sheet.get('birth_info') if isinstance(birth_sheet.get('birth_info'), dict) else {} + ayanamsa_display_text = _value_text(birth_info.get('ayanamsa_display'), birth_info.get('ayanamsa_name')) + node_mode_text = _value_text(birth_info.get('node_mode')) + strength_sheet = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {} + strength_summary = strength_sheet.get('summary_card') if isinstance(strength_sheet.get('summary_card'), dict) else {} + functional_layer = strength_sheet.get('functional_benefic_malefic') if isinstance(strength_sheet.get('functional_benefic_malefic'), dict) else {} + ashtakavarga_layer = strength_sheet.get('ashtakavarga') if isinstance(strength_sheet.get('ashtakavarga'), dict) else {} + vimsopaka_layer = strength_sheet.get('vimsopaka') if isinstance(strength_sheet.get('vimsopaka'), dict) else {} + divisional_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + varga_layers = [ + divisional_sheet.get('varga_full') if isinstance(divisional_sheet.get('varga_full'), dict) else {}, + divisional_sheet.get('varga_extended') if isinstance(divisional_sheet.get('varga_extended'), dict) else {}, + ] + special_lagnas_layer = divisional_sheet.get('special_lagnas') if isinstance(divisional_sheet.get('special_lagnas'), dict) else {} + + def _has_executed_payload(value) -> bool: + if value in (None, '', [], {}): + return False + if isinstance(value, dict): + status = str(value.get('status') or value.get('execution_status') or '').strip().lower() + if status in {'blocked', 'missing', 'not_found', 'parameter_sensitive'}: + return False + return True + + def _has_varga_code(varga_code: str) -> bool: + prefix = f'{varga_code}_' + for layer in varga_layers: + for key, value in layer.items(): + key_text = str(key) + if (key_text == varga_code or key_text.startswith(prefix)) and _has_executed_payload(value): + return True + return False + + def _has_special_lagna(*names: str) -> bool: + normalized_names = {str(name).strip().lower().replace(' ', '_') for name in names if str(name).strip()} + for key, value in special_lagnas_layer.items(): + normalized_key = str(key).strip().lower().replace(' ', '_') + if normalized_key in normalized_names and _has_executed_payload(value): + return True + return False + + def _has_functional_layer_basis() -> bool: + return bool(strength_summary.get('has_functional_layer') or _has_executed_payload(functional_layer)) + + def _has_shadbala_basis() -> bool: + return bool(strength_summary.get('has_shadbala') or _has_executed_payload(strength_sheet.get('shadbala'))) + + def _has_ashtakavarga_basis() -> bool: + return bool(strength_summary.get('has_ashtakavarga') or _has_executed_payload(ashtakavarga_layer)) + + def _chapter_called_basis_sentence(topic_id: str) -> str | None: + basis_by_topic = { + 'career': [ + ('D10(Dasamsa 事业分盘)', _has_varga_code('D10')), + ('功能性吉凶', _has_functional_layer_basis()), + ('Shadbala', _has_shadbala_basis()), + ('Ashtakavarga', _has_ashtakavarga_basis()), + ], + 'wealth': [ + ('D2(Hora 财富分盘)', _has_varga_code('D2')), + ('D11(Rudramsa 收益分盘)', _has_varga_code('D11')), + ('功能性吉凶', _has_functional_layer_basis()), + ('Shadbala', _has_shadbala_basis()), + ('Ashtakavarga', _has_ashtakavarga_basis()), + ], + 'relationship': [ + ('D9(Navamsa 关系分盘)', _has_varga_code('D9')), + ('UL(Upapada Lagna)', _has_special_lagna('UL', 'Upapada_Lagna', 'Upapada Lagna')), + ('功能性吉凶', _has_functional_layer_basis()), + ('Shadbala', _has_shadbala_basis()), + ('Ashtakavarga', _has_ashtakavarga_basis()), + ], + } + labels = [label for label, present in basis_by_topic.get(topic_id, []) if present] + if not labels: + return None + return f"本章已调用依据:{'、'.join(labels)}。此处只标注已执行数据来源,不新增事件断语。" + + def _dasha_domain_support_sentence(domain: str) -> str | None: + row = dasha_domain_rows.get(domain) if isinstance(dasha_domain_rows, dict) else {} + if not isinstance(row, dict): + return None + refs = row.get('trigger_refs') if isinstance(row.get('trigger_refs'), list) else [] + names = [] + seen = set() + for ref in refs: + if not isinstance(ref, dict): + continue + value = ref.get('lord') or ref.get('sign') + humanized = _humanize_reader_token(value) + if humanized and humanized not in seen: + seen.add(humanized) + names.append(humanized) + if len(names) >= 3: + break + if not names: + return None + label = row.get('evidence_label') or 'parameter_sensitive' + status = row.get('status') or 'blocked' + title = _humanize_domain_title(_humanize_reader_token(row.get('title'))) + paragraph = str(row.get('paragraph') or '').strip() + bits = [f"这条线的大运支持当前主要集中在{'、'.join(names)}"] + if title: + bits.append(f"对应的主题骨架仍按{title}来读") + if "houses, ownership, dignity, nakshatra, and functional benefic/malefic roles" in paragraph: + bits.append("阅读时继续看宫位、守护宫、尊贵、星宿与功能性吉凶星的同向支持") + bits.append(f"当前标签保持{label}") + if status == 'blocked': + bits.append('因此这里只保留支持结构,不升级成确定时点结论') + else: + bits.append('因此更适合拿它来校正主轴和阅读顺序,而不是单独下事件判断') + return ';'.join(bits) + '。' + + def _multi_dasha_support_sentence(domain: str) -> str | None: + narayana = timing_sheet.get('narayana_dasha') if isinstance(timing_sheet.get('narayana_dasha'), dict) else {} + narayana_current_raw = narayana.get('current_dasha') if isinstance(narayana, dict) else None + if isinstance(narayana_current_raw, dict): + current_bits = [] + for key, label in (('md', 'MD'), ('ad', 'AD'), ('pd', 'PD')): + row = narayana_current_raw.get(key) if isinstance(narayana_current_raw.get(key), dict) else {} + sign = _humanize_reader_token(row.get('sign')) + lord = _humanize_reader_token(row.get('lord')) + if sign and lord: + current_bits.append(f"{label} {sign}({lord})") + elif sign: + current_bits.append(f"{label} {sign}") + elif lord: + current_bits.append(f"{label} {lord}") + narayana_current = ' / '.join(current_bits) if current_bits else None + else: + narayana_current = _humanize_reader_token(narayana_current_raw) + bits = [] + domain_tail = { + 'career': '在事业这条线里,更适合把它当成角色切换、职责抬头和外部推进节奏的第二时间轴', + 'wealth': '在财务这条线里,更适合把它当成资源回流、收益兑现和风险敞口切换的第二时间轴', + 'relationship': '在关系这条线里,更适合把它当成合作节奏、关系回流和边界变化的第二时间轴', + } + if narayana_current not in (None, '', '-'): + bits.append(f"Narayana 当前主线落在{narayana_current}") + if domain in domain_tail: + bits.append(domain_tail[domain]) + row = dasha_domain_rows.get(domain) if isinstance(dasha_domain_rows, dict) else {} + if isinstance(row, dict) and row.get('status') not in (None, '', '-', 'blocked'): + bits.append("当前仍需要和 Vimshottari 继续交叉") + if bits: + return "多系统校验补充:" + ';'.join(bits) + "。这一层已经开始承担主题阅读顺序里的第二时间轴,但继续只作 dual-dasha 的 parameter_sensitive 支撑,不单独升级 timing truth。" + return "多系统校验补充:Narayana Dasha 当前在这份导出里仍未稳定下沉到主题正文,因此这一层继续保持 blocked / parameter_sensitive,只作为后续交叉校验入口。" + + def _strength_domain_support_sentence(domain: str) -> str | None: + shadbala = strength_sheet.get('shadbala') if isinstance(strength_sheet.get('shadbala'), dict) else {} + bhava_bala = strength_sheet.get('bhava_bala') if isinstance(strength_sheet.get('bhava_bala'), dict) else {} + strongest = _humanize_reader_token(shadbala.get('strongest')) if isinstance(shadbala, dict) else None + weakest = _humanize_reader_token(shadbala.get('weakest')) if isinstance(shadbala, dict) else None + houses = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] + top_house = None + top_score = None + weak_house = None + weak_score = None + for row in houses: + if not isinstance(row, dict): + continue + try: + score = float(row.get('score')) + except (TypeError, ValueError): + continue + if top_score is None or score > top_score: + top_score = score + top_house = row.get('house') + if weak_score is None or score < weak_score: + weak_score = score + weak_house = row.get('house') + bits = [] + if strongest not in (None, '', '-'): + bits.append(f"Shadbala 最强星当前落在{strongest}") + if domain == 'career': + if top_house in (10, '10', 11, '11', 6, '6'): + bits.append(f"Bhava Bala 里第{top_house}宫承接力更强,事业推进更容易接到团队、职责或结果层") + if weak_house in (10, '10'): + bits.append("但第10宫承接位仍偏弱,抬头后的角色压力和结果兑现需要留出缓冲") + elif domain == 'wealth': + if top_house in (2, '2', 11, '11', 9, '9'): + bits.append(f"Bhava Bala 里第{top_house}宫承接力更强,资源回流、收益兑现或外部支持更容易接住") + if weak_house in (2, '2', 8, '8'): + bits.append(f"但第{weak_house}宫的承接仍偏弱,现金流节奏、共享资源或风险敞口更需要保守管理") + elif domain == 'relationship': + if top_house in (7, '7', 11, '11', 2, '2'): + bits.append(f"Bhava Bala 里第{top_house}宫承接力更强,合作、关系回流或制度承诺更容易被现实接住") + if weak_house in (7, '7', 12, '12'): + bits.append(f"但第{weak_house}宫的承接仍偏弱,边界、消耗感或关系落地节奏更需要慢一点确认") + elif weakest not in (None, '', '-'): + bits.append(f"当前较弱的力量点更多集中在{weakest}这条线") + if bits: + return "力量层补充:" + ';'.join(bits) + "。这层已经不只是强弱标签,而是把承接位和压力位先压成主题阅读顺序;但仍保持 parameter_sensitive,不单独生成结果断语。" + return None + + def _dasha_timing_summary_sentence() -> str | None: + row = dasha_domain_rows.get('timing') if isinstance(dasha_domain_rows, dict) else {} + if not isinstance(row, dict): + return None + refs = row.get('trigger_refs') if isinstance(row.get('trigger_refs'), list) else [] + names = [] + seen = set() + for ref in refs: + if not isinstance(ref, dict): + continue + value = ref.get('lord') or ref.get('sign') + humanized = _humanize_reader_token(value) + if humanized and humanized not in seen: + seen.add(humanized) + names.append(humanized) + if len(names) >= 4: + break + if not names: + return None + label = row.get('evidence_label') or 'parameter_sensitive' + return f"时间层当前已经有大运触发骨架,优先要看的候选主星包括{'、'.join(names)};这一层先保留为{label},仍需要和年度、行运以及外部回放继续交叉。" + + def _dasha_appendix_boundary_sentence() -> str | None: + appendix_bits = [] + for item in dasha_evidence_appendix: + text = str(item or '').strip() + if not text: + continue + humanized = _humanize_reader_summary_line(text).rstrip('。') + if humanized and humanized not in appendix_bits: + appendix_bits.append(humanized) + if len(appendix_bits) >= 2: + break + if not appendix_bits: + return None + return f"大运解释的证据边界当前仍然是:{';'.join(appendix_bits)}。" + + def _dasha_exec_summary_sentence() -> str | None: + summary_bits = [] + for item in dasha_exec_summary: + text = str(item or '').strip() + if not text or 'Dasha interpretation pack status' in text or 'Narrative trigger rows' in text: + continue + humanized = _humanize_reader_summary_line(text).rstrip('。') + if humanized and humanized not in summary_bits: + summary_bits.append(humanized) + if not summary_bits: + return None + return ';'.join(summary_bits) + '。' + + def _planet_list_text(items: list[str], limit: int = 3) -> str | None: + names = [] + seen = set() + for item in items: + humanized = _humanize_reader_token(item) + if humanized and humanized not in seen: + seen.add(humanized) + names.append(humanized) + if len(names) >= limit: + break + if not names: + return None + return '、'.join(names) + + def _functional_layer_sentence() -> str | None: + if functional_layer.get('status') != 'used': + return None + benefics = _planet_list_text(functional_layer.get('functional_benefics') if isinstance(functional_layer.get('functional_benefics'), list) else []) + malefics = _planet_list_text(functional_layer.get('functional_malefics') if isinstance(functional_layer.get('functional_malefics'), list) else []) + yogakarakas = _planet_list_text(functional_layer.get('yogakarakas') if isinstance(functional_layer.get('yogakarakas'), list) else [], limit=2) + bits = [] + if benefics: + bits.append(f"功能性吉星当前以{benefics}为主") + if malefics: + bits.append(f"功能性凶星当前以{malefics}为主") + if yogakarakas: + bits.append(f"Yogakaraka 落在{yogakarakas}") + if not bits: + return None + return ';'.join(bits) + '。' + + def _calculation_profile_sentence() -> str | None: + bits = [] + if ayanamsa_display_text: + bits.append(f"岁差当前统一使用{ayanamsa_display_text}") + if node_mode_text: + bits.append(f"交点模式当前统一使用{node_mode_text}") + meta = birth_sheet.get('meta') if isinstance(birth_sheet.get('meta'), dict) else {} + warnings = meta.get('warnings') if isinstance(meta.get('warnings'), list) else [] + age_warning = next((str(item).strip() for item in warnings if '按 target_year 自动计算为' in str(item)), '') + if age_warning: + humanized = age_warning.replace('未提供年龄,', '当前样本未单独提供年龄,所以').replace('按 target_year 自动计算为', '报告先按目标年度自动换算为') + bits.append(humanized) + if not bits: + return None + return ';'.join(bits) + '。' + + def _strength_ready_sentence() -> str | None: + bits = [] + if strength_summary.get('has_shadbala'): + bits.append('Shadbala 力量层已经可读') + if strength_summary.get('has_ashtakavarga'): + bits.append('Ashtakavarga 已可用') + if strength_summary.get('has_vimsopaka'): + bits.append('Vimsopaka 分值层已到位') + if not bits: + return None + return ';'.join(bits) + '。' + + def _ashtakavarga_summary_sentence() -> str | None: + sav = ashtakavarga_layer.get('sav') if isinstance(ashtakavarga_layer.get('sav'), dict) else {} + assessment = sav.get('assessment') if isinstance(sav.get('assessment'), list) else [] + if not assessment: + return None + ranked = sorted( + [row for row in assessment if isinstance(row, dict) and row.get('score') is not None], + key=lambda row: float(row.get('score', 0)), + reverse=True, + ) + high = [] + low = [] + for row in ranked: + sign = _humanize_reader_token(row.get('sign')) + level = str(row.get('level') or '').strip() + if sign and len(high) < 2: + high.append(f"{sign}({level or '高分'})") + for row in reversed(ranked): + sign = _humanize_reader_token(row.get('sign')) + level = str(row.get('level') or '').strip() + if sign and len(low) < 2: + low.append(f"{sign}({level or '低分'})") + bits = [] + if high: + bits.append(f"SAV 高点当前集中在{'、'.join(high)}") + if low: + bits.append(f"低点落在{'、'.join(low)}") + if not bits: + return None + return ';'.join(bits) + '。' + + def _vimsopaka_summary_sentence() -> str | None: + candidates = [] + for planet, row in vimsopaka_layer.items(): + if not isinstance(row, dict): + continue + score = row.get('total_score') + category = str(row.get('category') or '').strip() + if score is None: + continue + candidates.append((float(score), _humanize_reader_token(planet), category)) + if not candidates: + return None + candidates.sort(reverse=True) + top = [] + for score, planet, category in candidates[:2]: + if planet: + if category: + top.append(f"{planet}({category},{score:.2f}/20)") + else: + top.append(f"{planet}({score:.2f}/20)") + if not top: + return None + return f"Vimsopaka 当前较突出的行星包括{'、'.join(top)}。" + + pushkara_layer = strength_sheet.get('pushkara') if isinstance(strength_sheet.get('pushkara'), dict) else {} + vargottama_layer = strength_sheet.get('vargottama') if isinstance(strength_sheet.get('vargottama'), dict) else {} + + def _pushkara_summary_sentence() -> str | None: + navamsa_hits = [] + bhaga_hits = [] + for planet, row in pushkara_layer.items(): + if not isinstance(row, dict): + continue + planet_text = _humanize_reader_token(planet) + if not planet_text: + continue + if row.get('pushkara_navamsa'): + navamsa_hits.append(planet_text) + if row.get('pushkara_bhaga'): + bhaga_hits.append(planet_text) + bits = [] + if navamsa_hits: + bits.append(f"Pushkara Navamsa 当前命中的行星包括{'、'.join(navamsa_hits[:3])}") + if bhaga_hits: + bits.append(f"Pushkara Bhaga 当前命中的行星包括{'、'.join(bhaga_hits[:3])}") + if not bits: + return None + return ';'.join(bits) + '。' + + def _vargottama_detail_sentence() -> str | None: + hits = [] + for planet, row in vargottama_layer.items(): + if not isinstance(row, dict) or not row.get('is_vargottama'): + continue + planet_text = _humanize_reader_token(planet) + if planet_text: + hits.append(planet_text) + if not hits: + return None + return f"Vargottama 当前已经明确落在{'、'.join(hits[:3])},这几颗星在本命与九分盘之间的表达会更直接。" + + + + annual_chart = annual.get('annual_chart') if isinstance(annual.get('annual_chart'), dict) else {} + annual_chart_data = annual_chart.get('data') if isinstance(annual_chart.get('data'), dict) else {} + annual_asc_text = _value_text( + annual_chart_data.get('asc_sign_cn'), + SIGNS_CN.get(annual_chart_data.get('asc_sign')) if annual_chart_data.get('asc_sign') in SIGNS_CN else None, + annual_chart_data.get('asc_sign'), + ) + + muntha = annual.get('muntha') if isinstance(annual.get('muntha'), dict) else {} + muntha_data = muntha.get('data') if isinstance(muntha.get('data'), dict) else {} + muntha_text = _value_text( + muntha_data.get('muntha_sign_cn'), + SIGNS_CN.get(muntha_data.get('muntha_sign')) if muntha_data.get('muntha_sign') in SIGNS_CN else None, + muntha_data.get('muntha_sign'), + ) + year_lord_field = annual.get('year_lord') if isinstance(annual.get('year_lord'), dict) else {} + year_lord_data = year_lord_field.get('data') if isinstance(year_lord_field.get('data'), dict) else {} + year_lord_text = _value_text( + _humanize_reader_token(year_lord_data.get('year_lord')) if year_lord_data.get('year_lord') else None, + _humanize_reader_token(year_lord_data.get('year_lord_sign')) if year_lord_data.get('year_lord_sign') else None, + ) + mudda_dasha = _envelope_dict(annual.get('mudda_dasha')) + mudda_periods = _envelope_list(mudda_dasha.get('periods')) + patyayini_dasha = _envelope_dict(annual.get('patyayini_dasha')) + patyayini_periods = _envelope_list(patyayini_dasha.get('periods')) + annual_external = _envelope_dict(annual.get('external_engine_comparison')) + pyjhora_replay = _envelope_dict(annual_external.get('pyjhora')) + replay_patyayini = _envelope_dict(pyjhora_replay.get('patyayini_dasha')) + replay_patyayini_raw = _envelope_dict(replay_patyayini.get('raw')) + replay_patyayini_periods = _envelope_list(replay_patyayini_raw.get('periods')) + replay_patyayini_rows = _envelope_list(replay_patyayini.get('normalized_rows')) + replay_sahams = _envelope_dict(pyjhora_replay.get('sahams')) + replay_sahams_raw = _envelope_dict(replay_sahams.get('raw')) + replay_saham_values = _envelope_dict(replay_sahams_raw.get('sahams')) + replay_saham_daynight = _envelope_dict(replay_sahams.get('daynight')) + monthly_windows = _envelope_list(annual.get('monthly_windows')) + tajika_yogas = _envelope_dict(annual.get('tajika_yogas')) + tajika_data = _envelope_dict(tajika_yogas.get('data')) + tajika_candidates = _envelope_list(tajika_data.get('candidate_yogas')) + if not tajika_candidates: + tajika_candidates = _envelope_list(tajika_data.get('rows')) + sahams = _envelope_dict(annual.get('sahams')) + + karakamsha = jaimini.get('karakamsha') if isinstance(jaimini.get('karakamsha'), dict) else {} + karakamsha_text = _value_text( + karakamsha.get('sign_cn'), + SIGNS_CN.get(karakamsha.get('sign')) if karakamsha.get('sign') in SIGNS_CN else None, + karakamsha.get('sign'), + ) + annual_profile = annual.get('profile') if isinstance(annual.get('profile'), dict) else {} + annual_target_year = annual_profile.get('target_year') or annual.get('target_year') + annual_heading = f"### {annual_target_year} 年度重点" if annual_target_year not in (None, '', [], {}) else '### 年度重点' + + annual_parts = [] + if annual_asc_text: + annual_parts.append(f"返照上升落在{annual_asc_text}") + if muntha_text: + annual_parts.append(f"Muntha 走到{muntha_text}") + + + + def _multi_reference_transit_section() -> list[str]: + transit = timing_sheet.get('transit_multi_reference') if isinstance(timing_sheet.get('transit_multi_reference'), dict) else {} + analysis = transit.get('transit_analysis') if isinstance(transit.get('transit_analysis'), dict) else {} + if not analysis: + return [] + out = [ + '### 行运四参考点(本地原始结构)', + '', + f"本表展示目标日 {_md_cell(transit.get('target_date'))} 的本地行运位置及相对于四个参考点的宫位。只保留位置与宫位编号,不采用 producer 内置的宫位意义或事件文本;PL9 行运页与日期/profile 的字段级对照未闭环,所有行保持 parameter_sensitive。", + '', + '| 行星 | 星座 | 星座内度数 | 从上升 | 从月亮 | 从 Arudha | 从 D9 上升 | 状态 |', + '|------|------|------------|--------|--------|-----------|------------|------|', + ] + reference_keys = ('Lagna', 'Chandra_Lagna', 'Arudha_Lagna', 'Navamsa_Lagna') + for planet in ('Jupiter', 'Saturn', 'Rahu', 'Ketu'): + row = analysis.get(planet) if isinstance(analysis.get(planet), dict) else {} + if not row: + continue + houses = row.get('house_from_ref') if isinstance(row.get('house_from_ref'), dict) else {} + values = [] + for key in reference_keys: + point = houses.get(key) if isinstance(houses.get(key), dict) else {} + values.append(point.get('house')) + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(row.get('sign_cn') or _humanize_reader_token(row.get('sign')))} | " + f"{_degree_text(row.get('degree_in_sign'))} | {' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |" + ) + out.append('') + return out + + def _yoga_interpretation_boundary_section() -> list[str]: + advanced_yoga = advanced_sheet.get('yoga') if isinstance(advanced_sheet.get('yoga'), dict) else {} + full_sections = full_report_pack.get('sections') if isinstance(full_report_pack.get('sections'), dict) else {} + yoga_section = full_sections.get('yoga') if isinstance(full_sections.get('yoga'), dict) else {} + dosha_section = full_sections.get('dosha') if isinstance(full_sections.get('dosha'), dict) else {} + detected = [] + seen = set() + for row in advanced_yoga.get('yogas', []) if isinstance(advanced_yoga.get('yogas'), list) else []: + if not isinstance(row, dict): + continue + identity = (row.get('rule_id'), row.get('name'), row.get('combination')) + if identity in seen: + continue + seen.add(identity) + detected.append(row) + out = [ + '### 本案 Yoga / Dosha 结构(本地检测)', + '', + '下表只列本次 packet 实际检测到的去重 Yoga。未完成 PL9 格局页的字段级对照,也不把自动解释或规则库通用含义当作本案确定结论,因此整节保持 parameter_sensitive。', + '', + f"- Yoga pack status: {_md_cell(_status_from_report_pack_section(yoga_section))};Dosha pack status: {_md_cell(_status_from_report_pack_section(dosha_section))}", + '- 数据来源:本仓 `yoga_engine` 的本案运行结果;不复制或重写 PL9 私有解释文本。', + ] + if detected: + out.extend(['', '| Yoga | 类别 | 强度 | 证据/组合 | 状态 |', '|------|------|------|-----------|------|']) + for row in detected[:20]: + name = row.get('name_cn') or row.get('name') or row.get('type') or row.get('id') + combo = row.get('combination') or row.get('source_reference') or row.get('reason') + out.append(f"| {_md_cell(name)} | {_md_cell(row.get('category') or row.get('subtype'))} | {_md_cell(row.get('strength'))} | {_md_cell(combo)} | parameter_sensitive |") + if len(detected) > 20: + out.append(f"| ... | ... | ... | 其余 {len(detected) - 20} 条保留在 Yoga pack 中。 | parameter_sensitive |") + else: + out.append('- 当前 packet 未携带可执行 Yoga rows,因此这一层保持 blocked/待接线;不会把规则库中的通用含义直接套到本案。') + out.append('') + return out + + d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} + d1_summary = d1_sheet.get('summary_card') if isinstance(d1_sheet.get('summary_card'), dict) else {} + ascendant = d1_summary.get('ascendant') if isinstance(d1_summary.get('ascendant'), dict) else {} + asc_text = _value_text(ascendant.get('sign_cn'), ascendant.get('sign')) + + annual_chart = annual.get('annual_chart') if isinstance(annual.get('annual_chart'), dict) else {} + annual_chart_data = annual_chart.get('data') if isinstance(annual_chart.get('data'), dict) else {} + annual_asc_text = _value_text( + annual_chart_data.get('asc_sign_cn'), + SIGNS_CN.get(annual_chart_data.get('asc_sign')) if annual_chart_data.get('asc_sign') in SIGNS_CN else None, + annual_chart_data.get('asc_sign'), + ) + + muntha = annual.get('muntha') if isinstance(annual.get('muntha'), dict) else {} + muntha_data = muntha.get('data') if isinstance(muntha.get('data'), dict) else {} + muntha_text = _value_text( + muntha_data.get('muntha_sign_cn'), + SIGNS_CN.get(muntha_data.get('muntha_sign')) if muntha_data.get('muntha_sign') in SIGNS_CN else None, + muntha_data.get('muntha_sign'), + ) + year_lord_field = annual.get('year_lord') if isinstance(annual.get('year_lord'), dict) else {} + year_lord_data = year_lord_field.get('data') if isinstance(year_lord_field.get('data'), dict) else {} + year_lord_text = _value_text( + _humanize_reader_token(year_lord_data.get('year_lord')) if year_lord_data.get('year_lord') else None, + _humanize_reader_token(year_lord_data.get('year_lord_sign')) if year_lord_data.get('year_lord_sign') else None, + ) + mudda_dasha = _envelope_dict(annual.get('mudda_dasha')) + mudda_periods = _envelope_list(mudda_dasha.get('periods')) + patyayini_dasha = _envelope_dict(annual.get('patyayini_dasha')) + patyayini_periods = _envelope_list(patyayini_dasha.get('periods')) + annual_external = _envelope_dict(annual.get('external_engine_comparison')) + pyjhora_replay = _envelope_dict(annual_external.get('pyjhora')) + replay_patyayini = _envelope_dict(pyjhora_replay.get('patyayini_dasha')) + replay_patyayini_raw = _envelope_dict(replay_patyayini.get('raw')) + replay_patyayini_periods = _envelope_list(replay_patyayini_raw.get('periods')) + replay_patyayini_rows = _envelope_list(replay_patyayini.get('normalized_rows')) + monthly_windows = _envelope_list(annual.get('monthly_windows')) + tajika_yogas = _envelope_dict(annual.get('tajika_yogas')) + tajika_data = _envelope_dict(tajika_yogas.get('data')) + tajika_candidates = _envelope_list(tajika_data.get('candidate_yogas')) + if not tajika_candidates: + tajika_candidates = _envelope_list(tajika_data.get('rows')) + sahams = _envelope_dict(annual.get('sahams')) + + karakamsha = jaimini.get('karakamsha') if isinstance(jaimini.get('karakamsha'), dict) else {} + karakamsha_text = _value_text( + karakamsha.get('sign_cn'), + SIGNS_CN.get(karakamsha.get('sign')) if karakamsha.get('sign') in SIGNS_CN else None, + karakamsha.get('sign'), + ) + annual_parts = [] + if annual_asc_text: + annual_parts.append(f"返照上升落在{annual_asc_text}") + if muntha_text: + annual_parts.append(f"Muntha 走到{muntha_text}") + + lines.extend([ + '', + '## 个人报告正文', + '', + '## 摘要', + '', + '本报告正文先给出基础资料、图盘、年度重点和主题章节;后文计算口径与阻塞审计仅用于核对边界。', + '', + ]) + lines.extend(_pl9_core_structure_tables()) + lines.extend([ + '### Birth Chart / Vargas', + '', + ]) + d1_svg = _d1_chart_svg() + if d1_svg: + lines.extend(['#### D1 — Rashi Chart(本命盘)', '', d1_svg, '']) + d9_svg = _d9_chart_svg() + if d9_svg: + lines.extend(['#### D9 — Navamsa(婚盘)', '', d9_svg, '']) + moon_chart_section = _moon_chart_section() + if moon_chart_section: + lines.extend(moon_chart_section) + sudarshana_section = _sudarshana_section() + if sudarshana_section: + lines.extend(sudarshana_section) + visual_chart_evidence_section = _visual_chart_evidence_section() + if visual_chart_evidence_section: + lines.extend(visual_chart_evidence_section) + if not d1_svg and not d9_svg: + lines.extend(['_当前 packet 中没有足够的图盘数据可渲染。_', '']) + if include_pl9_evidence_tables: + chart_atlas = _chart_atlas_section() + if chart_atlas: + lines.extend(chart_atlas) + upagraha_section = _upagraha_section() + if upagraha_section: + lines.extend(upagraha_section) + standard_lagnas_section = _standard_lagnas_section() + if standard_lagnas_section: + lines.extend(standard_lagnas_section) + sensitive_points_section = _sensitive_points_section() + if sensitive_points_section: + lines.extend(sensitive_points_section) + kp_lord_sub_section = _kp_lord_sub_section() + if kp_lord_sub_section: + lines.extend(kp_lord_sub_section) + support_segment_ids = rich_support.get('segment_ids') if isinstance(rich_support.get('segment_ids'), list) else [] + if support_segment_ids: + lines.extend([ + '### 补充阅读提示', + '', + _reader_group_paragraph('补充阅读重点', support_segment_ids), + '', + ]) + lines.extend([ + '### 基础命盘锚点', + '', + ]) + if asc_text: + lines.append(f"本命上升:{asc_text}。") + if karakamsha_text: + lines.append(f"Karakamsha:{karakamsha_text}。") + + lines.extend([ + '', + annual_heading.replace('年度重点', '年度计算锚点'), + '', + ]) + annual_report_sections = _envelope_dict(annual.get('report_sections')) + annual_exec_summary = _envelope_dict(annual_report_sections.get('executive_summary')) + annual_summary_lines = _envelope_list(annual_exec_summary.get('summary_lines')) + annual_thematic_narrative = _envelope_dict(annual_report_sections.get('thematic_narrative')) + annual_highlights = _envelope_list(annual_thematic_narrative.get('highlights')) + annual_evidence_appendix = _envelope_dict(annual_report_sections.get('evidence_appendix')) + annual_field_briefs = _envelope_dict(annual_evidence_appendix.get('field_briefs')) + + def _append_unique_text(items: list[str], value: str) -> None: + text = str(value or '').strip().rstrip('。') + if text and text not in items: + items.append(text) + + def _reader_executive_summary() -> dict: + ai_audit = packet.get('ai_and_audit') if isinstance(packet.get('ai_and_audit'), dict) else {} + worksheet_ai = worksheets.get('ai_and_audit') if isinstance(worksheets.get('ai_and_audit'), dict) else {} + for candidate in (ai_audit.get('summary'), worksheet_ai.get('summary')): + if not isinstance(candidate, dict): + continue + executive_summary = candidate.get('executive_summary') + if isinstance(executive_summary, dict): + return executive_summary + return {} + + def _render_key_time_nodes() -> list[str]: + executive_summary = _reader_executive_summary() + raw_nodes = executive_summary.get('key_time_nodes') if isinstance(executive_summary.get('key_time_nodes'), list) else [] + rows: list[str] = [] + seen: set[tuple[str, str, str]] = set() + for raw_node in raw_nodes[:5]: + if not isinstance(raw_node, dict): + continue + label = str(raw_node.get('label') or raw_node.get('title') or '').strip() + window = str(raw_node.get('window') or raw_node.get('date_range') or '').strip() + if not label and not window: + continue + status = str(raw_node.get('status') or 'parameter_sensitive').strip() + identity = (label, window, status) + if identity in seen: + continue + seen.add(identity) + parts = [] + theme = str(raw_node.get('theme') or '').strip() + if theme: + parts.append(f"主题:{_humanize_reader_token(theme)}") + parts.append(f"状态:{status}") + basis = str(raw_node.get('basis') or '').strip() + if basis: + parts.append(f"依据:{basis}") + trigger_condition = str(raw_node.get('trigger_condition') or '').strip() + if trigger_condition: + parts.append(f"触发条件:{trigger_condition}") + verification_hint = str(raw_node.get('verification_hint') or '').strip() + if verification_hint: + parts.append(f"回验:{verification_hint}") + headline = label + if window: + headline = f"{headline}:{window}" if headline else window + rows.append(f"- {headline}({';'.join(parts)})") + return rows + + def _language_bridge_frontmatter_section() -> list[str]: + bridge_packet = packet.get('startrack_language_bridge') if isinstance(packet.get('startrack_language_bridge'), dict) else {} + if bridge_packet.get('status') != 'used': + return [] + bridge = bridge_packet.get('bridge') if isinstance(bridge_packet.get('bridge'), dict) else {} + if not bridge: + return [] + out = [ + '### 中文表达辅助层', + '', + '这一层只负责把专业结果整理成更符合中文逻辑的读法,不改变排盘、分盘、大运、原始数值或 blocked / parameter_sensitive 等证据标签。', + '', + ] + top_times = bridge.get('top_candidate_times') if isinstance(bridge.get('top_candidate_times'), list) else [] + if top_times: + out.append(f"- 语言桥当前最优先提示的候选时间锚点:{'、'.join(str(item) for item in top_times[:3])}") + executive_summary = bridge.get('executive_summary') if isinstance(bridge.get('executive_summary'), list) else [] + for line in executive_summary[:3]: + text = str(line or '').strip() + if text: + out.append(f"- 中文摘要:{text}") + candidate_packets = bridge.get('candidate_language_packets') if isinstance(bridge.get('candidate_language_packets'), list) else [] + for packet_row in candidate_packets[:2]: + if not isinstance(packet_row, dict): + continue + headline = str(packet_row.get('headline') or '').strip() + rationale = str(packet_row.get('why_it_matters') or packet_row.get('summary') or '').strip() + if headline and rationale: + out.append(f"- 读法提示:{headline};{rationale}") + elif headline: + out.append(f"- 读法提示:{headline}") + phrase_bank = bridge.get('reusable_phrase_bank') if isinstance(bridge.get('reusable_phrase_bank'), list) else [] + if phrase_bank: + phrases = [str(item).strip() for item in phrase_bank[:5] if str(item).strip()] + if phrases: + out.append(f"- 可复用中文表达:{';'.join(phrases)}") + ziwei_audit = ( + bridge_packet.get('ziwei_doushu_audit') + if isinstance(bridge_packet.get('ziwei_doushu_audit'), dict) + else {} + ) + qizheng_pack = ( + ziwei_audit.get('qizheng_practical_rule_pack') + if isinstance(ziwei_audit.get('qizheng_practical_rule_pack'), dict) + else {} + ) + if qizheng_pack: + out.extend( + [ + "- 七政实用素材辅助层:" + f"知识点 {qizheng_pack.get('rule_count', 0)}、" + f"主题素材 {qizheng_pack.get('theme_material_count', 0)}、" + f"可进报告素材 {qizheng_pack.get('theme_material_report_ready_count', 0)}、" + f"runtime 可晋级 {qizheng_pack.get('runtime_promotable_count', 0)}。", + ] + ) + theme_materials = ( + qizheng_pack.get('theme_materials') + if isinstance(qizheng_pack.get('theme_materials'), list) + else [] + ) + for material in theme_materials[:3]: + if not isinstance(material, dict): + continue + title = str(material.get('title') or '').strip() + summary = str(material.get('summary_line') or '').strip() + tier = str(material.get('evidence_tier') or '').strip() + if title and summary: + out.append(f"- 七政素材:{title}({tier}):{summary}") + qizheng_boundary = str(qizheng_pack.get('language_bridge_boundary') or '').strip() + if qizheng_boundary: + out.append(f"- 七政素材边界:{qizheng_boundary}") + boundary = str(bridge_packet.get('boundary') or '').strip() + if boundary: + out.append(f"- 边界:{boundary}") + out.append('') + return out + + def _strength_ashtakavarga_section() -> list[str]: + strength_sheet = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {} + shadbala = strength_sheet.get('shadbala') if isinstance(strength_sheet.get('shadbala'), dict) else {} + shadbala_component_status = strength_sheet.get('shadbala_component_status') if isinstance(strength_sheet.get('shadbala_component_status'), dict) else {} + bhava_bala = strength_sheet.get('bhava_bala') if isinstance(strength_sheet.get('bhava_bala'), dict) else {} + ashtakavarga = strength_sheet.get('ashtakavarga') if isinstance(strength_sheet.get('ashtakavarga'), dict) else {} + vimsopaka = strength_sheet.get('vimsopaka') if isinstance(strength_sheet.get('vimsopaka'), dict) else {} + pushkara = strength_sheet.get('pushkara') if isinstance(strength_sheet.get('pushkara'), dict) else {} + vargottama = strength_sheet.get('vargottama') if isinstance(strength_sheet.get('vargottama'), dict) else {} + lagna_vargottamamsa = strength_sheet.get('lagna_vargottamamsa') if isinstance(strength_sheet.get('lagna_vargottamamsa'), dict) else {} + functional = strength_sheet.get('functional_benefic_malefic') if isinstance(strength_sheet.get('functional_benefic_malefic'), dict) else {} + friendship = strength_sheet.get('planetary_friendship') if isinstance(strength_sheet.get('planetary_friendship'), dict) else {} + if not shadbala and not bhava_bala and not ashtakavarga and not vimsopaka and not functional and not friendship: + return [] + out = [ + '### 力量、Ashtakavarga 与功能性吉凶', + '', + ] + if ashtakavarga: + sav = ashtakavarga.get('sav') if isinstance(ashtakavarga.get('sav'), dict) else {} + out.extend([ + '#### Ashtakavarga / SAV 宫位承接力', + '', + f"- 方法:{_md_cell(ashtakavarga.get('method'))};版本:{_md_cell(ashtakavarga.get('version'))};BAV 校验:{'全部通过' if ashtakavarga.get('all_bav_valid') else '需复核'}", + f"- 最强星座:{_md_cell('、'.join(ashtakavarga.get('strongest_signs', [])) if isinstance(ashtakavarga.get('strongest_signs'), list) else '-')}", + f"- 最弱星座:{_md_cell('、'.join(ashtakavarga.get('weakest_signs', [])) if isinstance(ashtakavarga.get('weakest_signs'), list) else '-')}", + '', + '| 宫位 | 星座 | SAV | 等级 |', + '|------|------|-----|------|', + ]) + house_scores = ashtakavarga.get('house_scores') if isinstance(ashtakavarga.get('house_scores'), dict) else {} + for idx in range(1, 13): + row = house_scores.get(f'house_{idx}') if isinstance(house_scores.get(f'house_{idx}'), dict) else {} + if row: + out.append(f"| {idx} | {_md_cell(_humanize_reader_token(row.get('sign')))} | {_md_cell(row.get('sav_score'))} | {_md_cell(row.get('level'))} |") + bav_validation = ashtakavarga.get('bav_validation') if isinstance(ashtakavarga.get('bav_validation'), list) else [] + if bav_validation: + out.extend(['', '| BAV 项 | 实际 | 预期 | 状态 |', '|--------|------|------|------|']) + for row in bav_validation: + if isinstance(row, dict): + out.append(f"| {_md_cell(_humanize_reader_token(row.get('planet')))} | {_md_cell(row.get('actual'))} | {_md_cell(row.get('expected'))} | {_md_cell(row.get('status'))} |") + bav = ashtakavarga.get('bav') if isinstance(ashtakavarga.get('bav'), dict) else {} + if bav: + out.extend([ + '', + '#### Bhinnashtakavarga 原始 Bindu 表', + '', + '总分已与 PL9 受控样本核对;12 星座逐格绑定仍为 parameter_sensitive,不能据此声称图表级全量 parity。', + '', + '| 来源 | 白羊 | 金牛 | 双子 | 巨蟹 | 狮子 | 处女 | 天秤 | 天蝎 | 射手 | 摩羯 | 水瓶 | 双鱼 | 总分 | 状态 |', + '|------|------|------|------|------|------|------|------|------|------|------|------|------|------|------|', + ]) + for source in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna'): + row = bav.get(source) if isinstance(bav.get(source), dict) else {} + bindus = row.get('bindus') if isinstance(row.get('bindus'), list) else [] + values = [str(value) for value in bindus[:12]] + values.extend(['-'] * (12 - len(values))) + out.append( + f"| {_md_cell(_humanize_reader_token(source))} | {' | '.join(_md_cell(value) for value in values)} | " + f"{_md_cell(row.get('total'))} | parameter_sensitive |" + ) + shodhya_pinda = ashtakavarga.get('shodhya_pinda') if isinstance(ashtakavarga.get('shodhya_pinda'), dict) else {} + if shodhya_pinda: + out.extend([ + '', + '#### Shodhya / Yoga Pinda(本地补充分值)', + '', + '本表透传本地 Bindu 权重计算的 Rashi/Graha/Yoga Pinda。不等同于 PL9 第 54 页的 Trikona / Ekadhipatya reduction 前后矩阵;该页逐阶段结构仍未闭环,所有行仅作 parameter_sensitive 补充数据。', + '', + '| 行星 | Rashi Pinda | Graha Pinda | Yoga Pinda | 本命星座 Bindu | 状态 |', + '|------|-------------|-------------|------------|----------------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): + row = shodhya_pinda.get(planet) if isinstance(shodhya_pinda.get(planet), dict) else {} + if row: + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(row.get('rashi_pinda'))} | " + f"{_md_cell(row.get('graha_pinda'))} | {_md_cell(row.get('total_pinda'))} | " + f"{_md_cell(row.get('bindu_at_own_sign'))} | parameter_sensitive |" + ) + sodhita = ashtakavarga.get('sodhita') if isinstance(ashtakavarga.get('sodhita'), dict) else {} + sodhita_sav = sodhita.get('sodhita_sav') if isinstance(sodhita.get('sodhita_sav'), dict) else {} + sodhita_rows = sodhita_sav.get('assessment') if isinstance(sodhita_sav.get('assessment'), list) else [] + if sodhita_rows: + out.extend([ + '', + '#### Sodhita Ashtakavarga(本地减分结果)', + '', + '本表复用既有本地 Sodhita producer,按该 producer 的 Sun / Mars / Saturn 贡献扣减生成。它不等同于 PL9 第 54 页的 Trikona / Ekadhipatya reduction 前后矩阵;逐阶段规则、矩阵与外部数值 parity 未闭环,所有行保持 parameter_sensitive。', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| 方法 | {_md_cell(sodhita.get('method'))} | parameter_sensitive |", + f"| 总分 | {_md_cell(sodhita_sav.get('total'))} | parameter_sensitive |", + '', + '| 星座 | Sodhita SAV | 本地等级 | 状态 |', + '|------|-------------|----------|------|', + ]) + for row in sodhita_rows: + if isinstance(row, dict): + out.append( + f"| {_md_cell(_humanize_reader_token(row.get('sign')))} | {_md_cell(row.get('score'))} | " + f"{_md_cell(row.get('level'))} | parameter_sensitive |" + ) + pav = ashtakavarga.get('pav') if isinstance(ashtakavarga.get('pav'), dict) else {} + pav_summary = pav.get('pav_summary') if isinstance(pav.get('pav_summary'), dict) else {} + if pav_summary: + out.extend([ + '', + '#### Prastara Ashtakavarga(本地贡献来源汇总)', + '', + '本表汇总本地 PAV producer 中每颗目标行星由七星与上升提供的 Bindu 计数。该汇总可重建本地 BAV 行总分,但不是 PL9 页面同构表;逐格贡献矩阵和外部数值 parity 未闭环,所有行保持 parameter_sensitive。', + '', + '| 目标行星 | 太阳 | 月亮 | 火星 | 水星 | 木星 | 金星 | 土星 | 上升 | 状态 |', + '|----------|------|------|------|------|------|------|------|------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): + row = pav_summary.get(planet) if isinstance(pav_summary.get(planet), dict) else {} + if row: + values = [row.get(source) for source in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna')] + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | " + f"{' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |" + ) + out.append('') + if shadbala: + out.extend([ + '#### Shadbala 六重力量摘要', + '', + f"- 状态:{_md_cell(shadbala.get('status'))};最强:{_md_cell(_humanize_reader_token(shadbala.get('strongest')))};最弱:{_md_cell(_humanize_reader_token(shadbala.get('weakest')))}", + f"- 口径:本地 Shadbala 计算口径。下表绝对 Rupas 尚未与原始 PL9 同源校准,字段状态标为 parameter_sensitive;SB 使用本地 Ishta Bala 百分比换算。", + f"- 本地总分:{_md_cell(shadbala.get('total_shadbala'))} / 本地最低需求 { _md_cell(shadbala.get('total_min_required')) }", + ]) + planets = shadbala.get('planets') if isinstance(shadbala.get('planets'), dict) else {} + if planets: + out.extend(['', '| 行星 | Local Rupas | Local Min Required | SB | Relative Rank | Field Status |', '|------|-------------|--------------------|----|---------------|--------------|']) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']: + row = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + if row: + total = row.get('total_rupas') or row.get('total_shadbala') or row.get('total') + try: + sb_ratio = round(float(row.get('ishta_bala_pct')) / 100.0, 2) + except (TypeError, ValueError): + sb_ratio = None + out.append(f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(total)} | {_md_cell(row.get('min_required'))} | {_md_cell(sb_ratio)} | {_md_cell(row.get('rank'))} | parameter_sensitive |") + out.extend([ + '', + '#### Shadbala 六项原始分量(本地 Virupas)', + '', + '以下为本地计算的六项原始分量,供与 PL9 第 43–44 页逐字段核对。Chesta 方法变体尚未完成外部仲裁,且绝对 Virupas 尚未完成同源校准,因此所有行保持 parameter_sensitive。', + '', + '| 行星 | Sthana | Dig | Kala | Chesta | Naisargika | Drik | Total Virupas | 状态 |', + '|------|--------|-----|------|--------|-------------|------|----------------|------|', + ]) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']: + row = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + if not row: + continue + sthana = row.get('sthana_bala') if isinstance(row.get('sthana_bala'), dict) else {} + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(sthana.get('total'))} | {_md_cell(row.get('dig_bala'))} | " + f"{_md_cell((row.get('kala_bala') or {}).get('total') if isinstance(row.get('kala_bala'), dict) else None)} | " + f"{_md_cell(row.get('chesta_bala'))} | {_md_cell(row.get('naisargika_bala'))} | {_md_cell(row.get('drik_bala'))} | " + f"{_md_cell(row.get('total_virupas'))} | parameter_sensitive |" + ) + if shadbala_component_status: + out.extend([ + '', + '#### Shadbala 分量证据状态', + '', + '这不是本案绝对分值的第二套计算,而是仓内既有 42 行组件级外部证据快照。42 个分量观测行中 7 行达到观察闭合(Naisargika);其余分量仍存在公式、单位或方法变体仲裁。绝对 Virupas 真值仍未闭环,不能据此升级上表状态。', + '', + '| 证据项 | 数值 | 状态 |', + '|--------|------|------|', + f"| 组件观测行 | {_md_cell(shadbala_component_status.get('row_count'))} | {_md_cell(shadbala_component_status.get('status'))} |", + f"| 观察闭合行 | {_md_cell(shadbala_component_status.get('closed_observation_row_count'))} | Naisargika observation-only |", + f"| 未闭合/需仲裁行 | {_md_cell(shadbala_component_status.get('blocked_or_unresolved_row_count'))} | formula_or_unit_arbitration_required |", + f"| 绝对真值矩阵 | {'允许' if shadbala_component_status.get('truth_matrix_allowed') else '不允许'} | {_md_cell(shadbala_component_status.get('claim_boundary'))} |", + ]) + core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} + natal_planets = core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {} + out.extend([ + '', + '#### Declination 支持数据(Ayana Bala 本地中间量)', + '', + '本表展示本地 Shadbala Ayana Bala 计算使用的 declination 中间量,以及同次本命速度和 KP Lord/Sub。它不是 PL9 p33 的 Declination / Kranti 表复刻:Kranti 尚未有独立 producer,速度和 KP profile 也仍待字段级闭环。', + '', + '| 行星 | 本命度数 | 本地 Declination | 速度 | NL / SL | 状态 |', + '|------|----------|-------------------|------|---------|------|', + ]) + for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu']: + strength = planets.get(planet) if isinstance(planets.get(planet), dict) else {} + natal = natal_planets.get(planet) if isinstance(natal_planets.get(planet), dict) else {} + kala = strength.get('kala_bala') if isinstance(strength.get('kala_bala'), dict) else {} + if not natal: + continue + local_degree = natal.get('local_degree_in_sign') if natal.get('local_degree_in_sign') is not None else natal.get('degree_in_sign') + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_degree_text(local_degree)} | " + f"{_md_cell(kala.get('declination'))} | {_md_cell(natal.get('speed'))} | " + f"{_md_cell(_humanize_reader_token(natal.get('nakshatra_lord')))} / {_md_cell(_humanize_reader_token(natal.get('sub_lord')))} | parameter_sensitive |" + ) + out.append('') + if bhava_bala: + rows = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] + if rows: + out.extend([ + '#### Bhava Bala(本地三分量评分)', + '', + '该表是本地宫主位置、宫内行星与相位影响的三分量评分;不是 PL9 同口径的 Bhava Bala Rupas 表。PL9 第 31–32 页的字段级数值对照尚未闭环,所有行保持 parameter_sensitive。', + '', + '| Bhava | 星座 | 宫主 | Lord | Occupant | Aspect | Local Score | 状态 |', + '|-------|------|------|------|----------|--------|-------------|------|', + ]) + for row in rows: + if not isinstance(row, dict): + continue + components = row.get('components') if isinstance(row.get('components'), dict) else {} + out.append( + f"| {_md_cell(row.get('house'))} | {_md_cell(_humanize_reader_token(row.get('sign')))} | " + f"{_md_cell(_humanize_reader_token(row.get('lord')))} | {_md_cell(components.get('lord_position'))} | " + f"{_md_cell(components.get('occupant_influence'))} | {_md_cell(components.get('aspect_influence'))} | " + f"{_md_cell(row.get('score'))} | parameter_sensitive |" + ) + out.append('') + if vimsopaka: + out.extend([ + '#### Vimsopaka 十六分盘力量摘要', + '', + 'PL9 第 42 页的星座行已另有 fixture 对照,但尊严格与 Vimshopaka 分值仍未字段级闭环;下表仅保留本地 shodasavarga 计算结果,状态为 parameter_sensitive。', + '', + '| 行星 | Local Score / 20 | 本地类别 | 状态 |', + '|------|------------------|----------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = vimsopaka.get(planet) if isinstance(vimsopaka.get(planet), dict) else {} + if row: + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(row.get('total_score'))} | " + f"{_md_cell(row.get('category'))} | parameter_sensitive |" + ) + out.append('') + if pushkara or vargottama or lagna_vargottamamsa: + out.extend([ + '#### Pushkara / Vargottama 分盘支持标记', + '', + '本表仅透传本地分盘规则结果,不套用解释模板或将其当作 PL9 页面文字复刻。Pushkara 范围、D1-D9 同座和 Lagna Vargottamamsa 的跨引擎字段级对照尚未闭环,所有行保持 parameter_sensitive。', + '', + '| 行星 | D1 星座 | D9 星座 | Vargottama | Pushkara Navamsa | Pushkara Bhaga | 状态 |', + '|------|---------|---------|-------------|-------------------|----------------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + varga_row = vargottama.get(planet) if isinstance(vargottama.get(planet), dict) else {} + pushkara_row = pushkara.get(planet) if isinstance(pushkara.get(planet), dict) else {} + if not varga_row and not pushkara_row: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(_humanize_reader_token(varga_row.get('d1_sign')))} | " + f"{_md_cell(_humanize_reader_token(varga_row.get('d9_sign')))} | {'是' if varga_row.get('is_vargottama') else '否'} | " + f"{'是' if pushkara_row.get('pushkara_navamsa') else '否'} | {'是' if pushkara_row.get('pushkara_bhaga') else '否'} | parameter_sensitive |" + ) + if lagna_vargottamamsa: + out.append( + f"| 上升 | {_md_cell(_humanize_reader_token(lagna_vargottamamsa.get('d1_sign')))} | " + f"{_md_cell(_humanize_reader_token(lagna_vargottamamsa.get('d9_sign')))} | " + f"{'是' if lagna_vargottamamsa.get('is_lagna_vargottamamsa') else '否'} | - | - | parameter_sensitive |" + ) + out.append('') + if functional: + out.extend([ + '#### 功能性吉凶与 Yogakaraka', + '', + f"- 上升:{_md_cell(_humanize_reader_token(functional.get('ascendant')))};Yogakaraka:{_md_cell('、'.join(map(_humanize_reader_token, functional.get('yogakarakas', []))) if isinstance(functional.get('yogakarakas'), list) else '-')}", + f"- 功能吉星:{_md_cell('、'.join(map(_humanize_reader_token, functional.get('functional_benefics', []))) if isinstance(functional.get('functional_benefics'), list) else '-')}", + f"- 功能凶星:{_md_cell('、'.join(map(_humanize_reader_token, functional.get('functional_malefics', []))) if isinstance(functional.get('functional_malefics'), list) else '-')}", + '', + ]) + if friendship.get('rows'): + out.extend([ + '#### 行星友好关系(本地 Naisargika / Tatkalika / Panchadha)', + '', + '本表仅将仓内既有永久友敌常量与临时星座距离规则透传为可审计原始关系,不生成事件判断或确定性预测。PL9 第 41 页的节点关系口径尚未完成字段级对照,所有行保持 parameter_sensitive。', + '', + '| 行星 | 天然朋友 | 天然敌人 | 天然中性 | 临时朋友 | 临时敌人 | 五重关系摘要 | 状态 |', + '|------|----------|----------|----------|----------|----------|--------------|------|', + ]) + + def _friendship_list(values) -> str: + if not isinstance(values, list) or not values: + return '-' + return '、'.join(_humanize_reader_token(value) for value in values) + + def _compound_summary(values) -> str: + if not isinstance(values, dict): + return '-' + labels = ( + ('极友', 'great_friends'), + ('友', 'friends'), + ('中性', 'neutral'), + ('敌', 'enemies'), + ('极敌', 'great_enemies'), + ) + parts = [f"{label}:{_friendship_list(values.get(key))}" for label, key in labels if values.get(key)] + return ';'.join(parts) if parts else '-' + + for row in friendship['rows']: + if not isinstance(row, dict): + continue + natural = row.get('natural') if isinstance(row.get('natural'), dict) else {} + temporary = row.get('temporary') if isinstance(row.get('temporary'), dict) else {} + out.append( + f"| {_md_cell(_humanize_reader_token(row.get('planet')))} | {_md_cell(_friendship_list(natural.get('friends')))} | " + f"{_md_cell(_friendship_list(natural.get('enemies')))} | {_md_cell(_friendship_list(natural.get('neutral')))} | " + f"{_md_cell(_friendship_list(temporary.get('friends')))} | {_md_cell(_friendship_list(temporary.get('enemies')))} | " + f"{_md_cell(_compound_summary(row.get('compound')))} | parameter_sensitive |" + ) + out.append('') + return out + + def _auxiliary_dasha_section() -> list[str]: + dasha_master_pack = timing_sheet.get('dasha_master_pack') if isinstance(timing_sheet.get('dasha_master_pack'), dict) else {} + families = dasha_master_pack.get('families') if isinstance(dasha_master_pack.get('families'), dict) else {} + if not families: + return [] + + def _period_start(period: dict) -> str: + raw = period.get('raw_period') if isinstance(period.get('raw_period'), dict) else {} + return _md_cell(period.get('start') or raw.get('start_date')) + + def _period_end(period: dict) -> str: + raw = period.get('raw_period') if isinstance(period.get('raw_period'), dict) else {} + return _md_cell(period.get('end') or raw.get('end_date')) + + def _period_years(period: dict) -> str: + raw = period.get('raw_period') if isinstance(period.get('raw_period'), dict) else {} + return _md_cell(period.get('years') if period.get('years') is not None else raw.get('years')) + + def _balance_text(balance: dict, lord_key: str = 'planet') -> str: + if not isinstance(balance, dict) or not balance: + return '' + lord = balance.get(lord_key) or balance.get('rashi') or balance.get('lord') + bits = [] + if balance.get('years_whole') is not None: + bits.append(f"{balance.get('years_whole')} 年") + if balance.get('months_whole') is not None: + bits.append(f"{balance.get('months_whole')} 月") + if balance.get('days_whole') is not None: + bits.append(f"{balance.get('days_whole')} 日") + display = f"{_humanize_reader_token(lord)},{' '.join(bits)}" if lord and bits else '' + if balance.get('display_calendar'): + display += f"({balance.get('display_calendar')})" + return display + + def _parse_vimshottari_dt(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + pass + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return None + + def _find_period_covering(timeline: list[dict] | None, target_dt: datetime | None) -> dict | None: + if not isinstance(timeline, list) or target_dt is None: + return None + for period in timeline: + if not isinstance(period, dict): + continue + start_dt = _parse_vimshottari_dt(period.get('start_datetime') or period.get('start')) + end_dt = _parse_vimshottari_dt(period.get('end_datetime') or period.get('end')) + if start_dt is None or end_dt is None: + continue + if start_dt <= target_dt < end_dt: + return period + return None + + def _format_vimshottari_balance_ymd(start: str | None, end: str | None, *, end_exclusive: bool = True) -> str: + start_dt = _parse_vimshottari_dt(start) + end_dt = _parse_vimshottari_dt(end) + if start_dt is None or end_dt is None or end_dt < start_dt: + return '' + start_date = start_dt.date() + end_date = end_dt.date() + if end_exclusive and end_date > start_date: + end_date -= timedelta(days=1) + if end_date < start_date: + return '' + years = end_date.year - start_date.year + months = end_date.month - start_date.month + days = end_date.day - start_date.day + if days < 0: + months -= 1 + prev_month = end_date.month - 1 or 12 + prev_year = end_date.year if end_date.month > 1 else end_date.year - 1 + days += calendar.monthrange(prev_year, prev_month)[1] + if months < 0: + years -= 1 + months += 12 + return f"{years}y {months}m {days}d" + + def _format_vimshottari_birth_chain(*rows: dict | None, humanized: bool = True) -> str: + labels = [] + for row in rows: + if not isinstance(row, dict): + continue + raw_lord = row.get('lord') + lord = _humanize_reader_token(raw_lord) if humanized else _md_cell(raw_lord) + if lord and lord not in {'-', ''}: + labels.append(lord) + return '-'.join(labels) + + now = datetime.now() + out: list[str] = [] + yogini = families.get('yogini') if isinstance(families.get('yogini'), dict) else {} + yogini_periods = yogini.get('periods') if isinstance(yogini.get('periods'), list) else [] + if yogini_periods: + out.extend([ + '#### Yogini Dasha 周期(本地计算)', + '', + '以下读取 canonical Dasha timing data 中的 Yogini 周期。PL9 第 80-82 页的起始规则、年常数与边界尚未完成外部 parity,因此只保留周期表,全部维持 parameter_sensitive。', + '', + '| 序号 | Yogini | 行星 | 起始 | 结束 | 年数 | 状态 |', + '|------|--------|------|------|------|------|------|', + ]) + for idx, period in enumerate(yogini_periods, start=1): + if not isinstance(period, dict): + continue + raw = period.get('raw_period') if isinstance(period.get('raw_period'), dict) else {} + yogini_name = raw.get('yogini') or (period.get('lord_hierarchy') or [None, None])[-1] + planet = period.get('lord') or raw.get('planet') + out.append( + f"| {idx} | {_md_cell(yogini_name)} | {_md_cell(_humanize_reader_token(planet))} | {_period_start(period)} | {_period_end(period)} | {_period_years(period)} | parameter_sensitive |" + ) + out.append('') + + vimshottari = timing_sheet.get('dasha') if isinstance(timing_sheet.get('dasha'), dict) else {} + vimshottari_timeline = vimshottari.get('timeline') if isinstance(vimshottari.get('timeline'), list) else [] + if vimshottari_timeline: + current = vimshottari.get('current_dasha') if isinstance(vimshottari.get('current_dasha'), dict) else {} + birth_balance_period = next((period for period in vimshottari_timeline if isinstance(period, dict) and period.get('is_balance')), None) + birth_dt = _parse_vimshottari_dt(vimshottari.get('birth_datetime')) + birth_md = _find_period_covering(vimshottari_timeline, birth_dt) or birth_balance_period + birth_ad = _find_period_covering(birth_md.get('antardasha_timeline') if isinstance(birth_md, dict) else None, birth_dt) + birth_pd = _find_period_covering(birth_ad.get('pratyantar_dasha_timeline') if isinstance(birth_ad, dict) else None, birth_dt) + current_ad = None + if isinstance(current.get('antardasha_timeline'), list): + current_ad = next((item for item in current.get('antardasha_timeline') if isinstance(item, dict) and item.get('is_current')), None) + current_pd = None + reference_dt = _parse_vimshottari_dt(vimshottari.get('reference_date')) + if isinstance(current_ad, dict) and isinstance(current_ad.get('pratyantar_dasha_timeline'), list): + current_pd = next((item for item in current_ad.get('pratyantar_dasha_timeline') if isinstance(item, dict) and item.get('is_current')), None) + if current_pd is None: + current_pd = _find_period_covering(current_ad.get('pratyantar_dasha_timeline'), reference_dt) + dasha_at_birth_line = 'Dasha at Birth = blocked' + balance_of_dasha_line = 'Balance of Dasha = blocked' + if isinstance(birth_md, dict): + birth_chain = _format_vimshottari_birth_chain(birth_md, birth_ad, birth_pd, humanized=False) + if birth_chain: + dasha_at_birth_line = f"Dasha at Birth = {birth_chain}" + balance_ymd = _format_vimshottari_balance_ymd(vimshottari.get('birth_datetime'), birth_md.get('end_datetime') or birth_md.get('end')) + if balance_ymd: + balance_of_dasha_line = f"Balance of Dasha = {_humanize_reader_token(birth_md.get('lord'))} {balance_ymd}" + elif birth_md.get('balance_years') is not None: + balance_of_dasha_line = f"Balance of Dasha = {_humanize_reader_token(birth_md.get('lord'))} {birth_md.get('balance_years')}y" + current_dasha_line = ( + f"Current Dasha = {_md_cell(_humanize_reader_token(current.get('lord')))}" + f"({_md_cell(current.get('start'))} → {_md_cell(current.get('end'))};{_md_cell(current.get('years'))} 年)" + if current else + 'Current Dasha = blocked' + ) + current_antardasha_line = ( + f"Current Antardasha = {_md_cell(_humanize_reader_token(current_ad.get('lord')))}" + f"({_md_cell(current_ad.get('start'))} → {_md_cell(current_ad.get('end'))})" + if current_ad else + 'Current Antardasha = blocked' + ) + current_pratyantar_line = ( + f"Current Pratyantar = {_md_cell(_humanize_reader_token(current_pd.get('lord')))}" + f"({_md_cell(current_pd.get('start'))} → {_md_cell(current_pd.get('end'))})" + if current_pd else + 'Current Pratyantar = blocked' + ) + out.extend([ + '#### Vimshottari 主大运', + '', + '以下直接读取本地 Vimshottari 时间线,并改写成更接近原始 PL9 的阅读入口。它可作为时间主线,但仍不升级为确定事件断言。', + dasha_at_birth_line, + balance_of_dasha_line, + current_dasha_line, + current_antardasha_line, + current_pratyantar_line, + '', + '| 序号 | 主运 | 起始 | 结束 | 年数 | 状态 |', + '|------|------|------|------|------|------|', + ]) + for idx, period in enumerate(vimshottari_timeline, start=1): + if not isinstance(period, dict): + continue + status = 'parameter_sensitive / birth_balance' if period.get('is_balance') else 'parameter_sensitive' + out.append( + f"| {idx} | {_md_cell(_humanize_reader_token(period.get('lord')))} | {_md_cell(period.get('start'))} | {_md_cell(period.get('end'))} | {_md_cell(period.get('years'))} | {status} |" + ) + out.append('') + + narayana_family = families.get('narayana') if isinstance(families.get('narayana'), dict) else {} + narayana_periods = narayana_family.get('periods') if isinstance(narayana_family.get('periods'), list) else [] + narayana = timing_sheet.get('narayana_dasha') if isinstance(timing_sheet.get('narayana_dasha'), dict) else {} + if narayana_periods: + current = narayana.get('current_dasha') if isinstance(narayana.get('current_dasha'), dict) else {} + current_md = current.get('md') if isinstance(current.get('md'), dict) else {} + current_ad = current.get('ad') if isinstance(current.get('ad'), dict) else {} + current_pd = current.get('pd') if isinstance(current.get('pd'), dict) else {} + interpretation = narayana.get('interpretation') if isinstance(narayana.get('interpretation'), list) else [] + out.extend([ + '#### Narayana Rashi Dasha', + '', + '以下保留 Narayana 的星座主周期与当前层级,用来恢复 dual-dasha 的正文阅读入口。当前这段已经比“无章节”前进了一步,但仍主要是本地年龄轴,不等同于原始 PL9 第 99 页那种日期边界表。', + f"起运星座:{_md_cell(_humanize_reader_token(narayana.get('lagna_sign')))};总周期:{_md_cell(narayana.get('total_cycle_years'))} 年" if narayana else '起运星座:blocked', + f"当前主层:{_md_cell(_humanize_reader_token(current_md.get('sign')))} / {_md_cell(_humanize_reader_token(current_md.get('lord')))}({_md_cell(current_md.get('start_age'))} → {_md_cell(current_md.get('end_age'))} 岁)" if current_md else '当前主层:blocked', + f"当前子层:{_md_cell(_humanize_reader_token(current_ad.get('sign')))} / {_md_cell(_humanize_reader_token(current_ad.get('lord')))}({_md_cell(current_ad.get('start_age'))} → {_md_cell(current_ad.get('end_age'))} 岁)" if current_ad else '当前子层:blocked', + f"当前次子层:{_md_cell(_humanize_reader_token(current_pd.get('sign')))} / {_md_cell(_humanize_reader_token(current_pd.get('lord')))}({_md_cell(current_pd.get('start_age'))} → {_md_cell(current_pd.get('end_age'))} 岁)" if current_pd else '当前次子层:blocked', + f"剩余年数:{_md_cell(current.get('remaining_years'))}" if current else '剩余年数:blocked', + '与原始 PL9 的当前核查结论:本地 Narayana 目前输出的是“从出生起算的年龄轴星座序列”;原始 PL9 第 99 页输出的是“固定起运序列 + 每段日期边界表”。因此这不是纯文案问题,而是 source-parity 尚未闭环。', + ]) + if interpretation: + out.append(_md_cell(interpretation[0])) + out.extend([ + '', + '| 序号 | 星座 | 主星 | 起始(年龄轴) | 结束(年龄轴) | 年数 | 状态 |', + '|------|------|------|----------------|----------------|------|------|', + ]) + for idx, period in enumerate(narayana_periods, start=1): + if not isinstance(period, dict): + continue + out.append( + f"| {idx} | {_md_cell(_humanize_reader_token(period.get('sign')))} | {_md_cell(_humanize_reader_token(period.get('lord')))} | {_md_cell(period.get('start_age'))} | {_md_cell(period.get('end_age'))} | {_period_years(period)} | parameter_sensitive |" + ) + out.extend([ + '', + '| 与原始 PL9 对照项 | 当前状态 | 说明 |', + '|--------------------|----------|------|', + '| 主阶段存在性 | 已有 | 本地已能导出完整 12 段 Narayana 主序列。 |', + '| 当前主层定位 | 已有 | 本地已能定位当前 MD / AD / PD 与剩余年数。 |', + '| 起运序列 parity | 未闭环 | 原始 PL9 当前样例从 Leo 起运;本地当前样例从 Aquarius 起运,不是同一序列。 |', + '| 日期边界 parity | 未闭环 | 原始 PL9 给出 01-08-2005、01-08-2015、01-08-2021 这类日期边界;本地当前正文仍只输出年龄轴。 |', + '| 结论 | source-parity 未闭环 | 现阶段不能把它描述成“只是渲染差异”,更准确地说是“已有正文入口,但源序列/口径仍未对齐”。 |', + '', + ]) + reference_rows = ( + narayana_reference_packet.get('boundary_rows') + if isinstance(narayana_reference_packet.get('boundary_rows'), list) + else [] + ) + reference_summary = ( + narayana_reference_packet.get('summary') + if isinstance(narayana_reference_packet.get('summary'), dict) + else {} + ) + continuation_observation = ( + narayana_reference_packet.get('continuation_observation') + if isinstance(narayana_reference_packet.get('continuation_observation'), dict) + else {} + ) + narayana_cross_case = ( + narayana_reference_packet.get('cross_case_ad_profile_replay') + if isinstance(narayana_reference_packet.get('cross_case_ad_profile_replay'), dict) + else {} + ) + if reference_rows: + out.extend([ + 'Narayana 参考对齐:当前已附带 PL9 第 99-100 页首周期对齐包,可辅助阅读源序列与后续延续差异,但不能当作 full parity 证明。', + _render_boundary_packet_summary_line( + narayana_reference_packet, + [('row_count', ' 行'), ('matched_count', ' 已匹配'), ('mismatch_count', ' 未匹配')], + ), + '', + '| 参考组 | PL9 星座/年数 | Replay 星座/年数 | PyJHora 星座/年数 | 状态 |', + '|--------|---------------|------------------|-------------------|------|', + ]) + for row in reference_rows[:12]: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('group'))} | {_md_cell(_display_rashi_from_value(row.get('pl9_sign_index')))} / {_md_cell(row.get('pl9_years'))} | {_md_cell(_display_rashi_from_value(row.get('replayed_sign_index')))} / {_md_cell(row.get('replayed_years'))} | {_md_cell(_display_rashi_from_value(row.get('pyjhora_sign_index')))} / {_md_cell(row.get('pyjhora_years'))} | {_md_cell(row.get('confidence_label') or row.get('status') or 'parameter_sensitive')} |" + ) + if continuation_observation: + out.extend([ + '', + f"续周期边界:{_md_cell(continuation_observation.get('boundary'))}", + ]) + if narayana_cross_case: + profile_summaries = ( + narayana_cross_case.get('profile_summaries') + if isinstance(narayana_cross_case.get('profile_summaries'), dict) + else {} + ) + promotion_decision = ( + narayana_cross_case.get('promotion_decision') + if isinstance(narayana_cross_case.get('promotion_decision'), dict) + else {} + ) + blocked_scan = ( + narayana_cross_case.get('blocked_ad_tie_break_candidate_scan') + if isinstance(narayana_cross_case.get('blocked_ad_tie_break_candidate_scan'), dict) + else {} + ) + multi_case_gate = ( + narayana_reference_packet.get('multi_case_promotion_gate') + if isinstance(narayana_reference_packet.get('multi_case_promotion_gate'), dict) + else {} + ) + out.extend([ + '', + 'Narayana cross-case replay:1993 observed profile 已用 1997 Domi / PL9 page 99-100 复测;当前结论是不升格为通用 PL9/JHora 规则。', + f"升格决策:{_md_cell(promotion_decision.get('status') or 'parameter_sensitive')}", + ]) + if profile_summaries: + out.extend([ + '', + '| AD profile | Matched | Mismatch | Blocked |', + '|------------|---------|----------|---------|', + ]) + for name in [ + 'parashara_equal_v1', + 'pl9_observed_direction_v1', + 'pl9_observed_direction_tie_break_v1', + 'pl9_observed_stronger_sign_v1', + ]: + summary = profile_summaries.get(name) + if not isinstance(summary, dict): + continue + out.append( + f"| {_md_cell(name)} | {_md_cell(summary.get('matched_count'))} | {_md_cell(summary.get('mismatch_count'))} | {_md_cell(summary.get('blocked_count'))} |" + ) + if blocked_scan: + out.extend([ + '', + f"Domi blocked AD tie-break scan:{_md_cell(blocked_scan.get('status'))};observed first-child 候选匹配 {_md_cell(blocked_scan.get('observed_candidate_match_count'))} / {_md_cell(blocked_scan.get('row_count'))};lord-location 起点排除 {_md_cell(blocked_scan.get('all_lord_location_mismatch_count'))} / {_md_cell(blocked_scan.get('row_count'))}。", + ]) + if multi_case_gate: + third_case_capture = ( + multi_case_gate.get('third_case_capture') + if isinstance(multi_case_gate.get('third_case_capture'), dict) + else {} + ) + out.append( + f"Narayana multi-case promotion gate:{_md_cell(multi_case_gate.get('status'))};case_count={_md_cell(multi_case_gate.get('case_count'))};next={_md_cell(multi_case_gate.get('next_replay_requirements'))}。" + ) + if third_case_capture: + out.append( + f"第三案捕获状态:{_md_cell(third_case_capture.get('status'))};parent rows={_md_cell(third_case_capture.get('captured_parent_row_count'))}/{_md_cell(third_case_capture.get('required_parent_row_count'))};template={_md_cell(third_case_capture.get('template_path'))}。" + ) + text_extraction_evidence = ( + multi_case_gate.get('pdf_text_extraction_evidence') + if isinstance(multi_case_gate.get('pdf_text_extraction_evidence'), dict) + else {} + ) + if text_extraction_evidence: + case_1993_pdf = text_extraction_evidence.get('case_1993') or {} + domi_pdf = text_extraction_evidence.get('domi_1997') or {} + india2_pdf = text_extraction_evidence.get('india2_third_case') or {} + out.append( + f"PDF 自动抽表证据:1993={_md_cell(case_1993_pdf.get('complete_parent_block_count'))} parent / {_md_cell(case_1993_pdf.get('child_boundary_count'))} child;Domi={_md_cell(domi_pdf.get('complete_parent_block_count'))} parent / {_md_cell(domi_pdf.get('child_boundary_count'))} child;India2 third={_md_cell(india2_pdf.get('complete_parent_block_count'))} parent / {_md_cell(india2_pdf.get('child_boundary_count'))} child。" + ) + observed_profile_matrix = ( + multi_case_gate.get('observed_profile_matrix') + if isinstance(multi_case_gate.get('observed_profile_matrix'), dict) + else {} + ) + if observed_profile_matrix: + out.append( + f"Observed profile matrix:{_md_cell(observed_profile_matrix.get('status'))};rows={_md_cell(observed_profile_matrix.get('row_count'))};markers={_md_cell(observed_profile_matrix.get('conflict_markers'))}。" + ) + rule_candidate_scores = ( + multi_case_gate.get('rule_candidate_scores') + if isinstance(multi_case_gate.get('rule_candidate_scores'), dict) + else {} + ) + if rule_candidate_scores: + top_first = rule_candidate_scores.get('top_first_child_candidate') or {} + top_direction = rule_candidate_scores.get('top_direction_candidate') or {} + out.append( + f"Rule candidate scores:first-child best={_md_cell(top_first.get('candidate'))} {_md_cell(top_first.get('match_count'))}/{_md_cell(top_first.get('evaluated_count'))};direction best={_md_cell(top_direction.get('candidate'))} {_md_cell(top_direction.get('match_count'))}/{_md_cell(top_direction.get('evaluated_count'))}。" + ) + interaction_rule_scores = ( + multi_case_gate.get('interaction_rule_scores') + if isinstance(multi_case_gate.get('interaction_rule_scores'), dict) + else {} + ) + if interaction_rule_scores: + top_first_interaction = interaction_rule_scores.get('top_first_child_interaction') or {} + top_direction_interaction = interaction_rule_scores.get('top_direction_interaction') or {} + improvement = interaction_rule_scores.get('improvement_over_simple_best') or {} + out.append( + f"Interaction rule scores:first-child best={_md_cell(top_first_interaction.get('candidate'))} {_md_cell(top_first_interaction.get('match_count'))}/{_md_cell(top_first_interaction.get('evaluated_count'))} Δ={_md_cell(improvement.get('first_child_relation_match_delta'))};direction best={_md_cell(top_direction_interaction.get('candidate'))} {_md_cell(top_direction_interaction.get('match_count'))}/{_md_cell(top_direction_interaction.get('evaluated_count'))} Δ={_md_cell(improvement.get('direction_match_delta'))}。" + ) + anomaly_table = ( + multi_case_gate.get('interaction_anomaly_table') + if isinstance(multi_case_gate.get('interaction_anomaly_table'), dict) + else {} + ) + if anomaly_table: + anomaly_summary = anomaly_table.get('summary') or {} + out.append( + f"Interaction anomaly table:{_md_cell(anomaly_table.get('status'))};first-child mismatches={_md_cell(anomaly_summary.get('first_child_mismatch_count'))};direction mismatches={_md_cell(anomaly_summary.get('direction_mismatch_count'))}。" + ) + secondary_attribution = ( + multi_case_gate.get('anomaly_secondary_attribution') + if isinstance(multi_case_gate.get('anomaly_secondary_attribution'), dict) + else {} + ) + if secondary_attribution: + secondary_summary = secondary_attribution.get('summary') or {} + out.append( + f"Anomaly secondary attribution:{_md_cell(secondary_attribution.get('status'))};buckets={_md_cell(secondary_summary.get('secondary_bucket_counts'))};cycles={_md_cell(secondary_summary.get('cycle_counts'))}。" + ) + row_evidence_ledger = ( + multi_case_gate.get('row_evidence_ledger') + if isinstance(multi_case_gate.get('row_evidence_ledger'), dict) + else {} + ) + if row_evidence_ledger: + ledger_summary = row_evidence_ledger.get('summary') or {} + out.append( + f"Row evidence ledger:{_md_cell(row_evidence_ledger.get('status'))};rows={_md_cell(row_evidence_ledger.get('row_count'))};lord-location={_md_cell(ledger_summary.get('lord_location_status_counts'))};cycles={_md_cell(ledger_summary.get('cycle_counts'))}。" + ) + cycle_segment_scores = ( + multi_case_gate.get('cycle_segment_rule_scores') + if isinstance(multi_case_gate.get('cycle_segment_rule_scores'), dict) + else {} + ) + if cycle_segment_scores: + segments = cycle_segment_scores.get('segments') or {} + first_cycle = segments.get('first_cycle') or {} + continuation = segments.get('domi_continuation') or {} + first_fc = first_cycle.get('top_first_child_candidate') or {} + first_dir = first_cycle.get('top_direction_candidate') or {} + cont_fc = continuation.get('top_first_child_candidate') or {} + cont_dir = continuation.get('top_direction_candidate') or {} + out.append( + f"Cycle segment scores:first-cycle first-child={_md_cell(first_fc.get('candidate'))} {_md_cell(first_fc.get('match_count'))}/{_md_cell(first_fc.get('evaluated_count'))};first-cycle direction={_md_cell(first_dir.get('candidate'))} {_md_cell(first_dir.get('match_count'))}/{_md_cell(first_dir.get('evaluated_count'))};Domi continuation first-child={_md_cell(cont_fc.get('candidate'))} {_md_cell(cont_fc.get('match_count'))}/{_md_cell(cont_fc.get('evaluated_count'))};Domi continuation direction={_md_cell(cont_dir.get('candidate'))} {_md_cell(cont_dir.get('match_count'))}/{_md_cell(cont_dir.get('evaluated_count'))}。" + ) + direction_attribution = ( + multi_case_gate.get('direction_attribution_profile') + if isinstance(multi_case_gate.get('direction_attribution_profile'), dict) + else {} + ) + if direction_attribution: + segments = direction_attribution.get('segments') or {} + first_cycle = segments.get('first_cycle') or {} + continuation = segments.get('domi_continuation') or {} + first_candidate = first_cycle.get('candidate_formula_like') or {} + first_lookup = first_cycle.get('observed_lookup') or {} + continuation_candidate = continuation.get('candidate_formula_like') or {} + promotion = direction_attribution.get('promotion_assessment') or {} + out.append( + f"Direction attribution:{_md_cell(direction_attribution.get('status'))};first-cycle compact={_md_cell(first_candidate.get('candidate'))} {_md_cell(first_candidate.get('match_count'))}/{_md_cell(first_candidate.get('evaluated_count'))};first-cycle lookup={_md_cell(first_lookup.get('match_count'))}/{_md_cell(first_lookup.get('evaluated_count'))} overfit;Domi continuation compact={_md_cell(continuation_candidate.get('candidate'))} {_md_cell(continuation_candidate.get('match_count'))}/{_md_cell(continuation_candidate.get('evaluated_count'))};promote={_md_cell(promotion.get('can_promote_direction_formula'))}。" + ) + exception_dossier = ( + multi_case_gate.get('direction_exception_dossier') + if isinstance(multi_case_gate.get('direction_exception_dossier'), dict) + else {} + ) + if exception_dossier: + exception_summary = exception_dossier.get('summary') or {} + out.append( + f"Direction exception dossier:{_md_cell(exception_dossier.get('status'))};Scorpio first-child exceptions={_md_cell(exception_summary.get('scorpio_first_child_exception_count'))};Domi continuation mixed={_md_cell(exception_summary.get('domi_continuation_mixed_count'))};promotion={_md_cell(exception_summary.get('formula_promotion_status'))}。" + ) + external_source_evidence = ( + multi_case_gate.get('external_source_evidence') + if isinstance(multi_case_gate.get('external_source_evidence'), dict) + else {} + ) + if external_source_evidence: + source_count = len(external_source_evidence.get('sources') or []) + promotion = ( + external_source_evidence.get('promotion_decision') + if isinstance(external_source_evidence.get('promotion_decision'), dict) + else {} + ) + out.append( + f"External Narayana source evidence:{_md_cell(external_source_evidence.get('status'))};sources={_md_cell(source_count)};promote={_md_cell(promotion.get('allowed'))};reason={_md_cell(promotion.get('reason'))}。" + ) + + ashtottari = families.get('ashtottari') if isinstance(families.get('ashtottari'), dict) else {} + ashtottari_periods = ashtottari.get('periods') if isinstance(ashtottari.get('periods'), list) else [] + if ashtottari_periods: + profile = ashtottari.get('calculation_profile') if isinstance(ashtottari.get('calculation_profile'), dict) else {} + balance = ashtottari.get('dasha_balance_at_birth') if isinstance(ashtottari.get('dasha_balance_at_birth'), dict) else {} + current_periods = _resolve_current_recursive_periods_from_family(ashtottari, now.isoformat()) + current = current_periods.get('major') or {} + current_antardasha = current_periods.get('antardasha') + current_pratyantardasha = current_periods.get('pratyantardasha') + out.extend([ + '#### Ashtottari Dasha(适用性与本地主周期)', + '', + _md_cell(profile.get('applicability_reason') or 'Ashtottari applicability rule remains parameter_sensitive.'), + f"出生 balance:{_balance_text(balance, 'planet')}" if balance else '出生 balance:blocked', + f"Period levels:{_md_cell(', '.join(ashtottari.get('period_levels') or ['major']))}", + f"当前主层:{_md_cell(_humanize_reader_token(current.get('lord') or current.get('planet')))}({_md_cell(current.get('start_date'))} → {_md_cell(current.get('end_date'))})" if current else '当前主层:blocked', + f"当前子层:{_md_cell(_humanize_reader_token(current_antardasha.get('lord') or current_antardasha.get('planet')))}({_md_cell(current_antardasha.get('start_date'))} → {_md_cell(current_antardasha.get('end_date'))})" if current_antardasha else '当前子层:blocked', + f"当前次子层:{_md_cell(_humanize_reader_token(current_pratyantardasha.get('lord') or current_pratyantardasha.get('planet')))}({_md_cell(current_pratyantardasha.get('start_date'))} → {_md_cell(current_pratyantardasha.get('end_date'))})" if current_pratyantardasha else '当前次子层:blocked', + '该 family 来自本地 canonical Dasha timing data;外部同输入 boundary/profile 尚未闭环,不可升为 verified。', + '', + '| 序号 | 行星 | 起始 | 结束 | 年数 | 状态 |', + '|------|------|------|------|------|------|', + ]) + for idx, period in enumerate(ashtottari_periods, start=1): + if not isinstance(period, dict): + continue + planet = period.get('lord') or (period.get('raw_period') or {}).get('planet') + out.append( + f"| {idx} | {_md_cell(_humanize_reader_token(planet))} | {_period_start(period)} | {_period_end(period)} | {_period_years(period)} | parameter_sensitive / unverified |" + ) + reference_rows = ( + ashtottari_reference_packet.get('boundary_rows') + if isinstance(ashtottari_reference_packet.get('boundary_rows'), list) + else [] + ) + reference_summary = ( + ashtottari_reference_packet.get('summary') + if isinstance(ashtottari_reference_packet.get('summary'), dict) + else {} + ) + if reference_rows: + out.extend([ + '', + 'Ashtottari 参考对齐:当前已附带同案例 PL9 边界参考包,可辅助阅读边界差异,但仍不能把它写成 verified parity。', + _render_boundary_packet_summary_line( + ashtottari_reference_packet, + [('row_count', ' 行'), ('max_abs_delta_days', ' 天')], + ), + '', + '| 参考组 | PL9 结束 | Replay 结束 | 偏差 | 状态 |', + '|--------|----------|-------------|------|------|', + ]) + for row in reference_rows[:7]: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('group'))} | {_md_cell(row.get('pl9_end'))} | {_md_cell(row.get('replayed_end'))} | {_md_cell(row.get('delta_days'))} 天 | {_md_cell(row.get('confidence_label') or 'parameter_sensitive')} |" + ) + out.append('') + + kala_chakra = families.get('kala_chakra') if isinstance(families.get('kala_chakra'), dict) else {} + kala_periods = kala_chakra.get('periods') if isinstance(kala_chakra.get('periods'), list) else [] + if kala_periods: + profile = kala_chakra.get('calculation_profile') if isinstance(kala_chakra.get('calculation_profile'), dict) else {} + balance = kala_chakra.get('dasha_balance_at_birth') if isinstance(kala_chakra.get('dasha_balance_at_birth'), dict) else {} + current_periods = _resolve_current_recursive_periods_from_family(kala_chakra, now.isoformat()) + out.extend([ + '#### Kala Chakra Dasha(本地原始周期,冲突保留)', + '', + 'algorithm_conflict / parameter_sensitive:本表只保留本地 canonical Dasha timing data 的原始周期;external profile parity remains unclosed。', + f"模式:{_md_cell(profile.get('mode'))};起始:{_md_cell(_humanize_reader_token(kala_chakra.get('starting_lord')))} / {_md_cell(_humanize_reader_token(kala_chakra.get('starting_rashi')))};Deha:{_md_cell(_humanize_reader_token(kala_chakra.get('deha_rashi')))};Jeeva:{_md_cell(_humanize_reader_token(kala_chakra.get('jeeva_rashi')))}", + f"出生 balance:{_balance_text(balance, 'rashi')}" if balance else '出生 balance:blocked', + f"Period levels:{_md_cell(', '.join(kala_chakra.get('period_levels') or ['major']))}", + f"当前主层:{_md_cell(_humanize_reader_token((current_periods.get('major') or {}).get('lord')))} / {_md_cell(_humanize_reader_token((current_periods.get('major') or {}).get('rashi')))}({_md_cell((current_periods.get('major') or {}).get('start_date'))} → {_md_cell((current_periods.get('major') or {}).get('end_date'))})" if current_periods.get('major') else '当前主层:blocked', + f"当前子层:{_md_cell(_humanize_reader_token((current_periods.get('antardasha') or {}).get('lord')))} / {_md_cell(_humanize_reader_token((current_periods.get('antardasha') or {}).get('rashi')))}({_md_cell((current_periods.get('antardasha') or {}).get('start_date'))} → {_md_cell((current_periods.get('antardasha') or {}).get('end_date'))})" if current_periods.get('antardasha') else '当前子层:blocked', + f"当前次子层:{_md_cell(_humanize_reader_token((current_periods.get('pratyantardasha') or {}).get('lord')))} / {_md_cell(_humanize_reader_token((current_periods.get('pratyantardasha') or {}).get('rashi')))}({_md_cell((current_periods.get('pratyantardasha') or {}).get('start_date'))} → {_md_cell((current_periods.get('pratyantardasha') or {}).get('end_date'))})" if current_periods.get('pratyantardasha') else '当前次子层:blocked', + '', + '| 序号 | 主星 | 星座 | 起始 | 结束 | 年数 | 状态 |', + '|------|------|------|------|------|------|------|', + ]) + for idx, period in enumerate(kala_periods, start=1): + if not isinstance(period, dict): + continue + lord = period.get('lord') or (period.get('raw_period') or {}).get('lord') + sign = period.get('sign') or (period.get('raw_period') or {}).get('rashi') + out.append( + f"| {idx} | {_md_cell(_humanize_reader_token(lord))} | {_md_cell(_humanize_reader_token(sign))} | {_period_start(period)} | {_period_end(period)} | {_period_years(period)} | parameter_sensitive |" + ) + kala_reference_rows = ( + kalachakra_reference_packet.get('boundary_rows') + if isinstance(kalachakra_reference_packet.get('boundary_rows'), list) + else [] + ) + kala_reference_summary = ( + kalachakra_reference_packet.get('summary') + if isinstance(kalachakra_reference_packet.get('summary'), dict) + else {} + ) + kala_comparison = ( + kalachakra_reference_packet.get('same_case_md_comparison') + if isinstance(kalachakra_reference_packet.get('same_case_md_comparison'), dict) + else {} + ) + if kala_reference_rows: + out.extend([ + '', + '#### Kala Chakra 深度差异诊断', + '', + 'Kala Chakra 当前已附带 PL9 第 83-88 页结构对齐包,可辅助阅读 page-83 MD 头部与 page-84-88 AD/PD 组标签,但不能把它写成算法或日期边界已闭环。', + _render_boundary_packet_summary_line( + kalachakra_reference_packet, + [('row_count', ' 行'), ('matched_count', ' 已匹配'), ('mismatch_count', ' 未匹配')], + ), + '', + '| 参考组 | PL9 星座/年数 | Replay 星座/年数 | 状态 |', + '|--------|---------------|------------------|------|', + ]) + for row in kala_reference_rows[:12]: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('group'))} | {_md_cell(_display_rashi_from_value(row.get('pl9_rashi')))} / {_md_cell(row.get('pl9_years'))} | {_md_cell(_display_rashi_from_value(row.get('replayed_rashi')))} / {_md_cell(row.get('replayed_years'))} | {_md_cell(row.get('confidence_label') or row.get('status') or 'parameter_sensitive')} |" + ) + divergence = kala_comparison.get('first_divergence') if isinstance(kala_comparison.get('first_divergence'), dict) else {} + if divergence: + out.extend([ + '', + f"首个分歧:在 {_md_cell(divergence.get('after'))} 之后,PL9 指向 {_md_cell(divergence.get('pl9'))},本地 replay 指向 {_md_cell(divergence.get('native'))}。", + ]) + kala_reference_rows = ( + kalachakra_reference_packet.get('boundary_rows') + if isinstance(kalachakra_reference_packet.get('boundary_rows'), list) + else [] + ) + kala_reference_summary = ( + kalachakra_reference_packet.get('summary') + if isinstance(kalachakra_reference_packet.get('summary'), dict) + else {} + ) + kala_deeper_sequence_boundary = ( + kalachakra_reference_packet.get('deeper_sequence_boundary') + if isinstance(kalachakra_reference_packet.get('deeper_sequence_boundary'), dict) + else {} + ) + kala_external_behavior_alignment = ( + kalachakra_reference_packet.get('external_behavior_alignment') + if isinstance(kalachakra_reference_packet.get('external_behavior_alignment'), dict) + else {} + ) + kala_candidate_profiles = ( + kalachakra_reference_packet.get('candidate_continuation_profiles') + if isinstance(kalachakra_reference_packet.get('candidate_continuation_profiles'), dict) + else {} + ) + kala_selection_diagnostics = ( + kalachakra_reference_packet.get('selection_diagnostics') + if isinstance(kalachakra_reference_packet.get('selection_diagnostics'), dict) + else {} + ) + kala_continuation_policy_boundary = ( + kalachakra_reference_packet.get('continuation_policy_boundary') + if isinstance(kalachakra_reference_packet.get('continuation_policy_boundary'), dict) + else {} + ) + kala_same_case = ( + kalachakra_reference_packet.get('same_case_md_comparison') + if isinstance(kalachakra_reference_packet.get('same_case_md_comparison'), dict) + else {} + ) + if kala_reference_rows: + out.extend([ + '', + '#### Kala Chakra 参考对齐', + '', + 'Kala Chakra 当前已附带 PL9 第 83-88 页结构参考包,可辅助阅读主周期差异,但不能当作算法或日期边界已经闭环的证明。', + f"参考包摘要:{_md_cell(kalachakra_reference_packet.get('packet_mode'))};行数 {_md_cell(kala_reference_summary.get('row_count'))};已匹配 {_md_cell(kala_reference_summary.get('matched_count'))};未匹配 {_md_cell(kala_reference_summary.get('mismatch_count'))}。", + ( + f"更深层拆分:同星座但年数不符 {_md_cell(kala_reference_summary.get('same_rashi_duration_mismatch_count'))} 行;" + f"顺序分歧 {_md_cell(kala_reference_summary.get('ordering_mismatch_count'))} 行;" + f"本地序列已截断 {_md_cell(kala_reference_summary.get('native_sequence_exhausted_count'))} 行。" + ), + '', + '| 参考组 | PL9 星座/年数 | Replay 星座/年数 | 状态 |', + '|--------|---------------|------------------|------|', + ]) + for row in kala_reference_rows[:12]: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(row.get('group'))} | {_md_cell(_humanize_reader_token(row.get('pl9_rashi')))} / {_md_cell(row.get('pl9_years'))} | {_md_cell(_humanize_reader_token(row.get('replayed_rashi')))} / {_md_cell(row.get('replayed_years'))} | {_md_cell(row.get('confidence_label') or row.get('status') or 'parameter_sensitive')} |" + ) + divergence = kala_same_case.get('first_divergence') if isinstance(kala_same_case.get('first_divergence'), dict) else {} + if divergence: + out.extend([ + '', + f"首个分歧:在 {_md_cell(divergence.get('after'))} 之后,PL9 指向 {_md_cell(divergence.get('pl9'))},本地 replay 指向 {_md_cell(divergence.get('native'))}。", + ]) + deeper_boundary_note = kala_deeper_sequence_boundary.get('boundary_note') + if deeper_boundary_note: + out.extend([ + f"边界说明:{_md_cell(deeper_boundary_note)}", + ]) + external_alignment_note = kala_external_behavior_alignment.get('boundary_note') + if external_alignment_note: + out.extend([ + f"外部行为参照:{_md_cell(external_alignment_note)}", + ]) + candidate_boundary_note = kala_candidate_profiles.get('boundary_note') + exact_candidates = kala_candidate_profiles.get('exact_pl9_continuation_candidates') + hybrid_candidates = kala_candidate_profiles.get('native_then_pl9_hybrid_candidates') + closest_exact_candidate = ( + kala_candidate_profiles.get('closest_exact_pl9_candidate') + if isinstance(kala_candidate_profiles.get('closest_exact_pl9_candidate'), dict) + else {} + ) + closest_hybrid_candidate = ( + kala_candidate_profiles.get('closest_native_then_pl9_hybrid_candidate') + if isinstance(kala_candidate_profiles.get('closest_native_then_pl9_hybrid_candidate'), dict) + else {} + ) + if candidate_boundary_note: + out.extend([ + f"候选续段:{_md_cell(candidate_boundary_note)}", + ]) + if isinstance(exact_candidates, list) and isinstance(hybrid_candidates, list): + out.extend([ + f"候选计数:PL9 exact 候选 {_md_cell(len(exact_candidates))} 条;native-then-PL9 hybrid 候选 {_md_cell(len(hybrid_candidates))} 条。", + ]) + if closest_exact_candidate: + out.extend([ + ( + f"最接近当前 active seed 的 PL9 exact 候选:group {_md_cell(closest_exact_candidate.get('group_index'))} / " + f"pada {_md_cell(closest_exact_candidate.get('pada_index'))} / " + f"start {_md_cell(closest_exact_candidate.get('start_index'))}。" + ), + ]) + if closest_hybrid_candidate: + out.extend([ + ( + f"最接近当前 active seed 的 hybrid 候选:group {_md_cell(closest_hybrid_candidate.get('group_index'))} / " + f"pada {_md_cell(closest_hybrid_candidate.get('pada_index'))} / " + f"start {_md_cell(closest_hybrid_candidate.get('start_index'))}。" + ), + ]) + selection_boundary_note = kala_selection_diagnostics.get('boundary_note') + if selection_boundary_note: + out.extend([ + f"选择诊断:{_md_cell(selection_boundary_note)}", + ]) + continuation_policy_note = kala_continuation_policy_boundary.get('boundary_note') + continuation_divergence = ( + kala_continuation_policy_boundary.get('first_divergence') + if isinstance(kala_continuation_policy_boundary.get('first_divergence'), dict) + else {} + ) + if continuation_policy_note: + out.extend([ + f"策略边界:{_md_cell(continuation_policy_note)}", + ]) + if continuation_divergence: + out.extend([ + ( + f"策略首分歧:在 {_md_cell(continuation_divergence.get('after'))} 之后," + f"native policy 指向 {_md_cell(_humanize_reader_token(continuation_divergence.get('native_runtime_next')))}," + f"PL9 exact policy 指向 {_md_cell(_humanize_reader_token(continuation_divergence.get('pl9_exact_next')))}。" + ), + ]) + out.append('') + return out + + def _jaimini_special_section() -> list[str]: + if not jaimini: + return [] + out = [ + '### Jaimini 特殊点与命运锚点', + '', + ] + karakas = jaimini.get('chara_karaka_7') if isinstance(jaimini.get('chara_karaka_7'), dict) else {} + table = karakas.get('karaka_table') if isinstance(karakas.get('karaka_table'), dict) else {} + if table: + out.extend(['#### 七 Karaka', '', '| Karaka | 行星 | 度数 | 主题 |', '|--------|------|------|------|']) + for key, row in table.items(): + if isinstance(row, dict): + out.append(f"| {_md_cell(row.get('cn_name') or key)} | {_md_cell(_humanize_reader_token(row.get('planet')))} | {_md_cell(row.get('degree_in_sign'))} | {_md_cell(row.get('domain'))} |") + out.append('') + karakas_8 = jaimini.get('chara_karaka_8') if isinstance(jaimini.get('chara_karaka_8'), dict) else {} + table_8 = karakas_8.get('karaka_table_8') if isinstance(karakas_8.get('karaka_table_8'), dict) else {} + if table_8: + out.extend([ + '#### 八 Karaka(含 Rahu 口径)', + '', + 'Rahu 是否纳入及角色排序尚未完成 PL9 字段级对照;下表保留本地 8 Karaka 结果,状态为 parameter_sensitive。', + '', + '| Karaka | 行星 | 度数 | 主题 | 状态 |', + '|--------|------|------|------|------|', + ]) + for key, row in table_8.items(): + if isinstance(row, dict): + out.append(f"| {_md_cell(row.get('cn_name') or key)} | {_md_cell(_humanize_reader_token(row.get('planet')))} | {_md_cell(row.get('degree_in_sign'))} | {_md_cell(row.get('domain'))} | parameter_sensitive |") + out.append('') + karakamsha = jaimini.get('karakamsha') if isinstance(jaimini.get('karakamsha'), dict) else {} + if karakamsha: + out.extend([ + '#### Karakamsha', + '', + f"- 星座:{_md_cell(_humanize_reader_token(karakamsha.get('karakamsha_sign')))};守护星:{_md_cell(_humanize_reader_token(karakamsha.get('karakamsha_lord')))};度数:{_md_cell(karakamsha.get('karakamsha_degree'))}", + '', + ]) + for title, key in [('Arudha Padas', 'arudha_padas'), ('Jaimini Special Lagnas', 'special_lagnas')]: + data = jaimini.get(key) if isinstance(jaimini.get(key), dict) else {} + if not data: + continue + out.extend([f'#### {title}', '', '| 项目 | 星座/值 |', '|------|---------|']) + for item_key, value in data.items(): + if isinstance(value, dict): + sign = value.get('sign') or value.get('rashi') or value.get('value') or value.get('name') + else: + sign = value + out.append(f"| {_md_cell(item_key)} | {_md_cell(_humanize_reader_token(sign))} |") + if key == 'special_lagnas': + out.extend(['', 'Special Lagnas 使用简化日出敏感口径,需以当地日出与图格级 fixture 完成 PL9 对照前保持 parameter_sensitive。']) + out.append('') + arudha = jaimini.get('arudha_padas') if isinstance(jaimini.get('arudha_padas'), dict) else {} + arudha_lagna = arudha.get('arudha_lagna') if isinstance(arudha.get('arudha_lagna'), dict) else {} + upapada = arudha.get('upapada') if isinstance(arudha.get('upapada'), dict) else {} + special_lagnas = jaimini.get('special_lagnas') if isinstance(jaimini.get('special_lagnas'), dict) else {} + yogi_local = jaimini.get('yogi_avayogi_local') if isinstance(jaimini.get('yogi_avayogi_local'), dict) else {} + yogi_comparison = jaimini.get('yogi_avayogi_local_external_comparison') if isinstance(jaimini.get('yogi_avayogi_local_external_comparison'), dict) else {} + yogi_comparison_rows = { + row.get('field'): row + for row in (yogi_comparison.get('rows') if isinstance(yogi_comparison.get('rows'), list) else []) + if isinstance(row, dict) + } + yogi_replay = jaimini.get('yogi_avayogi_external_replay') if isinstance(jaimini.get('yogi_avayogi_external_replay'), dict) else {} + def _replay_point_text(point: dict) -> str: + if not isinstance(point, dict) or not point: + return '-' + parts = [] + sign = _humanize_reader_token(point.get('sign')) + if sign and sign != '-': + parts.append(str(sign)) + nakshatra = point.get('nakshatra') + lord = _humanize_reader_token(point.get('nakshatra_lord')) + if nakshatra: + parts.append(str(nakshatra)) + if lord and lord != '-': + parts.append(f"lord={lord}") + if point.get('degree_in_sign') not in (None, ''): + parts.append(f"{point.get('degree_in_sign')}°") + return ' / '.join(parts) if parts else '-' + out.extend([ + '#### PL9 第89页字段映射', + '', + '下表只标明本地报告当前能否提供同名字段。AL/UL 使用本地 Arudha producer;HL/GL/PP/ViL/VL 使用 sunrise-sensitive 的本地 Jaimini special lagnas;Yogi 与 Ava Yogi 使用本地 Yogi Sphuta producer 并以 PyJHora/JHora 外部回放校验,但仍不是独立多引擎 parity;Brahma、Maheshwara 与 Rudra 仍没有同口径结构化 producer。', + '', + '| 字段 | 本地值 | 状态 |', + '|------|--------|------|', + f"| Arudha Lagna (AL) | {_md_cell(_humanize_reader_token(arudha_lagna.get('sign')))} | parameter_sensitive |", + f"| Upapada (UL) | {_md_cell(_humanize_reader_token(upapada.get('sign')))} | parameter_sensitive |", + ]) + for field in ('HL', 'GL', 'PP', 'ViL', 'VL'): + value = special_lagnas.get(field) if isinstance(special_lagnas.get(field), dict) else {} + out.append( + f"| {field} | {_md_cell(_humanize_reader_token(value.get('sign')))} | parameter_sensitive |" + if value + else f"| {field} | - | missing_in_local / blocked |" + ) + for label, key in (('Yogi', 'yogi'), ('Ava Yogi', 'ava_yogi')): + local_point = yogi_local.get(key) if isinstance(yogi_local.get(key), dict) else {} + point = yogi_replay.get(key) if isinstance(yogi_replay.get(key), dict) else {} + comparison = yogi_comparison_rows.get(label) if isinstance(yogi_comparison_rows.get(label), dict) else {} + if local_point: + status = 'partial_verified / pyjhora_formula_aligned' if comparison.get('status') == 'match' else 'parameter_sensitive' + out.append(f"| {label} | {_md_cell(_replay_point_text(local_point))} | {status} |") + elif point: + out.append(f"| {label} | {_md_cell(_replay_point_text(point))} | pyjhora_behavior_only / not_multiengine_parity |") + else: + out.append(f"| {label} | - | missing_in_local / blocked |") + out.extend([ + '| Brahma | - | missing_in_local / blocked |', + '| Maheshwara | - | missing_in_local / blocked |', + '| Rudra | - | missing_in_local / blocked |', + '', + ]) + chara_dasha = jaimini.get('chara_dasha') if isinstance(jaimini.get('chara_dasha'), dict) else {} + sequence = chara_dasha.get('dasha_sequence') if isinstance(chara_dasha.get('dasha_sequence'), list) else [] + if sequence: + out.extend([ + '#### Chara Dasha 阶段序列(本地 KN Rao 口径)', + '', + '以下仅保留本地 Chara Dasha 返回的星座顺序与时长。PL9 第 90-91 页的绝对起止日期尚未闭环,不能把本表当作同页日期复刻或事件预测。', + '', + '| 阶段 | 星座 | 主星 | 年数 | 状态 |', + '|------|------|------|------|------|', + ]) + for period in sequence: + if not isinstance(period, dict): + continue + out.append( + f"| {_md_cell(period.get('order'))} | {_md_cell(_humanize_reader_token(period.get('sign')))} | " + f"{_md_cell(_humanize_reader_token(period.get('lord')))} | {_md_cell(period.get('duration_years'))} | parameter_sensitive |" + ) + out.extend([ + '', + '| 边界项目 | 状态 |', + '|----------|------|', + '| 出生 balance / 绝对日期边界 | blocked |', + '', + ]) + graha_padas = jaimini.get('graha_padas') if isinstance(jaimini.get('graha_padas'), dict) else {} + graha_pada_rows = graha_padas.get('graha_padas') if isinstance(graha_padas.get('graha_padas'), dict) else {} + if graha_pada_rows: + out.extend([ + '#### Graha Padas(隔离的本地适配器输出)', + '', + f"方法标记:{_md_cell(graha_padas.get('method'))}。该 producer 的方法标记并非当前 Lahiri 本命盘同口径,不能与 Arudha Padas 或 PL9 第 40 页的 Jaimini 表混用;仅保留原始结果供审计,所有行标为 unclosed_divisional_chart。", + '', + '| 行星 | 本命星座 | 守护星 | 守护星星座 | Graha Pada | 距离 | 状态 |', + '|------|----------|--------|------------|------------|------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'): + row = graha_pada_rows.get(planet) if isinstance(graha_pada_rows.get(planet), dict) else {} + if not row: + continue + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(_humanize_reader_token(row.get('planet_sign')))} | " + f"{_md_cell(_humanize_reader_token(row.get('lord')))} | {_md_cell(_humanize_reader_token(row.get('lord_sign')))} | " + f"{_md_cell(_humanize_reader_token(row.get('graha_pada_sign')))} | {_md_cell(row.get('distance'))} | unclosed_divisional_chart |" + ) + out.append('') + return out + + def _aspects_section() -> list[str]: + aspects = advanced_sheet.get('aspects') if isinstance(advanced_sheet.get('aspects'), dict) else {} + all_aspects = aspects.get('aspects') if isinstance(aspects.get('aspects'), list) else [] + tight_aspects = aspects.get('tight_aspects') if isinstance(aspects.get('tight_aspects'), list) else [] + if not all_aspects and not tight_aspects: + return [] + out = [ + '### 相位结构(本地计算)', + '', + '下表列出本次计算返回的紧密相位,供结构阅读与交叉核验。PL9 的相位矩阵、Bhava 相位表和视觉条图尚未完成字段级对照,因此整节保持 parameter_sensitive。', + '', + '| 行星一 | 关系 | 行星二 | 相位角 | 容许度 | 强度 | 状态 |', + '|--------|------|--------|--------|--------|------|------|', + ] + for row in tight_aspects: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(_humanize_reader_token(row.get('planet1')))} | {_md_cell(_humanize_reader_token(row.get('type')))} | " + f"{_md_cell(_humanize_reader_token(row.get('planet2')))} | {_md_cell(row.get('aspect_degree'))}° | " + f"{_md_cell(row.get('orb'))}° | {_md_cell(row.get('strength'))} | parameter_sensitive |" + ) + out.append('') + if all_aspects: + out.extend([ + '#### 全部相位原始清单(本地容许度模型)', + '', + '该表保留 producer 返回的全部有效相位,包含未被“紧密相位”摘要选中的条目。PL9 第 37 / 45 页的九曜分值矩阵与 Bhava 相位表尚未完成字段级对照,因此不应将本表称为 PL9 parity,所有行保持 parameter_sensitive。', + '', + '| 行星一 | 关系 | 行星二 | 相位角 | 容许度 | 紧密度 | 强度 | 状态 |', + '|--------|------|--------|--------|--------|--------|------|------|', + ]) + for row in all_aspects: + if not isinstance(row, dict): + continue + out.append( + f"| {_md_cell(_humanize_reader_token(row.get('planet1')))} | {_md_cell(_humanize_reader_token(row.get('type')))} | " + f"{_md_cell(_humanize_reader_token(row.get('planet2')))} | {_md_cell(row.get('aspect_degree'))}° | " + f"{_md_cell(row.get('orb'))}° | {_md_cell(row.get('orb_category'))} | {_md_cell(row.get('strength'))} | parameter_sensitive |" + ) + out.append('') + house_aspects = aspects.get('house_aspects') if isinstance(aspects.get('house_aspects'), list) else [] + if house_aspects: + out.extend([ + '#### 行星对 Bhava 相位(本地 Parashari 规则)', + '', + '该表列出仓内已有 Parashari 宫位相位规则的原始落点。PL9 第 45 页的 Bhava 相位数值表尚未完成字段级对照,且本地规则未纳入节点,因此所有行保持 parameter_sensitive,不用于替代 PL9 分值或自动事件判断。', + '', + '| 行星 | 来源 Bhava | 目标 Bhava | 相位类型 | 特殊相位 | 状态 |', + '|------|------------|------------|----------|----------|------|', + ]) + for row in house_aspects: + if not isinstance(row, dict): + continue + targets = row.get('aspects_to') if isinstance(row.get('aspects_to'), dict) else {} + for target, detail in sorted(targets.items(), key=lambda item: int(item[0])): + detail = detail if isinstance(detail, dict) else {} + out.append( + f"| {_md_cell(_humanize_reader_token(row.get('planet')))} | {_md_cell(row.get('house'))} | {_md_cell(target)} | " + f"{_md_cell(detail.get('aspect_type'))} | {'是' if detail.get('is_special') else '否'} | parameter_sensitive |" + ) + out.append('') + return out + + def _nakshatra_crosscheck_section() -> list[str]: + nakshatra = advanced_sheet.get('nakshatra_adv') if isinstance(advanced_sheet.get('nakshatra_adv'), dict) else {} + combined = nakshatra.get('tara_chandra_combined') if isinstance(nakshatra.get('tara_chandra_combined'), dict) else {} + if not combined: + return [] + out = [ + '### 星宿交叉状态(本地高级模块)', + '', + '本表只保留本命星宿、Tara 与 Chandra 分类代码,不采纳模块内置的吉凶解释或事件断语。PL9 第 35 页的星宿空间矩阵与 Tara/Chandra 口径尚未完成字段级对照,因此所有行保持 parameter_sensitive。', + '', + '| 行星 | 本命星宿 / Pada | Tara 分类 | Chandra 分类 | 组合代码 | 状态 |', + '|------|------------------|-----------|--------------|----------|------|', + ] + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = combined.get(planet) if isinstance(combined.get(planet), dict) else {} + if not row: + continue + tara = row.get('tara') if isinstance(row.get('tara'), dict) else {} + chandra = row.get('chandra') if isinstance(row.get('chandra'), dict) else {} + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(row.get('nakshatra'))} / {_md_cell((nakshatra.get('sub_lords') or {}).get(planet, {}).get('pada'))} | " + f"{_md_cell(tara.get('tara_name'))} | {_md_cell(chandra.get('chandra_name'))} | {_md_cell(row.get('combined_score'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _natal_sahams_section() -> list[str]: + natal_sahams = advanced_sheet.get('sahams') if isinstance(advanced_sheet.get('sahams'), dict) else {} + rows = [ + row for row in natal_sahams.values() + if isinstance(row, dict) and row.get('name') and row.get('sign') is not None + ] + if not rows: + return [] + out = [ + '#### 本命 Saham 原始支持点位', + '', + '本命 Saham 与年度 Saham 不是同一组计算:本表来自本命盘原始 producer,仅保留名称、星座与星座内度数,不输出 producer 内置解释文本或事件断语。尚未完成 PL9 年度条件页的字段级对照,所有行保持 parameter_sensitive。', + '', + '| Saham | 星座 | 星座内度数 | 状态 |', + '|-------|------|------------|------|', + ] + for row in sorted(rows, key=lambda item: str(item.get('name'))): + out.append( + f"| {_md_cell(row.get('name'))} | {_md_cell(row.get('sign_cn') or _humanize_reader_token(row.get('sign')))} | " + f"{_degree_text(row.get('degree_in_sign'))} | parameter_sensitive |" + ) + out.append('') + return out + + def _avastha_section() -> list[str]: + avasthas = advanced_sheet.get('avasthas') if isinstance(advanced_sheet.get('avasthas'), dict) else {} + if not avasthas: + return [] + out = [ + '### 行星 Avastha 状态(本地计算)', + '', + '下表保留本次计算的五类行星状态,供技术核对使用。PL9 第 46 页的 Avastha 细分栏位尚未完成字段级对照,因此不载入自动解释或补救建议,整节保持 parameter_sensitive。', + '', + '| 行星 | Bala | Jagrat | Deeptadi | Lajjitadi | Shayanadi | 状态 |', + '|------|------|--------|----------|------------|-----------|------|', + ] + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = avasthas.get(planet) if isinstance(avasthas.get(planet), dict) else {} + state_rows = row.get('avasthas') if isinstance(row.get('avasthas'), dict) else {} + if not state_rows: + continue + values = [] + for category in ('Bala', 'Jagrat', 'Deeptadi', 'Lajjitadi', 'Shayanadi'): + state = state_rows.get(category) if isinstance(state_rows.get(category), dict) else {} + values.append(_md_cell(state.get('state'))) + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {' | '.join(values)} | parameter_sensitive |" + ) + out.append('') + return out + + def _dispositor_cross_varga_section() -> list[str]: + dispositor_chains = advanced_sheet.get('dispositor_chains') if isinstance(advanced_sheet.get('dispositor_chains'), dict) else {} + inter_chart_linkage = advanced_sheet.get('inter_chart_linkage') if isinstance(advanced_sheet.get('inter_chart_linkage'), dict) else {} + final_dispositors = advanced_sheet.get('final_dispositors') if isinstance(advanced_sheet.get('final_dispositors'), dict) else {} + if not dispositor_chains and not inter_chart_linkage: + return [] + out = [ + '### 定位星链与跨分盘落点(本地支持数据)', + '', + '本节仅透传 D1 定位星链和 D1/D9/D10/D12 的已计算落点,不输出 producer 自带的飞星解释、事件判断或确定性结论。该链条及分盘变体尚未完成 PL9 字段级 parity,所有行保持 parameter_sensitive。', + '', + ] + if dispositor_chains: + out.extend([ + '#### D1 定位星链', + '', + '| 行星 | 定位星链 | 本地最终定位星 | 状态 |', + '|------|------------|----------------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + chain = dispositor_chains.get(planet) if isinstance(dispositor_chains.get(planet), list) else [] + if not chain: + continue + parts = [] + for step in chain: + if not isinstance(step, dict): + continue + sign = step.get('sign_cn') or _humanize_reader_token(step.get('sign')) + lord = _humanize_reader_token(step.get('dispositor')) + if sign and lord: + parts.append(f'{sign} → {lord}') + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {_md_cell(';'.join(parts))} | " + f"{_md_cell(_humanize_reader_token(final_dispositors.get(planet)))} | parameter_sensitive |" + ) + out.append('') + if inter_chart_linkage: + out.extend([ + '#### D1 / D9 / D10 / D12 落点', + '', + '| 行星 | D1 星座 / 宫位 | D9 星座 / 宫位 | D10 星座 / 宫位 | D12 星座 / 宫位 | 状态 |', + '|------|---------------|---------------|----------------|----------------|------|', + ]) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'): + row = inter_chart_linkage.get(planet) if isinstance(inter_chart_linkage.get(planet), dict) else {} + if not row: + continue + values = [] + for division in ('D1', 'D9', 'D10', 'D12'): + point = row.get(division) if isinstance(row.get(division), dict) else {} + sign = point.get('sign_cn') or _humanize_reader_token(point.get('sign')) + house = point.get('house') + values.append(f"{sign or '-'} / {house if house is not None else '-'}") + out.append( + f"| {_md_cell(_humanize_reader_token(planet))} | {' | '.join(_md_cell(value) for value in values)} | parameter_sensitive |" + ) + out.append('') + return out + + lines.extend([ + '', + '## 主题解读与时间展开', + '', + '### 解读摘要', + '', + '本报告正文先给出基础资料、图盘、年度重点和主题章节;后文计算口径与阻塞审计仅用于核对边界。', + '', + ]) + language_bridge_frontmatter = _language_bridge_frontmatter_section() + if language_bridge_frontmatter: + lines.extend(language_bridge_frontmatter) + lines.extend([ + '', + '### 本命主轴', + '', + ]) + if asc_text: + lines.append(f"你的本命上升落在{asc_text},先从这里最容易抓到性格主轴、人生重心和基础趋势。") + else: + lines.append('你的本命盘主结构已经稳定,先从这里最容易抓到性格主轴、人生重心和基础趋势。') + calc_profile_sentence = _calculation_profile_sentence() + if calc_profile_sentence: + lines.append(calc_profile_sentence) + if strength_summary.get('has_functional_layer'): + functional_sentence = _functional_layer_sentence() + if functional_sentence: + lines.append(functional_sentence) + strength_ready_sentence = _strength_ready_sentence() + if strength_ready_sentence: + lines.append(strength_ready_sentence) + ashtakavarga_summary_sentence = _ashtakavarga_summary_sentence() + if ashtakavarga_summary_sentence: + lines.append(ashtakavarga_summary_sentence) + vimsopaka_summary_sentence = _vimsopaka_summary_sentence() + if vimsopaka_summary_sentence: + lines.append(vimsopaka_summary_sentence) + pushkara_summary_sentence = _pushkara_summary_sentence() + if pushkara_summary_sentence: + lines.append(pushkara_summary_sentence) + vargottama_detail_sentence = _vargottama_detail_sentence() + if vargottama_detail_sentence: + lines.append(vargottama_detail_sentence) + if karakamsha_text: + lines.append(f"Karakamsha:{karakamsha_text}。") + + lines.extend([ + '', + annual_heading, + '', + ]) + annual_report_sections = _envelope_dict(annual.get('report_sections')) + annual_exec_summary = _envelope_dict(annual_report_sections.get('executive_summary')) + annual_thematic_narrative = _envelope_dict(annual_report_sections.get('thematic_narrative')) + annual_summary_lines = _envelope_list(annual_exec_summary.get('summary_lines')) + annual_quick_takeaways = _envelope_list(annual_exec_summary.get('quick_takeaways')) + annual_flagged_fields = _envelope_list(annual_exec_summary.get('blocked_fields')) + annual_evidence_appendix = _envelope_dict(annual_report_sections.get('evidence_appendix')) + annual_field_briefs = _envelope_dict(annual_evidence_appendix.get('field_briefs')) + annual_highlights = _envelope_list(annual_thematic_narrative.get('highlights')) + annual_field_brief_rows = [] + annual_field_brief_label_map = { + 'muntha': 'Muntha', + 'year_lord': 'Year Lord', + 'tajika_yogas': 'Tajika Yogas', + 'sahams': 'Sahams', + } + # 字段优先级:Muntha / Year Lord 是年度核心锚点,须排在速览表前部 + # (速览表仅展示前几行,纯字母序会把 Year Lord 挤出表格)。 + field_brief_priority = ('muntha', 'year_lord', 'tajika_yogas', 'sahams') + ordered_field_keys = [k for k in field_brief_priority if k in annual_field_briefs] + ordered_field_keys += sorted(k for k in annual_field_briefs if k not in ordered_field_keys) + for field_key in ordered_field_keys: + if field_key == 'response_envelope': + continue + brief_text = _humanize_reader_summary_line(annual_field_briefs.get(field_key, '')).rstrip('。') + if not brief_text: + continue + annual_field_brief_rows.append(( + annual_field_brief_label_map.get(field_key, _humanize_reader_token(field_key)), + brief_text, + field_key, + )) + annual_summary_bits = [] + for summary_line in annual_summary_lines: + if summary_line: + _append_unique_text(annual_summary_bits, _humanize_reader_summary_line(summary_line)) + annual_highlight_bits = [] + for highlight in annual_highlights: + if highlight: + _append_unique_text(annual_highlight_bits, _humanize_annual_narrative_highlight(highlight)) + annual_brief_bits = [brief_text for _, brief_text, _ in annual_field_brief_rows] + if annual_summary_bits: + annual_brief_bits = [bit for bit in annual_brief_bits if bit not in annual_summary_bits] + annual_highlight_bits = [] + annual_instruction_note = '' + for highlight in annual_highlights: + if not highlight: + continue + humanized_highlight = _humanize_annual_highlight_line(str(highlight)).strip().rstrip('。') + if not humanized_highlight: + continue + if humanized_highlight == '这一层适合从年度结构展开,再回头核对证据附录': + annual_instruction_note = '年度解释层当前还在提醒你:先看年度结构,再回到证据附录核对。' + continue + annual_highlight_bits.append(humanized_highlight) + if annual_summary_bits: + lines.append(f"年度主线摘要:{';'.join(annual_summary_bits)}。") + if annual_highlight_bits: + lines.append(f"年度节奏说明:{'。'.join(annual_highlight_bits)}。") + key_time_node_lines = _render_key_time_nodes() + if key_time_node_lines: + lines.extend([ + '', + '#### 关键时间节点卡片', + '', + ]) + lines.extend(key_time_node_lines) + annual_mainline_bits = [] + for summary_line in annual_summary_lines: + if summary_line: + _append_unique_text(annual_mainline_bits, _humanize_reader_summary_line(summary_line)) + if not annual_mainline_bits: + for _, brief_text, _ in annual_field_brief_rows: + _append_unique_text(annual_mainline_bits, brief_text) + if annual_mainline_bits: + lines.append(f"年度主线摘要:{';'.join(annual_mainline_bits[:3])}。") + annual_timing_bits = [] + mudda_preview = [] + if isinstance(mudda_periods, list) and mudda_periods: + for period in mudda_periods[:3]: + if not isinstance(period, dict): + continue + lord = _humanize_reader_token(period.get('lord')) + months = period.get('months') + if lord and months not in (None, ''): + mudda_preview.append(f"{lord}(约{months}个月)") + elif lord: + mudda_preview.append(lord) + if mudda_preview: + annual_timing_bits.append("Mudda 先给出年度内部的大段推进顺序:" + "、".join(mudda_preview)) + monthly_preview = [] + if isinstance(monthly_windows, list) and monthly_windows: + for window in monthly_windows[:3]: + if not isinstance(window, dict): + continue + idx = window.get('index') + lord = _humanize_reader_token(window.get('lord')) + duration = window.get('duration_months') + if idx and lord and duration not in (None, ''): + monthly_preview.append(f"第{idx}段由{lord}主导(约{duration}个月)") + if monthly_preview: + annual_timing_bits.append("Monthly Windows 再把它压到 " + ";".join(monthly_preview)) + patyayini_preview = [] + if isinstance(replay_patyayini_rows, list) and replay_patyayini_rows: + for row in replay_patyayini_rows[:3]: + if not isinstance(row, dict): + continue + patyayini_preview.append(f"{_md_cell(row.get('main_code'))} → {_md_cell(row.get('sub_code'))}") + patyayini_preview = [bit for bit in patyayini_preview if " → -" not in bit and "- → -" not in bit] + if patyayini_preview: + annual_timing_bits.append("Patyayini 继续把它拆成 " + "、".join(patyayini_preview)) + if annual_timing_bits: + lines.append( + "年度节奏可以按三层一起读:" + + ";".join(annual_timing_bits) + + "。这些仍只是 parameter_sensitive / pyjhora_behavior_only 的时间线索,不直接升级为具体事件预测。" + ) + annual_takeaway_bits = [] + for takeaway in annual_quick_takeaways: + if takeaway: + _append_unique_text(annual_takeaway_bits, _humanize_reader_summary_line(str(takeaway))) + if annual_takeaway_bits: + lines.append(f"年度速读提示:{';'.join(annual_takeaway_bits[:2])}。") + if annual_instruction_note: + lines.append(annual_instruction_note) + if annual_flagged_fields: + flagged_labels = [] + label_map = { + 'muntha': 'Muntha', + 'year_lord': 'Year Lord', + 'tajika_yogas': 'Tajika Yogas', + 'sahams': 'Sahams', + 'mudda_dasha': 'Mudda Dasha', + 'patyayini_dasha': 'Patyayini Dasha', + } + for field in annual_flagged_fields: + label = label_map.get(str(field), _humanize_reader_token(field)) + if label and label not in flagged_labels: + flagged_labels.append(str(label)) + if flagged_labels: + lines.append( + "年度边界提醒:" + + "、".join(flagged_labels) + + " 当前仍带冲突或受限标签;这部分继续按 parameter_sensitive / blocked 阅读,不提前升级为年度事件判断。" + ) + annual_focus_bits = [brief_text for _, brief_text, _ in annual_field_brief_rows] + if annual_focus_bits: + lines.append(f"年度附加点说明:{';'.join(annual_focus_bits)}。这层更适合先当成年度结构坐标来读,再回到各主题和月度支持里继续核对。") + annual_timing_focus = _annual_timing_layers_bridge() + has_annual_timing_focus = bool(annual_timing_focus) + if annual_timing_focus: + lines.append(annual_timing_focus) + annual_patyayini_focus = _annual_patyayini_focus_sentence() + if annual_patyayini_focus: + lines.append(annual_patyayini_focus) + if annual_asc_text: + lines.append(f"年度返照上升落在{annual_asc_text},这说明这一年的外部展开方式、本人的出场姿态和事件主舞台,已经形成了一条可直接阅读的年返主线。") + if year_lord_text: + lines.append(f"在年返控制权层,Year Lord 当前优先候选指向{year_lord_text},所以年度节奏更适合优先关注这颗星代表的推进方式,再把它和返照上升一起交叉阅读。") + if isinstance(mudda_periods, list) and mudda_periods and not has_annual_timing_focus: + first_wave = [] + for period in mudda_periods[:3]: + if not isinstance(period, dict): + continue + lord = _humanize_reader_token(period.get('lord')) + months = period.get('months') + if lord and months not in (None, ''): + first_wave.append(f"{lord}(约{months}个月)") + elif lord: + first_wave.append(lord) + if first_wave: + lines.append(f"Mudda Dasha 已经给出年度内部的推进顺序,前几段重点依次会先经过{'、'.join(first_wave)},所以这份年返不只是有年度主题,还已经开始有阶段节奏。") + if isinstance(monthly_windows, list) and monthly_windows and not has_annual_timing_focus: + monthly_bits = [] + for window in monthly_windows[:3]: + if not isinstance(window, dict): + continue + idx = window.get('index') + lord = _humanize_reader_token(window.get('lord')) + duration = window.get('duration_months') + if idx and lord and duration not in (None, ''): + monthly_bits.append(f"第{idx}段由{lord}主导(约{duration}个月)") + if monthly_bits: + lines.append(f"年度层还可以继续下探月度推进窗口:{';'.join(monthly_bits)}。这种分段节奏比单一全年总标题更接近 PL9 厚页报告的读法。") + annual_modules_ready = [] + if tajika_yogas.get('status') == 'partial_verified': + annual_modules_ready.append('Tajika Yogas') + if sahams.get('status') == 'partial_verified': + annual_modules_ready.append('Sahams') + if annual_modules_ready: + lines.append('年度附加点已经能读到一部分关系、机会、阻力和主题点位;它们不再只是年度壳层标题,但仍需要带着证据边界阅读。') + elif tajika_yogas.get('status') == 'blocked' and sahams.get('status') == 'blocked': + lines.append('年度附加点当前仍只作为受限核对信息保留;它们会出现在证据层里,但不会被提前写成年度关系、机会或阻力的确定结论。') + if tajika_candidates: + candidate_labels = [] + for candidate in tajika_candidates: + if not isinstance(candidate, dict): + continue + label = _humanize_tajika_candidate(candidate.get('name')) + if label and label not in candidate_labels: + candidate_labels.append(label) + if candidate_labels: + lines.append( + f"Tajika 候选提醒:年度候选交互里已经出现{'、'.join(candidate_labels[:2])}," + "但命名 Yoga 条件链与外部数值闭环尚未完成,因此当前只保留 parameter_sensitive / blocked 的候选阅读。" + ) + if tajika_yogas.get('status') in {'partial_verified', 'blocked'} or sahams.get('status') in {'partial_verified', 'blocked'}: + tajika_state = _md_cell(tajika_yogas.get('status') or 'blocked') + sahams_state = _md_cell(sahams.get('status') or 'blocked') + lines.append( + f"Tajika / Sahams 提醒:年度候选交互与敏感点都已经进入可读层,当前分别保持 {tajika_state} / {sahams_state};" + "它们适合补年度主题与敏感区,不单独升级为已成立年度事件。" + ) + future_annual_sections = _future_annual_focus_sections() + if future_annual_sections: + lines.extend(['']) + lines.extend(future_annual_sections) + + if annual_highlight_bits: + lines.extend(['', f"年度结构说明:{'。'.join(annual_highlight_bits[:2])}。"]) + annual_snapshot_rows = [] + annual_structure_text = annual_highlight_bits[0] if annual_highlight_bits else _humanize_reader_summary_line(str(annual_thematic_narrative.get('status') or '')) + if annual_structure_text: + annual_snapshot_rows.append(( + '年度结构', + annual_structure_text, + annual_thematic_narrative.get('status') or 'parameter_sensitive', + )) + annual_status_by_field = { + 'muntha': (muntha.get('status') if isinstance(muntha, dict) else None) or annual_evidence_appendix.get('status') or 'parameter_sensitive', + 'year_lord': (year_lord_field.get('status') if isinstance(year_lord_field, dict) else None) or annual_evidence_appendix.get('status') or 'parameter_sensitive', + 'tajika_yogas': tajika_yogas.get('status') or 'blocked', + 'sahams': sahams.get('status') or 'blocked', + } + for field_label, brief_text, field_key in annual_field_brief_rows: + annual_snapshot_rows.append(( + field_label, + brief_text, + annual_status_by_field.get(field_key) or annual_evidence_appendix.get('status') or 'parameter_sensitive', + )) + if annual_snapshot_rows: + lines.extend([ + '', + '#### 年度专题速览', + '', + '| 年度主题 | 当前可读信息 | 状态 |', + '|----------|----------------|------|', + ]) + for topic, detail, status in annual_snapshot_rows[:3]: + lines.append(f"| {_md_cell(topic)} | {_md_cell(detail)} | {_md_cell(status)} |") + if isinstance(mudda_periods, list) and mudda_periods: + lines.extend(['', '#### Mudda Dasha 年度阶段', '', '| 阶段 | 主星 | 月数 | 状态 |', '|------|------|------|------|']) + for idx, period in enumerate(mudda_periods[:9], start=1): + if isinstance(period, dict): + lines.append(f"| {idx} | {_md_cell(_humanize_reader_token(period.get('lord')))} | {_md_cell(period.get('months'))} | {_md_cell(period.get('status') or mudda_dasha.get('status'))} |") + domi_gap_appendix = _domi_pl9_current_gap_appendix() + if domi_gap_appendix: + lines.extend(['']) + lines.extend(domi_gap_appendix) + timing_mainline_section = _timing_mainline_section() + if timing_mainline_section: + lines.extend(['']) + lines.extend(timing_mainline_section) + graphical_ephemeris_section = _graphical_ephemeris_text_section() + if graphical_ephemeris_section: + lines.extend(['']) + lines.extend(graphical_ephemeris_section) + kp_three_year_monthly_section = _kp_three_year_monthly_section() + if kp_three_year_monthly_section: + lines.extend(['']) + lines.extend(kp_three_year_monthly_section) + if isinstance(monthly_windows, list) and monthly_windows: + lines.extend(['', '#### Monthly Windows', '', '| 序号 | 来源 | 主星 | 持续月数 | 状态 |', '|------|------|------|----------|------|']) + for window in monthly_windows[:12]: + if isinstance(window, dict): + lines.append( + f"| {_md_cell(window.get('index'))} | {_md_cell(window.get('source'))} | {_md_cell(_humanize_reader_token(window.get('lord')))} | " + f"{_md_cell(window.get('duration_months'))} | {_md_cell(window.get('status'))} |" + ) + if replay_patyayini_rows: + lines.extend([ + '', + '#### Patyayini Dasha 外部回放(PyJHora/JHora)', + '', + '下表仅展开外部回放 tuple 中实际返回的层级代码、边界分量和原始时长。tuple 未返回时区,边界语义也未完成 PL9 p134 对照;它不是本地 producer,所有行保持 pyjhora_behavior_only / not_multiengine_parity。', + '', + '| 序号 | 主层原始代号 | 子层原始代号 | 边界时间(外部 tuple;时区未返回) | 原始时长 | 状态 |', + '|------|--------------|--------------|-------------------------------------|----------|------|', + ]) + for row in replay_patyayini_rows[:72]: + if not isinstance(row, dict): + continue + lines.append( + f"| {_md_cell(row.get('order'))} | {_md_cell(row.get('main_code'))} | {_md_cell(row.get('sub_code'))} | " + f"{_md_cell(row.get('boundary_display'))} ({_md_cell(row.get('boundary_semantics'))}) | " + f"{_md_cell(row.get('duration_raw'))} | pyjhora_behavior_only / not_multiengine_parity |" + ) + if tajika_yogas.get('status') == 'partial_verified' or sahams.get('status') == 'partial_verified': + lines.extend(['', '#### 年度附加点', '', '| 项目 | 状态 |', '|------|------|']) + if tajika_yogas.get('status') == 'partial_verified': + lines.append('| Tajika Yogas | partial_verified |') + if sahams.get('status') == 'partial_verified': + lines.append('| Sahams | partial_verified |') + if tajika_candidates: + lines.extend([ + '', + '#### 年度 Tajika 候选交互(本地计算)', + '', + '下表仅呈现七曜相位、Deeptamsa 与趋近/分离关系形成的候选交互。完整命名 Yoga 链、事件解释及 PL9 条件页 parity 尚未闭环,因此所有行保持 parameter_sensitive。', + '', + '| 候选交互 | 原始代号 | 行星对 | 相位 | 偏差 | 运动 | 状态 |', + '|----------|----------|--------|------|------|------|------|', + ]) + for candidate in tajika_candidates: + if not isinstance(candidate, dict): + continue + candidate_name = candidate.get('name') or candidate.get('candidate_name') + planets = _envelope_list(candidate.get('planets')) + planet_pair = ' / '.join( + _humanize_reader_token(planet) + for planet in planets + if planet not in (None, '', [], {}) + ) + motion = candidate.get('motion') + if motion in (None, '', [], {}): + applying = candidate.get('applying') + if applying is True: + motion = 'applying' + elif applying is False: + motion = 'separating' + lines.append( + f"| {_md_cell(_humanize_tajika_candidate(candidate_name))} | {_md_cell(candidate_name)} | " + f"{_md_cell(planet_pair)} | {_md_cell(candidate.get('aspect'))}° | {_md_cell(candidate.get('residual'))}° | " + f"{_md_cell(_humanize_tajika_motion(motion))} | parameter_sensitive |" + ) + saham_data = sahams.get('data') if isinstance(sahams.get('data'), dict) else {} + saham_rows = [ + row for row in saham_data.values() + if isinstance(row, dict) and row.get('name') and row.get('sign') is not None + ] + if saham_rows: + lines.extend([ + '', + '#### 年度 Saham 原始点位(本地计算)', + '', + '下表仅保留 Saham 的名称、星座和星座内度数,不输出 producer 自带的解释文本或事件断语。年度点位尚未完成 PL9 条件页的字段级 parity,因此所有行保持 parameter_sensitive。', + '', + '| Saham | 星座 | 星座内度数 | 状态 |', + '|-------|------|------------|------|', + ]) + for row in sorted(saham_rows, key=lambda item: str(item.get('name'))): + lines.append( + f"| {_md_cell(row.get('name'))} | {_md_cell(row.get('sign_cn') or _humanize_reader_token(row.get('sign')))} | " + f"{_degree_text(row.get('degree_in_sign'))} | parameter_sensitive |" + ) + if replay_saham_values: + annual_return = replay_sahams_raw.get('annual_return') if isinstance(replay_sahams_raw.get('annual_return'), dict) else {} + return_date = annual_return.get('date') + return_time = annual_return.get('time') + return_display = ' / '.join(str(part) for part in (return_date, return_time) if part not in (None, '', [], {})) or '-' + daynight_label = 'daytime' if replay_saham_daynight.get('is_daytime') is True else 'nighttime' if replay_saham_daynight.get('is_daytime') is False else 'unavailable' + lines.extend([ + '', + '#### 年度 Saham 外部回放(PyJHora/JHora)', + '', + '以下是 PyJHora/JHora 以年度返照盘返回的逐项 Saham 度数。外部回放只用于数值交叉核对;它没有完成与本地 producer 的逐点 parity,也不证明年度事件、应期或命名 Yoga。', + '', + f"年度返照标记:`{_md_cell(return_display)}`;公式昼夜选择:`{_md_cell(daynight_label)}`;状态:`pyjhora_behavior_only / not_multiengine_parity`。", + '', + '| Saham(PyJHora 原始 callable) | 星座 | 星座内度数 | 状态 |', + '|------------------------------|------|------------|------|', + ]) + for name, longitude in sorted(replay_saham_values.items(), key=lambda item: str(item[0])): + if not isinstance(longitude, (int, float)): + continue + sign = SIGNS[int(float(longitude) // 30) % 12] + label = str(name).removesuffix('_saham').replace('_', ' ').title() + lines.append( + f"| {_md_cell(label)} | {_md_cell(SIGNS_CN.get(sign) or sign)} | {_degree_text(float(longitude) % 30)} | pyjhora_behavior_only / not_multiengine_parity |" + ) + if annual: + muntha_status = muntha.get('status') or 'blocked' + muntha_confidence = 'parameter_sensitive' if muntha_status in {'conflict', 'partial_verified'} else muntha_status + tajika_status = tajika_yogas.get('status') or 'blocked' + sahams_status = sahams.get('status') or 'blocked' + mudda_status = mudda_dasha.get('status') or 'blocked' + annual_chart_status = annual_chart.get('status') or 'blocked' + tajika_reason = tajika_yogas.get('reason') or '年度 Tajika 条件尚未完成外部数值闭环。' + lines.extend([ + '', + '#### 年度专题证据状态', + '', + '| 项目 | 调用状态 | 结论强度 | 当前边界 |', + '|------|----------|----------|----------|', + f"| 年度返照盘 | executed | {_md_cell(annual_chart_status)} | 年度盘结构已返回;尚未作为 PL9 全页数值 parity 声称。 |", + f"| Muntha | {_md_cell(muntha_status)} | {_md_cell(muntha_confidence)} | 同一计算 profile 的 producer 结果尚待仲裁。 |", + '| Year Lord | executed | pyjhora_behavior_only / not_multiengine_parity | 仅锁定 PyJHora 行为,尚未完成多引擎 parity/replay。 |', + f"| Mudda Dasha | executed | {_md_cell(mudda_status)} | 年度阶段已返回;外部 Varshaphala 回放尚未闭环。 |", + f"| Patyayini Dasha | {'executed' if patyayini_periods or replay_patyayini_periods else 'missing_in_local'} | {'parameter_sensitive' if patyayini_periods else 'pyjhora_behavior_only / not_multiengine_parity' if replay_patyayini_periods else 'blocked'} | {'已返回本地 periods;PL9 第 134 页的日期边界仍待字段级对照。' if patyayini_periods else 'PyJHora/JHora 外部回放已返回原始 periods;其数组尚未归一化,不作为本地 producer 或 PL9 日期边界 parity。' if replay_patyayini_periods else '当前年度 pack 没有 Patyayini periods;PL9 第 134 页 ending-date 表保持 missing_in_local。'} |", + f"| Tajika Yogas | blocked | blocked | {_md_cell(_humanize_reader_summary_line(str(tajika_reason)))} |", + f"| Sahams | executed | {_md_cell(sahams_status)} | 年度点位保留为补充数据,尚未作为全量 PL9 条件页 parity。 |", + ]) + lines.append('') + if include_pl9_evidence_tables: + strength_section = _strength_ashtakavarga_section() + if strength_section: + lines.extend(strength_section) + auxiliary_dasha_section = _auxiliary_dasha_section() + if auxiliary_dasha_section: + lines.extend(auxiliary_dasha_section) + dasha_sandhi_section = _dasha_sandhi_section() + if dasha_sandhi_section: + lines.extend(dasha_sandhi_section) + jaimini_section = _jaimini_special_section() + if jaimini_section: + lines.extend(jaimini_section) + aspects_section = _aspects_section() + if aspects_section: + lines.extend(aspects_section) + nakshatra_section = _nakshatra_crosscheck_section() + if nakshatra_section: + lines.extend(nakshatra_section) + natal_sahams_section = _natal_sahams_section() + if natal_sahams_section: + lines.extend(natal_sahams_section) + avastha_section = _avastha_section() + if avastha_section: + lines.extend(avastha_section) + dispositor_section = _dispositor_cross_varga_section() + if dispositor_section: + lines.extend(dispositor_section) + transit_section = _multi_reference_transit_section() + if transit_section: + lines.extend(transit_section) + yoga_section = _yoga_interpretation_boundary_section() + if yoga_section: + lines.extend(yoga_section) + + professional_support = full_report_pack.get('sections', {}).get('professional_support') if isinstance(full_report_pack.get('sections'), dict) else {} + professional_topics = professional_support.get('topics') if isinstance(professional_support.get('topics'), dict) else {} + if professional_support: + special_topic = professional_topics.get('special_lagnas') if isinstance(professional_topics.get('special_lagnas'), dict) else {} + narayana_topic = professional_topics.get('narayana_alignment') if isinstance(professional_topics.get('narayana_alignment'), dict) else {} + patyayini_topic = professional_topics.get('patyayini_annual_support') if isinstance(professional_topics.get('patyayini_annual_support'), dict) else {} + kp_topic = professional_topics.get('kp_domain_support') if isinstance(professional_topics.get('kp_domain_support'), dict) else {} + auxiliary_topic = professional_topics.get('auxiliary_dasha_support') if isinstance(professional_topics.get('auxiliary_dasha_support'), dict) else {} + natal_topic = professional_topics.get('natal_special_factor_support') if isinstance(professional_topics.get('natal_special_factor_support'), dict) else {} + restricted_topic = professional_topics.get('restricted_research_materials') if isinstance(professional_topics.get('restricted_research_materials'), dict) else {} + special_rows = [ + ('A10 / Karma Pada', _special_lagna_sign_text('A10_Karma_Pada', 'A10', 'Karma_Pada')), + ('Hora Lagna / Sree Lagna', ' / '.join(filter(None, [ + _special_lagna_sign_text('Hora_Lagna', 'HL'), + _special_lagna_sign_text('Sree_Lagna', 'SL'), + ]))), + ('UL / Pranapada', ' / '.join(filter(None, [ + _special_lagna_sign_text('Upapada_Lagna', 'UL', 'Upapada Lagna'), + _special_lagna_sign_text('PP', 'Pranapada_Lagna', 'Pranapada Lagna'), + ]))), + ] + special_detail_rows = _special_lagna_detail_rows() + professional_strength_rows = _professional_strength_rows() + patyayini_preview = [] + for row in replay_patyayini_rows[:3] if isinstance(replay_patyayini_rows, list) else []: + if not isinstance(row, dict): + continue + main_code = row.get('main_code') + sub_code = row.get('sub_code') + if main_code not in (None, ''): + patyayini_preview.append(f"{main_code} → {sub_code}" if sub_code not in (None, '') else str(main_code)) + kp_layers = kp_topic.get('available_layers') if isinstance(kp_topic.get('available_layers'), list) else [] + lines.extend([ + '', + '### 专业支持专题:第二证据轴', + '', + '本专题把已完成计算、但过去分散在技术页、附录或 support layer 的专业资料集中为正文级交叉阅读入口。它们只提供结构与时间的第二证据轴;不单独生成事件、应期或最终判断。', + '', + '#### 特殊上升点与 Arudha 支持', + '', + '以下逐项保留已经计算出的星座内度数、宫位和 producer 来源。它们是第二证据轴的原始结构,' + '不把单一特殊点写成财富、关系或职业事件的确定结论。', + '', + '| 专题锚点 | 星座与度数 | 宫位 | 计算来源 | 专题职责 | 状态 |', + '|----------|------------|------|----------|----------|------|', + ]) + if special_detail_rows: + for label, placement, house, source, role in special_detail_rows: + lines.append( + f"| {_md_cell(label)} | {_md_cell(placement)} | {_md_cell(house)} | {_md_cell(source)} | " + f"{_md_cell(role)} | {_md_cell(special_topic.get('status') or 'blocked')} |" + ) + else: + for label, value in special_rows: + lines.append( + f"| {_md_cell(label)} | {_md_cell(value or '-')} | - | - | 与相应分盘、本命和大运交叉;不单独升级专题结论。 | " + f"{_md_cell(special_topic.get('status') or 'blocked')} |" + ) + if professional_strength_rows: + lines.extend([ + '', + '#### 力量与兑现结构交叉摘要', + '', + '下表压缩功能性吉凶、Shadbala、Bhava Bala、SAV 与 Vimsopaka 的已执行结果,' + '使三大主题章节的强弱阅读能够回溯到原始结构。它不创建新的综合分数,也不替代完整力量/Ashtakavarga 附录。', + '', + '| 证据层 | 本次原始摘要 | 来源/方法 | 阅读边界 | 状态 |', + '|--------|--------------|-----------|----------|------|', + ]) + for label, summary, source, boundary in professional_strength_rows: + lines.append( + f"| {_md_cell(label)} | {_md_cell(summary)} | {_md_cell(source)} | {_md_cell(boundary)} | parameter_sensitive |" + ) + lines.extend([ + '', + '#### Narayana Dasha 交叉时间轴', + '', + f"Narayana 当前状态为 `{_md_cell(narayana_topic.get('status') or 'blocked')}`;它作为 Vimshottari 之外的第二时间轴参与主题排序。" + f"参考对齐状态:`{_md_cell(narayana_topic.get('reference_alignment_status') or 'not_closed')}`。" + '本地年龄轴与 PL9 日期边界/起运序列的字段级 parity 尚未闭环,因此不替代主时间判断。', + '', + '#### Patyayini 年内节奏支持', + '', + f"Patyayini 当前状态为 `{_md_cell(patyayini_topic.get('status') or 'blocked')}`;" + + (f"外部回放目前可见的前段序列为 {'、'.join(patyayini_preview)}。" if patyayini_preview else '当前没有可呈现的年内分段序列。') + + '它只补充年度内部换挡阅读,不作为本地 timing truth 或自动事件判断。', + '', + '#### KP 主题支持层', + '', + f"KP 当前状态为 `{_md_cell(kp_topic.get('status') or 'blocked')}`;已可用层为 `{_md_cell(' / '.join(str(item) for item in kp_layers) or '-')}`。" + '事业、财务与关系章节继续以宫头显著星、Ruling Planets 和三年逐月支持作交叉核对,所有结果保持 parameter_sensitive。', + '', + ]) + if auxiliary_topic: + systems = auxiliary_topic.get('systems') if isinstance(auxiliary_topic.get('systems'), dict) else {} + lines.extend([ + '#### 辅助大运族:Yogini / Ashtottari / Kala Chakra', + '', + '这三组周期已在完整报告的时间章节保留原始周期或冲突资料。此处固定它们的可用性与交叉核验边界:它们只能补充 Vimshottari + Narayana 主时间轴,不能单独生成应期、事件或最终判断。', + '', + '| 系统 | 周期是否可用 | 当前状态 | 正文角色 |', + '|------|--------------|----------|----------|', + ]) + for label, key in (('Yogini Dasha', 'yogini'), ('Ashtottari Dasha', 'ashtottari'), ('Kala Chakra Dasha', 'kala_chakra')): + row = systems.get(key) if isinstance(systems.get(key), dict) else {} + availability = 'available' if row.get('available') else 'blocked' + lines.append( + f"| {label} | {availability} | {_md_cell(row.get('status') or 'blocked')} | 交叉核验,不替代主时间轴 |" + ) + lines.extend(['', f"专题状态:{_md_cell(auxiliary_topic.get('status') or 'blocked')}。", '']) + if natal_topic: + layers = natal_topic.get('available_layers') if isinstance(natal_topic.get('available_layers'), dict) else {} + natal_sahams = advanced_sheet.get('sahams') if isinstance(advanced_sheet.get('sahams'), dict) else {} + avasthas = advanced_sheet.get('avasthas') if isinstance(advanced_sheet.get('avasthas'), dict) else {} + upagrahas = divisional_sheet.get('upagrahas') if isinstance(divisional_sheet.get('upagrahas'), dict) else {} + saham_preview = [] + for key in ('karya_saham', 'dhan_saham', 'vivah_saham', 'rajya_saham'): + point = natal_sahams.get(key) if isinstance(natal_sahams.get(key), dict) else {} + if not point: + continue + sign = _humanize_reader_token(point.get('sign')) + degree = point.get('degree_in_sign') + label = point.get('name') or key.replace('_saham', '').replace('_', ' ').title() + if sign: + saham_preview.append(f"{label}:{sign} {_degree_text(degree)}") + avastha_planet_count = sum( + 1 for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu') + if isinstance(avasthas.get(planet), dict) and isinstance(avasthas.get(planet, {}).get('avasthas'), dict) + ) + upagraha_raw = upagrahas.get('raw') if isinstance(upagrahas.get('raw'), dict) else {} + upagraha_preview = [] + for key in ('Gulika', 'Maandi', 'Dhuma', 'Vyatipata'): + point = upagraha_raw.get(key) if isinstance(upagraha_raw.get(key), dict) else {} + if not point: + continue + try: + sign = SIGNS_CN.get(SIGNS[int(point.get('sign_idx')) % 12], '-') + except (TypeError, ValueError): + sign = '-' + upagraha_preview.append(f"{key}:{sign} {_degree_text(point.get('degree_in_sign'))}") + lines.extend([ + '#### 本命补充因子:Sahams / Avasthas / Upagrahas', + '', + '这些本命补充因子的原始点位与状态在后续技术章节完整保留。此处给出可复核索引,' + '用于限定主题阅读和发现需要人工核验的结构,不能被压缩成独立的事件断语或确定性结论。', + '', + '| 因子层 | 本次原始摘要 | 正文角色 | 状态 |', + '|--------|--------------|----------|------|', + f"| Natal Sahams | {';'.join(saham_preview) if saham_preview else 'available' if layers.get('natal_sahams') else 'blocked'} | 主题补充点位;完整点位表见后续技术章节 | parameter_sensitive |", + f"| Avasthas | 已执行 {avastha_planet_count} 个行星状态组 | 五类状态交叉核验;不采纳 producer 内置补救或事件文本 | parameter_sensitive |", + f"| Upagrahas | {';'.join(upagraha_preview) if upagraha_preview else 'available' if layers.get('upagrahas') else 'blocked'} | D1 原始补充点位;完整 11 点清单见技术章节 | parameter_sensitive |", + '', + f"专题状态:{_md_cell(natal_topic.get('status') or 'blocked')}。", + '', + ]) + if restricted_topic: + materials = restricted_topic.get('materials') if isinstance(restricted_topic.get('materials'), dict) else {} + labels = ( + ('jaimini_special_points', 'Jaimini Yogi / Ava Yogi / Brahma / Maheshwara / Rudra'), + ('tajika_named_yoga', 'Tajika Named Yogas'), + ('annual_sahams', 'Annual Sahams'), + ('kranti', 'Kranti'), + ('graha_padas', 'Graha Padas'), + ) + lines.extend([ + '#### 未闭环专业层:可见但不入判断', + '', + '以下资料或计算中间层已被明确登记,因此不会从报告中静默消失;但它们尚未完成 producer、同口径或外部 parity 闭环。它们不可生成事件、应期、最终判断或 promotion 决定。', + '', + '| 专业层 | 当前状态 | 未闭环原因 |', + '|--------|----------|------------|', + ]) + for key, label in labels: + row = materials.get(key) if isinstance(materials.get(key), dict) else {} + lines.append( + f"| {_md_cell(label)} | {_md_cell(row.get('status') or 'blocked')} | {_md_cell(row.get('reason') or 'not_available')} |" + ) + lines.extend(['', '']) + kranti_material = materials.get('kranti') if isinstance(materials.get('kranti'), dict) else {} + p33_summary = kranti_material.get('p33_contract_summary') if isinstance(kranti_material.get('p33_contract_summary'), dict) else {} + if p33_summary: + lines.extend([ + '#### p33 Declination / Kranti / KP cusp 对照摘要', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| 合同记录行 | {_md_cell(p33_summary.get('record_count'))} | controlled_pdf_extraction |", + f"| 状态分布 | {_md_cell(p33_summary.get('by_status'))} | parameter_sensitive_boundary |", + f"| KP cusp 本地对照宫数 | {_md_cell(p33_summary.get('kp_cusp_comparison_count'))} | parameter_sensitive |", + f"| KP cusp 最大差值(度) | {_md_cell(p33_summary.get('kp_cusp_max_delta_degrees'))} | profile_not_closed |", + f"| Kranti blocked 合同 | {_md_cell(p33_summary.get('kranti_dependency_contract_count'))} | independent_producer_missing |", + f"| Kranti blocked 行星 | {_md_cell(p33_summary.get('kranti_blocked_planets'))} | not_promoted |", + '', + ]) + friendship_material = materials.get('planetary_friendship') if isinstance(materials.get('planetary_friendship'), dict) else {} + p41_summary = friendship_material.get('p41_contract_summary') if isinstance(friendship_material.get('p41_contract_summary'), dict) else {} + if p41_summary: + lines.extend([ + '#### p41 行星友好关系对照摘要', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| 关系集合记录 | {_md_cell(p41_summary.get('record_count'))} | controlled_pdf_extraction |", + f"| 状态分布 | {_md_cell(p41_summary.get('by_status'))} | parameter_sensitive_boundary |", + f"| 已对照集合 | {_md_cell(p41_summary.get('set_comparison_count'))} | local_vs_pl9_sets_recorded |", + f"| 最大集合差异数 | {_md_cell(p41_summary.get('max_difference_count'))} | profile_not_closed |", + f"| 匹配集合 | {_md_cell(p41_summary.get('matched_fields'))} | fixed_match_rows_only |", + '', + ]) + shadbala_material = materials.get('shadbala_components') if isinstance(materials.get('shadbala_components'), dict) else {} + p43_44_summary = shadbala_material.get('p43_44_contract_summary') if isinstance(shadbala_material.get('p43_44_contract_summary'), dict) else {} + if p43_44_summary: + component_profile = p43_44_summary.get('component_profile') if isinstance(p43_44_summary.get('component_profile'), dict) else {} + largest_delta = component_profile.get('largest_delta') if isinstance(component_profile.get('largest_delta'), dict) else {} + lines.extend([ + '#### p43-p44 Shadbala 分量对照摘要', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| 合同记录行 | {_md_cell(p43_44_summary.get('record_count'))} | controlled_fixture |", + f"| 状态分布 | {_md_cell(p43_44_summary.get('by_status'))} | partial_boundary |", + f"| 可数值对照分量 | {_md_cell(p43_44_summary.get('comparable_component_count'))} | local_vs_pl9_delta_recorded |", + f"| 最大差值(Virupas) | {_md_cell(p43_44_summary.get('max_delta_virupas'))} | formula_profile_not_closed |", + f"| 允许 match 的分量 | {_md_cell(p43_44_summary.get('match_component_policy'))} | fixed_policy |", + f"| 分量 profile 状态 | {_md_cell(component_profile.get('status'))} | report_readability_profile |", + f"| 已闭合分量名 | {_md_cell(component_profile.get('matched_component_names'))} | match_policy_only |", + f"| 公式 profile 未闭合分量 | {_md_cell(component_profile.get('blocked_formula_profile_component_names'))} | blocked_formula_profile |", + f"| 最大差值字段 | {_md_cell(largest_delta.get('field'))} | delta_retained |", + '', + ]) + diagnostics = component_profile.get('component_diagnostics') if isinstance(component_profile.get('component_diagnostics'), list) else [] + if diagnostics: + lines.extend([ + '#### p43-p44 Shadbala 分量 profile 诊断', + '', + '| 分量 | 当前状态 | 最大差值 | 主要差异源 | 闭合要求 | 下一优先级 |', + '|------|----------|----------|------------|----------|------------|', + ]) + for diagnostic in diagnostics: + if not isinstance(diagnostic, dict): + continue + lines.append( + f"| {_md_cell(diagnostic.get('component'))} | " + f"{_md_cell(diagnostic.get('promotion_status'))} | " + f"{_md_cell(diagnostic.get('largest_delta_field'))}: {_md_cell(diagnostic.get('max_delta_virupas'))} | " + f"{_md_cell(diagnostic.get('likely_difference_sources'))} | " + f"{_md_cell(diagnostic.get('closure_requirement'))} | " + f"{_md_cell(diagnostic.get('next_priority'))} |" + ) + lines.append('') + kala_profile = component_profile.get('kala_bala_subcomponent_profile') if isinstance(component_profile.get('kala_bala_subcomponent_profile'), dict) else {} + kala_rows = kala_profile.get('rows') if isinstance(kala_profile.get('rows'), list) else [] + if kala_rows: + try: + from domi_kala_difference_attribution import build_attribution as _build_domi_kala_attribution + kala_difference_attribution = _build_domi_kala_attribution() + except Exception as exc: + kala_difference_attribution = { + 'status': 'blocked', + 'claim_boundary': f'domi_kala_difference_attribution_unavailable: {exc}', + } + lines.extend([ + '#### Kala Bala 子分量 profile(PL9 / PyJHora / 本地三方观测)', + '', + f"状态:{_md_cell(kala_profile.get('status'))}。本表把 day/night、paksha、tribhaga、calendar/hora、ayana/declination 等差异源拆开审计;当前已记录 PL9/PyJHora/本地子分量行,但总 Kala Bala 公式语义与 JHora desktop 证据尚未闭合,因此不能升级为完整 parity。", + '', + '| 子分量 | 本地字段 | PL9覆盖 | 本地覆盖 | PyJHora捕获 | PL9-本地最大差 | PL9-PyJHora最大差 | 状态 | 闭合要求 |', + '|--------|----------|----------|----------|---------------|----------------|--------------------|------|----------|', + ]) + for row in kala_rows: + if not isinstance(row, dict): + continue + pyjhora_capture = f"{row.get('pyjhora_capture_status')} / {row.get('pyjhora_observed_planet_count')}" + lines.append( + f"| {_md_cell(row.get('label'))} | " + f"{_md_cell(row.get('local_keys'))} | " + f"{_md_cell(row.get('pl9_observed_planet_count'))} | " + f"{_md_cell(row.get('local_observed_planet_count'))} | " + f"{_md_cell(pyjhora_capture)} | " + f"{_md_cell(row.get('max_abs_delta_pl9_local'))} | " + f"{_md_cell(row.get('max_abs_delta_pl9_pyjhora'))} | " + f"{_md_cell(row.get('status'))} | " + f"{_md_cell(row.get('closure_requirement'))} |" + ) + lines.append('') + attribution_rows = ( + kala_difference_attribution.get('rows') + if isinstance(kala_difference_attribution.get('rows'), list) + else [] + ) + if attribution_rows: + attribution_summary = ( + kala_difference_attribution.get('summary') + if isinstance(kala_difference_attribution.get('summary'), dict) + else {} + ) + lines.extend([ + '#### Kala Bala 差异归因(Paksha / Calendar-Hora)', + '', + f"状态:{_md_cell(kala_difference_attribution.get('status'))}。最大差异源:{_md_cell((attribution_summary.get('top_difference_source') or {}).get('label'))};当前仍不声称 Kala total parity 或 JHora desktop parity。", + f"JHora 最小补证项:{_md_cell(attribution_summary.get('jhora_capture_required_rows') or ['Moon.paksha', 'Moon.hora', 'Saturn.hora'])};模板:references/oracle/artifacts/jhora_domi_kala_paksha_hora_manual_template_20260826.json。", + '', + '| 目标 | 最大 local-PL9 差 | 最大 PyJHora-PL9 差 | 高差异行 | 归因状态 |', + '|------|-------------------|---------------------|----------|----------|', + ]) + for attribution in attribution_rows: + if not isinstance(attribution, dict): + continue + high_delta_rows = attribution.get('high_delta_rows') if isinstance(attribution.get('high_delta_rows'), list) else [] + high_delta_text = '; '.join( + f"{row.get('planet')}.{row.get('key')} PL9={row.get('pl9')} local={row.get('local')} PyJHora={row.get('pyjhora')}" + for row in high_delta_rows[:4] + if isinstance(row, dict) + ) + lines.append( + f"| {_md_cell(attribution.get('label'))} | " + f"{_md_cell(attribution.get('max_abs_delta_local_vs_pl9'))} | " + f"{_md_cell(attribution.get('max_abs_delta_pyjhora_vs_pl9'))} | " + f"{_md_cell(high_delta_text)} | " + f"{_md_cell(attribution.get('status'))} |" + ) + lines.append('') + report_sections = full_report_pack.get('sections') if isinstance(full_report_pack.get('sections'), dict) else {} + divisional_section = report_sections.get('divisional') if isinstance(report_sections.get('divisional'), dict) else {} + divisional_data = divisional_section.get('data') if isinstance(divisional_section.get('data'), dict) else {} + p42_contract = divisional_data.get('pl9_p42_shodashvarga_contract') if isinstance(divisional_data.get('pl9_p42_shodashvarga_contract'), dict) else {} + p42_summary = p42_contract.get('summary') if isinstance(p42_contract.get('summary'), dict) else {} + if p42_summary: + visual_sheet = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + visual_pack = visual_sheet.get('visual_chart_pack') if isinstance(visual_sheet.get('visual_chart_pack'), dict) else {} + visual_summary = visual_pack.get('placement_summary') if isinstance(visual_pack.get('placement_summary'), dict) else {} + visual_placement = visual_pack.get('content_placement') if isinstance(visual_pack.get('content_placement'), dict) else {} + d150_panel = {} + for visual_panel in visual_placement.get('candidate_panels') or []: + if isinstance(visual_panel, dict) and visual_panel.get('panel_id') == 'p013-r3c1': + d150_panel = visual_panel + break + d150_anchor = ( + d150_panel.get('extraction', {}).get('anchor_cross_check', {}) + if isinstance(d150_panel.get('extraction'), dict) + else {} + ) + d150_visual_status = 'visual_grid_not_extracted' + if d150_anchor.get('status') == 'candidate': + d150_visual_status = 'visual_content_extracted_asc_sign_anchor_closed' + elif int(visual_summary.get('parsed_placement_count') or 0) > 0 and 13 in (visual_summary.get('page_numbers') or []): + d150_visual_status = ( + 'visual_content_extracted_partial' + if int(visual_summary.get('anchored_candidate_panel_count') or 0) > 0 + else 'visual_content_extracted_asc_sign_anchor_blocked' + ) + lines.extend([ + '#### p42 Shodashvarga 分盘汇总对照摘要', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| 合同记录行 | {_md_cell(p42_summary.get('record_count'))} | controlled_pdf_extraction |", + f"| 状态分布 | {_md_cell(p42_summary.get('by_status'))} | sign_rows_match_dignity_partial |", + f"| Dignity 单元 | {_md_cell(p42_summary.get('dignity_cell_count'))} | vocabulary_comparison_recorded |", + f"| Dignity 归一匹配 | {_md_cell(p42_summary.get('dignity_normalized_match_count'))} | lexical_normalization_only |", + f"| Dignity 词汇差异 | {_md_cell(p42_summary.get('dignity_vocabulary_mismatch_count'))} | profile_not_closed |", + f"| Visual variant 合同 | {_md_cell(p42_summary.get('visual_variant_contract_count'))} | visual_boundary_recorded |", + f"| D150 本地研究分盘 | {_md_cell(p42_summary.get('d150_local_research_high_available'))} | research_generic_dn_not_promoted |", + f"| D150 visual panels | {_md_cell(p42_summary.get('d150_visual_manifest_panel_count'))} | {d150_visual_status} |", + f"| Iyer visual labels | {_md_cell(p42_summary.get('iyer_visual_manifest_label_count'))} | variant_profile_not_closed |", + '', + ]) + p42_records = { + record.get('field'): record for record in (p42_contract.get('records') if isinstance(p42_contract.get('records'), list) else []) + if isinstance(record, dict) + } + d150_record = p42_records.get('D150.visual_variant') if isinstance(p42_records.get('D150.visual_variant'), dict) else {} + d150_contract = d150_record.get('visual_variant_contract') if isinstance(d150_record.get('visual_variant_contract'), dict) else {} + d150_preview = d150_contract.get('local_value_preview') if isinstance(d150_contract.get('local_value_preview'), dict) else {} + if d150_preview: + lines.extend([ + '#### D150 visual variant 本地预览', + '', + '本表只展示本地 research-generic D150_NadiAmsa 的少量原始点位,并记录 PL9 视觉页存在 D150 panel。它不是 p42 Shodashvarga Summary 字段级复刻。', + '', + '| 点位 | 星座 | 宫位/度数 | 状态 |', + '|------|------|-----------|------|', + ]) + for label in ('Ascendant', 'Sun', 'Moon'): + point = d150_preview.get(label) if isinstance(d150_preview.get(label), dict) else {} + if not point: + continue + detail = point.get('degree_in_sign') + if detail is None: + detail = point.get('house') or point.get('degree') or point.get('lon') + lines.append( + f"| {_md_cell(label)} | {_md_cell(_humanize_reader_token(point.get('sign')))} | " + f"{_md_cell(detail)} | research_generic_dn_not_promoted |" + ) + lines.extend(['', '']) + iyer_record = p42_records.get('Iyer.variant_rows') if isinstance(p42_records.get('Iyer.variant_rows'), dict) else {} + iyer_contract = iyer_record.get('visual_variant_contract') if isinstance(iyer_record.get('visual_variant_contract'), dict) else {} + iyer_labels = iyer_contract.get('visual_manifest_labels') if isinstance(iyer_contract.get('visual_manifest_labels'), list) else [] + if iyer_labels: + lines.extend([ + '#### Iyer visual variant 标签清单', + '', + '| 序号 | PL9 visual label | 状态 |', + '|------|------------------|------|', + ]) + for index, label in enumerate(iyer_labels, start=1): + lines.append(f"| {index} | {_md_cell(label)} | variant_profile_not_closed |") + lines.extend(['', '']) + yogi_material = materials.get('jaimini_special_points') if isinstance(materials.get('jaimini_special_points'), dict) else {} + yogi_replay = yogi_material.get('yogi_avayogi_external_replay') if isinstance(yogi_material.get('yogi_avayogi_external_replay'), dict) else {} + yogi_point = yogi_replay.get('yogi') if isinstance(yogi_replay.get('yogi'), dict) else {} + ava_point = yogi_replay.get('ava_yogi') if isinstance(yogi_replay.get('ava_yogi'), dict) else {} + if yogi_point and ava_point: + p89_summary = yogi_material.get('p89_contract_summary') if isinstance(yogi_material.get('p89_contract_summary'), dict) else {} + if p89_summary: + lines.extend([ + '#### p89 Jaimini 特殊点位 blocked 合同摘要', + '', + '| 项目 | 数值 | 状态 |', + '|------|------|------|', + f"| Blocked dependency contracts | {_md_cell(p89_summary.get('blocked_dependency_contract_count'))} | explicit_blocked_contracts |", + f"| Blocked contract fields | {_md_cell(_envelope_list(p89_summary.get('blocked_dependency_contract_fields')))} | not_promoted |", + '', + ]) + lines.extend([ + '#### Yogi / Ava Yogi 外部原始回放(PyJHora/JHora)', + '', + '下表仅记录外部引擎返回的点位、星宿主与星座主,用于研究复核。它不是本地 producer,' + '未完成多引擎 parity;不得据此生成财富、事件、应期、补救、最终判断或 promotion。', + '', + '| 点位 | 星座与度数 | 星宿 / 主星 | 星座主 | 外部口径 | 状态 |', + '|------|------------|-------------|--------|----------|------|', + ]) + for label, point in (('Yogi', yogi_point), ('Ava Yogi', ava_point)): + placement = f"{_humanize_reader_token(point.get('sign'))} {_degree_text(point.get('degree_in_sign'))}" + nakshatra = f"{_humanize_reader_token(point.get('nakshatra'))} / {_humanize_reader_token(point.get('nakshatra_lord'))}" + lines.append( + f"| {label} | {_md_cell(placement)} | {_md_cell(nakshatra)} | " + f"{_md_cell(_humanize_reader_token(point.get('sign_lord')))} | PyJHora external replay | " + 'pyjhora_behavior_only / not_multiengine_parity |' + ) + lines.extend(['', '']) + lines.extend([ + '', + '### 事业与外部发展', + '', + ]) + career_basis_sentence = _chapter_called_basis_sentence('career') + if career_basis_sentence: + lines.append(career_basis_sentence) + career_dasha_support = _dasha_domain_support_sentence('career') + if career_dasha_support: + lines.append(career_dasha_support) + career_multi_dasha_support = _multi_dasha_support_sentence('career') + if career_multi_dasha_support: + lines.append(career_multi_dasha_support) + career_strength_support = _strength_domain_support_sentence('career') + if career_strength_support: + lines.append(career_strength_support) + guided_career_title = _guided_topic_title_sentence('career_direction') + guided_career_value = _guided_topic_value_sentence('career_direction') + career_lead = _compose_narrative_paragraph( + guided_career_title or '事业与外部发展的现实舞台已经先被点亮。', + f"这一年的外部舞台会先被{annual_asc_text}主题点亮。" if annual_asc_text else '年度主壳层已经先把这一年的现实节奏和个人重心托起来,因此事业线会更直接地落到现实推进、身份位置和外部变化上。', + guided_career_value, + ) + if career_lead: + lines.append(career_lead) + career_support_blend = _domain_support_blend('career') + if career_support_blend: + lines.append(career_support_blend) + career_high_value_authority = _high_value_theme_authority_sentence('career') + if career_high_value_authority: + lines.append(career_high_value_authority) + lines.extend(['', '#### KP 与多系统冲突裁决', '']) + lines.append(_kp_conflict_adjudication_sentence('career')) + career_kp_summary = _kp_domain_summary_block('career') + if career_kp_summary: + lines.extend(career_kp_summary) + kp_career_section = _kp_domain_evidence_section('career') + if kp_career_section: + lines.extend(['']) + lines.extend(kp_career_section) + career_kp_monthly = _kp_monthly_theme_brief('career') + if career_kp_monthly: + lines.extend(['']) + lines.extend(career_kp_monthly) + + lines.extend([ + '', + '### 财运与资源使用', + '', + ]) + wealth_basis_sentence = _chapter_called_basis_sentence('wealth') + if wealth_basis_sentence: + lines.append(wealth_basis_sentence) + wealth_dasha_support = _dasha_domain_support_sentence('wealth') + if wealth_dasha_support: + lines.append(wealth_dasha_support) + wealth_multi_dasha_support = _multi_dasha_support_sentence('wealth') + if wealth_multi_dasha_support: + lines.append(wealth_multi_dasha_support) + wealth_strength_support = _strength_domain_support_sentence('wealth') + if wealth_strength_support: + lines.append(wealth_strength_support) + guided_wealth_title = _guided_topic_title_sentence('wealth_risk') + guided_wealth_value = _guided_topic_value_sentence('wealth_risk') + wealth_lead = _compose_narrative_paragraph( + guided_wealth_title or '财运与资源使用这条线,先浮出来的不是单一金额式结论,而是资源感、支撑感和调度能力的强弱分布。', + guided_wealth_value, + ) + if wealth_lead: + lines.append(wealth_lead) + wealth_support_blend = _domain_support_blend('wealth') + if wealth_support_blend: + lines.append(wealth_support_blend) + wealth_high_value_authority = _high_value_theme_authority_sentence('wealth') + if wealth_high_value_authority: + lines.append(wealth_high_value_authority) + lines.extend(['', '#### KP 与多系统冲突裁决', '']) + lines.append(_kp_conflict_adjudication_sentence('wealth')) + wealth_kp_summary = _kp_domain_summary_block('wealth') + if wealth_kp_summary: + lines.extend(wealth_kp_summary) + kp_wealth_section = _kp_domain_evidence_section('wealth') + if kp_wealth_section: + lines.extend(['']) + lines.extend(kp_wealth_section) + wealth_kp_monthly = _kp_monthly_theme_brief('wealth') + if wealth_kp_monthly: + lines.extend(['']) + lines.extend(wealth_kp_monthly) + lines.extend([ + '', + '### 关系与合作模式', + '', + ]) + relationship_basis_sentence = _chapter_called_basis_sentence('relationship') + if relationship_basis_sentence: + lines.append(relationship_basis_sentence) + relationship_dasha_support = _dasha_domain_support_sentence('relationship') + if relationship_dasha_support: + lines.append(relationship_dasha_support) + relationship_multi_dasha_support = _multi_dasha_support_sentence('relationship') + if relationship_multi_dasha_support: + lines.append(relationship_multi_dasha_support) + relationship_strength_support = _strength_domain_support_sentence('relationship') + if relationship_strength_support: + lines.append(relationship_strength_support) + guided_relationship_title = _guided_topic_title_sentence('relationship_partnership') + guided_relationship_value = _guided_topic_value_sentence('relationship_partnership') + relationship_lead = _compose_narrative_paragraph( + guided_relationship_title or '关系与合作模式这条线,已经露出比表面互动更深的底色。', + f"Karakamsha 落在{karakamsha_text},说明真正牵动你长期联结与合作默契的,不只是表面互动,而是更深层的价值感和关系议题。" if karakamsha_text else None, + guided_relationship_value, + ) + if relationship_lead: + lines.append(relationship_lead) + relationship_support_blend = _domain_support_blend('relationship') + if relationship_support_blend: + lines.append(relationship_support_blend) + relationship_high_value_authority = _high_value_theme_authority_sentence('relationship') + if relationship_high_value_authority: + lines.append(relationship_high_value_authority) + lines.extend(['', '#### KP 与多系统冲突裁决', '']) + lines.append(_kp_conflict_adjudication_sentence('relationship')) + relationship_kp_summary = _kp_domain_summary_block('relationship') + if relationship_kp_summary: + lines.extend(relationship_kp_summary) + kp_relationship_section = _kp_domain_evidence_section('relationship') + if kp_relationship_section: + lines.extend(['']) + lines.extend(kp_relationship_section) + relationship_kp_monthly = _kp_monthly_theme_brief('relationship') + if relationship_kp_monthly: + lines.extend(['']) + lines.extend(relationship_kp_monthly) + unexported_layers_section = _reader_unexported_high_value_layers_section() + if unexported_layers_section: + lines.extend(['']) + lines.extend(unexported_layers_section) + + lines.extend([ + '', + '### 时间与年度提醒', + '', + ]) + guided_timing_value = _guided_topic_value_sentence('birth_time_rectification') + if guided_timing_value: + lines.append(guided_timing_value) + dasha_timing_summary = _dasha_timing_summary_sentence() + if dasha_timing_summary: + lines.append(dasha_timing_summary) + dasha_exec_summary_sentence = _dasha_exec_summary_sentence() + if dasha_exec_summary_sentence: + lines.append(dasha_exec_summary_sentence) + if key_time_node_lines: + lines.extend(['', '#### 关键时间节点回看', '']) + lines.extend(key_time_node_lines) + if annual_brief_bits: + lines.append(f"年度补充锚点:{';'.join(annual_brief_bits)}。") + annual_timing_layers_bridge = _annual_timing_layers_bridge() + if annual_timing_layers_bridge: + lines.append(annual_timing_layers_bridge) + patyayini_reader_bridge = _patyayini_reader_bridge() + if patyayini_reader_bridge: + lines.extend(['']) + lines.extend(patyayini_reader_bridge) + prashna_exact_cusp_boundary = _prashna_exact_cusp_boundary_sentence() + if prashna_exact_cusp_boundary: + lines.append(prashna_exact_cusp_boundary) + dasha_appendix_boundary = _dasha_appendix_boundary_sentence() + if dasha_appendix_boundary: + lines.append(dasha_appendix_boundary) + if annual_summary_bits: + lines.append('年度重点信息已在上方年度小节列出;这里不再重复 Muntha、Year Lord 等字段明细,只提醒你回到年度小节核对完整结构。') + + raw_timing_rows = [] + bhrigu_raw = timing_sheet.get('bhrigu_pada_dasha') if isinstance(timing_sheet.get('bhrigu_pada_dasha'), dict) else {} + if bhrigu_raw: + birth_moon = bhrigu_raw.get('birth_moon') if isinstance(bhrigu_raw.get('birth_moon'), dict) else {} + raw_timing_rows.append(( + 'Bhrigu Pada / BCP', + bhrigu_raw.get('status', 'blocked'), + f"出生月亮={birth_moon.get('longitude', '-')}°;序列行数={len(bhrigu_raw.get('dasha_sequence', []))}", + 'raw_full_reading.modules.bhrigu_pada_dasha', + )) + pac_raw = timing_sheet.get('double_transit_pac') if isinstance(timing_sheet.get('double_transit_pac'), dict) else {} + if pac_raw: + pac_entries = pac_raw.get('double_transit') if isinstance(pac_raw.get('double_transit'), list) else [] + raw_timing_rows.append(( + 'Double Transit PAC', + pac_raw.get('status', 'blocked'), + f"参考日={pac_raw.get('reference_date', pac_raw.get('transit_date', '-'))};双重触发条目={len(pac_entries)}", + 'raw_full_reading.modules.double_transit_pac', + )) + ll7l_raw = timing_sheet.get('transit_ll7l') if isinstance(timing_sheet.get('transit_ll7l'), dict) else {} + if ll7l_raw: + p5 = ll7l_raw.get('p5') if isinstance(ll7l_raw.get('p5'), dict) else {} + p8 = ll7l_raw.get('p8') if isinstance(ll7l_raw.get('p8'), dict) else {} + raw_timing_rows.append(( + 'Transit LL/7L', + ll7l_raw.get('status', 'blocked'), + f"参考日={ll7l_raw.get('reference_date', ll7l_raw.get('transit_date', '-'))};P5={p5.get('hit', '-')};P8={p8.get('hit', '-')}", + 'raw_full_reading.modules.transit_ll7l', + )) + congregation_raw = timing_sheet.get('planetary_congregation') if isinstance(timing_sheet.get('planetary_congregation'), dict) else {} + if congregation_raw: + raw_timing_rows.append(( + 'Planetary Congregation', + congregation_raw.get('status', 'blocked'), + f"参考日={congregation_raw.get('reference_date', '-') };命中={congregation_raw.get('hit', '-')};旗标数={len(congregation_raw.get('flags', []))}", + 'raw_full_reading.modules.planetary_congregation', + )) + western_raw = timing_sheet.get('unified_western_reading') if isinstance(timing_sheet.get('unified_western_reading'), dict) else {} + if western_raw: + western_layers = ((western_raw.get('raw_result') or {}).get('layers') or {}) if isinstance(western_raw.get('raw_result'), dict) else {} + executed_layers = [name for name, item in western_layers.items() if isinstance(item, dict) and item.get('status') == 'executed'] + raw_timing_rows.append(( + 'Unified Western Raw Layer', + western_raw.get('status', 'blocked'), + f"参考日={western_raw.get('reference_date', '-')};已执行层={','.join(executed_layers)}", + 'raw_full_reading.modules.unified_western_reading', + )) + if raw_timing_rows: + lines.extend(['', '#### 原始时机观察', '', '本表只展示已运行计算的原始结构,不生成事件结论,也不参与精确应期升级。', '', '| 层 | 状态 | 原始观察 | JSON 路径 |', '|----|------|----------|-----------|']) + for layer, status, observation, path in raw_timing_rows: + lines.append(f"| {_md_cell(layer)} | {_md_cell(status)} | {_md_cell(observation)} | `{_md_cell(path)}` |") + + birth_provenance = packet.get('birth_provenance') if isinstance(packet.get('birth_provenance'), dict) else {} + timing_contract = packet.get('timing_precision_contract') if isinstance(packet.get('timing_precision_contract'), dict) else {} + birth_time_sensitivity = packet.get('birth_time_sensitivity') if isinstance(packet.get('birth_time_sensitivity'), dict) else {} + event_replay = packet.get('event_replay') if isinstance(packet.get('event_replay'), dict) else {} + technique_audit_table = packet.get('technique_audit_table') if isinstance(packet.get('technique_audit_table'), list) else [] + raw_module_usage_map = packet.get('raw_module_usage_map') if isinstance(packet.get('raw_module_usage_map'), dict) else {} + rectification_evidence_contract = packet.get('rectification_evidence_contract') if isinstance(packet.get('rectification_evidence_contract'), dict) else {} + timing_boundary_attribution = packet.get('timing_boundary_attribution') if isinstance(packet.get('timing_boundary_attribution'), dict) else {} + module_execution_audit = packet.get('module_execution_audit') if isinstance(packet.get('module_execution_audit'), list) else [] + + lines.extend(['', '## 出生资料来源与不确定范围', '']) + lines.append(f"- 地点标签:{_md_cell(birth_provenance.get('birthplace_label', 'not_recorded'))}") + lines.append(f"- 坐标精度:{_md_cell(birth_provenance.get('coordinate_precision', 'unverified_user_coordinates'))}") + lines.append(f"- 坐标来源:{_md_cell(birth_provenance.get('coordinate_source', 'not_recorded'))}") + lines.append(f"- 出生时间来源:{_md_cell(birth_provenance.get('time_source', 'not_recorded'))}") + lines.append(f"- 时间不确定范围(分钟):{_md_cell(birth_provenance.get('uncertainty_minutes', 'not_recorded'))}") + lines.append(f"- 边界:{_md_cell(birth_provenance.get('boundary', 'not_recorded'))}") + + lines.extend(['', '## 时间精度合同', '']) + lines.append(_md_cell(timing_contract.get('boundary', 'timing_precision_contract_missing'))) + lines.extend(['', '| 粒度 | 状态 | 允许结论 | 禁止结论 |', '|------|------|----------|----------|']) + for tier in ('day', 'week', 'month', 'year'): + row = timing_contract.get('tiers', {}).get(tier, {}) if isinstance(timing_contract.get('tiers'), dict) else {} + lines.append( + f"| {tier} | {_md_cell(row.get('status', 'blocked'))} | {_md_cell(';'.join(row.get('allowed_claims', [])))} | {_md_cell(';'.join(row.get('prohibited_claims', [])))} |" + ) + + lines.extend(['', '## 事件回放校验', '']) + lines.append(f"- 状态:{_md_cell(event_replay.get('status', 'blocked'))}") + lines.append(f"- 原因/边界:{_md_cell(event_replay.get('reason') or event_replay.get('boundary') or 'not_recorded')}") + lines.append(f"- 校验轨道:{_md_cell('、'.join(event_replay.get('validation_lanes', [])))}") + required_fields = event_replay.get('required_event_fields') if isinstance(event_replay.get('required_event_fields'), list) else [] + if required_fields: + lines.append(f"- 待提供字段:{_md_cell('、'.join(required_fields))}") + event_rows = event_replay.get('events') if isinstance(event_replay.get('events'), list) else [] + if event_rows: + lines.extend(['', '| 年份/日期 | 领域 | 事件 | 结果/观察 |', '|-----------|------|------|-----------|']) + for item in event_rows: + if isinstance(item, dict): + lines.append(f"| {_md_cell(item.get('year_or_date'))} | {_md_cell(item.get('domain'))} | {_md_cell(item.get('event_description'))} | {_md_cell(item.get('outcome_or_observation'))} |") + blind_policy = event_replay.get('blind_holdout_policy') if isinstance(event_replay.get('blind_holdout_policy'), dict) else {} + if blind_policy: + lines.extend(['', '#### 盲回放与防调参边界', '']) + lines.append(f"- 状态:{_md_cell(blind_policy.get('status'))}") + lines.append(f"- 标签先冻结:{_md_cell(blind_policy.get('freeze_user_labels_before_comparison'))}") + lines.append(f"- 禁止同一事件反向调出生时间或参数:{_md_cell(blind_policy.get('forbid_birth_time_or_parameter_tuning_from_same_events'))}") + lines.append(f"- 边界:{_md_cell(blind_policy.get('boundary'))}") + if event_replay.get('family_d12_binding'): + lines.append(f"- D12/父母家庭绑定:{_md_cell(event_replay.get('family_d12_binding'))}") + candidate_segment_table = event_replay.get('candidate_segment_table') if isinstance(event_replay.get('candidate_segment_table'), dict) else {} + if candidate_segment_table: + summary = candidate_segment_table.get('summary') if isinstance(candidate_segment_table.get('summary'), dict) else {} + primary = summary.get('primary_candidate_window') if isinstance(summary.get('primary_candidate_window'), dict) else {} + if primary: + lines.append(f"- 当前代表时间:{_md_cell(primary.get('representative_time'))}") + lines.append(f"- 当前候选带:{_md_cell(primary.get('start_time'))} - {_md_cell(primary.get('end_time'))}") + lines.append(f"- 校时批准状态:not_approved") + lines.append("- 详细分钟微比较见后文“校时附录”。") + + lines.extend(['', '## 生时敏感性矩阵(-)', '']) + sensitivity_window = birth_time_sensitivity.get('window') if isinstance(birth_time_sensitivity.get('window'), dict) else {} + lines.append(f"- 状态:{_md_cell(birth_time_sensitivity.get('status', 'blocked'))}") + lines.append(f"- 候选窗口:{_md_cell(sensitivity_window.get('start'))} / {_md_cell(sensitivity_window.get('center'))} / {_md_cell(sensitivity_window.get('end'))}") + approval_gate = birth_time_sensitivity.get('approval_gate') if isinstance(birth_time_sensitivity.get('approval_gate'), dict) else {} + if approval_gate: + lines.append(f"- Review 到 Approval 门槛:{_md_cell(approval_gate.get('status', 'not_provided'))}") + lines.append(f"- 可进入 pending approval:{_md_cell(approval_gate.get('can_enter_pending_approval'))}") + if approval_gate.get('blocked_reasons'): + lines.append(f"- 阻断原因:{_md_cell(';'.join(str(item) for item in approval_gate.get('blocked_reasons', [])))}") + lines.append(f"- 下一步:{_md_cell(approval_gate.get('required_next_step', 'not_recorded'))}") + lines.append(f"- D12 使用边界:{_md_cell(birth_time_sensitivity.get('d12_parent_family_policy', 'not_recorded'))}") + sensitivity_segment_table = birth_time_sensitivity.get('candidate_segment_table') if isinstance(birth_time_sensitivity.get('candidate_segment_table'), dict) else {} + if sensitivity_segment_table: + lines.append(f"- 候选段表:{_md_cell(sensitivity_segment_table.get('scope', 'candidate_window_only'))}") + sensitivity_micro_compare = birth_time_sensitivity.get('candidate_micro_compare') if isinstance(birth_time_sensitivity.get('candidate_micro_compare'), dict) else {} + if sensitivity_micro_compare: + lines.append(f"- 比较包:{_md_cell(sensitivity_micro_compare.get('status', 'blocked'))}") + sensitivity_profile = birth_time_sensitivity.get('profile') if isinstance(birth_time_sensitivity.get('profile'), dict) else {} + stable_layers = sensitivity_profile.get('stable_evidence') if isinstance(sensitivity_profile.get('stable_evidence'), dict) else {} + sensitive_layers = sensitivity_profile.get('sensitive_evidence') if isinstance(sensitivity_profile.get('sensitive_evidence'), dict) else {} + lines.extend(['', '| 校时层 | 三分钟结果 | 类型 | 使用边界 |', '|--------|------------|------|----------|']) + sensitivity_keys = ('D1.ascendant', 'D3.ascendant', 'D4.ascendant', 'D6.ascendant', 'D7.ascendant', 'D9.ascendant', 'D10.ascendant', 'D11.ascendant', 'D12.ascendant', 'D16.ascendant', 'D24.ascendant', 'D30.ascendant', 'D40.ascendant', 'D45.ascendant', 'D60.ascendant', 'arudha.UL', 'arudha.A7', 'arudha.A10', 'KP.cusp_observation') + for key in sensitivity_keys: + if key in stable_layers: + value, kind = stable_layers.get(key), 'stable_across_window' + elif key in sensitive_layers: + value, kind = sensitive_layers.get(key), 'minute_sensitive' + else: + value, kind = 'not_returned', 'blocked' + boundary = '可作为候选比较的原始结构,不可单独批准出生分钟。' + if key == 'D12.ascendant': + boundary = '仅在有日期化父母/家庭事件时参与候选比较;不得单独选择分钟。' + elif key == 'KP.cusp_observation': + boundary = '只作局部计算观察;exact-cusp parity gate 未闭环。' + lines.append(f"| `{key}` | {_md_cell(_json_safe_report_snapshot(value))} | {kind} | {boundary} |") + lines.append(f"- 总边界:{_md_cell(birth_time_sensitivity.get('claim_boundary', 'not_recorded'))}") + if candidate_segment_table: + lines.extend(['', '### 校时附录:候选分钟计划', '']) + lines.append(f"- 状态:{_md_cell(candidate_segment_table.get('scope', 'candidate_window_only'))}") + lines.append(f"- 结论边界:{_md_cell(candidate_segment_table.get('claim_boundary', 'not_recorded'))}") + summary = candidate_segment_table.get('summary') if isinstance(candidate_segment_table.get('summary'), dict) else {} + primary = summary.get('primary_candidate_window') if isinstance(summary.get('primary_candidate_window'), dict) else {} + if primary: + lines.append( + f"- 主候选:{_md_cell(primary.get('start_time'))} / {_md_cell(primary.get('representative_time'))} / {_md_cell(primary.get('end_time'))}" + ) + recommended = candidate_segment_table.get('recommended_next_pass') if isinstance(candidate_segment_table.get('recommended_next_pass'), dict) else {} + if recommended.get('candidate_minutes'): + lines.append(f"- 微调分钟:{_md_cell(' / '.join(str(item) for item in recommended.get('candidate_minutes', [])))}") + if sensitivity_micro_compare: + lines.extend(['', '### 校时附录:分钟排行表', '']) + lines.append(f"- 状态:{_md_cell(sensitivity_micro_compare.get('status', 'blocked'))}") + lines.append(f"- 结论边界:{_md_cell(sensitivity_micro_compare.get('claim_boundary', 'not_recorded'))}") + if sensitivity_micro_compare.get('candidate_minutes'): + lines.append(f"- 候选分钟:{_md_cell(' / '.join(str(item) for item in sensitivity_micro_compare.get('candidate_minutes', [])))}") + if sensitivity_micro_compare.get('leader'): + leader = sensitivity_micro_compare.get('leader') if isinstance(sensitivity_micro_compare.get('leader'), dict) else {} + lines.append(f"- 当前领先:{_md_cell(leader.get('candidate_time'))} / {_md_cell(leader.get('top_score'))}") + ranking_rows = sensitivity_micro_compare.get('minute_results') if isinstance(sensitivity_micro_compare.get('minute_results'), list) else [] + if not ranking_rows and sensitivity_micro_compare.get('candidate_minutes'): + ranking_rows = [ + {'candidate_time': minute, 'confidence': 'pending_replay', 'top_score': None, 'margin_percent': None, 'note': 'Waiting for event replay'} + for minute in sensitivity_micro_compare.get('candidate_minutes', []) + ] + if ranking_rows: + lines.extend(['', '| 排名 | 分钟 | 状态 | 分数 | 边际 | 备注 |', '|------|------|------|------|------|------|']) + for index, row in enumerate(ranking_rows, start=1): + if not isinstance(row, dict): + continue + lines.append( + f"| {index} | {_md_cell(row.get('candidate_time'))} | {_md_cell(row.get('confidence', 'blocked'))} | " + f"{_md_cell(row.get('top_score', 'pending'))} | {_md_cell(row.get('margin_percent', 'pending'))} | " + f"{_md_cell((row.get('candidate_summary') or {}).get('claim_status') or row.get('note') or 'pending')} |" + ) + + lines.extend(['', '## 校时证据领域合同', '']) + lines.append(_md_cell(rectification_evidence_contract.get('claim_boundary', 'rectification_evidence_contract_missing'))) + global_rules = rectification_evidence_contract.get('global_rules') if isinstance(rectification_evidence_contract.get('global_rules'), dict) else {} + if global_rules: + lines.append( + f"- 全局门:至少 {_md_cell(global_rules.get('minimum_independent_support_layers'))} 个独立支持层;" + f"标签先冻结={_md_cell(global_rules.get('freeze_event_labels_before_candidate_comparison'))};" + f"D60 决策权={_md_cell(global_rules.get('d60_decision_authority'))};" + f"D40/D45={_md_cell(global_rules.get('d40_d45_authority'))}。" + ) + lines.extend(['', '| 事件领域 | 结构层 | 时间/事件层 | 可核验事实示例 | 最低交叉 | 专项边界 |', '|----------|--------|-------------|----------------|----------|----------|']) + for item in rectification_evidence_contract.get('domains', []) if isinstance(rectification_evidence_contract.get('domains'), list) else []: + if isinstance(item, dict): + lines.append( + f"| {_md_cell(item.get('label'))} | {_md_cell('、'.join(item.get('structural_layers', [])))} | " + f"{_md_cell('、'.join(item.get('timing_layers', [])))} | {_md_cell(';'.join(item.get('objective_event_examples', [])))} | " + f"{_md_cell(item.get('minimum_cross_support'))} | {_md_cell(item.get('special_policy', '遵循全局边界'))} |" + ) + + lines.extend(['', '## 时间节点边界归因', '']) + lines.append(_md_cell(timing_boundary_attribution.get('claim_boundary', 'timing_boundary_attribution_missing'))) + lines.extend(['', '| 主题 | 窗口 | 原始来源 | 精度层 | 当前状态 | 禁止结论 |', '|------|------|----------|--------|----------|----------|']) + for item in timing_boundary_attribution.get('nodes', []) if isinstance(timing_boundary_attribution.get('nodes'), list) else []: + if isinstance(item, dict): + lines.append( + f"| {_md_cell(item.get('theme'))} | {_md_cell(item.get('window'))} | " + f"{_md_cell(';'.join(item.get('raw_sources', [])))} | {_md_cell(item.get('precision_tier'))} | " + f"{_md_cell(item.get('tier_status'))} | {_md_cell(';'.join(item.get('prohibited_claims', [])))} |" + ) + lines.extend(['', '| Parity lane | 状态 | 来源路径 | 仍未闭环原因 |', '|-------------|------|----------|----------------|']) + for item in timing_boundary_attribution.get('parity_gates', []) if isinstance(timing_boundary_attribution.get('parity_gates'), list) else []: + if isinstance(item, dict): + source_path = str(item.get('source_path') or '') + source_label = { + 'raw_full_reading.modules.dasha': 'full-reading Dasha module', + 'raw_full_reading.modules.narayana_dasha': 'full-reading Narayana module', + 'worksheets.timing_and_predictive_systems.annual_tajika_series': 'annual Tajika series', + 'references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json': 'KP exact cusp oracle status', + }.get(source_path, source_path.split('.')[-1] if source_path else '-') + lines.append(f"| {_md_cell(item.get('lane'))} | {_md_cell(item.get('status'))} | {_md_cell(source_label)} | {_md_cell(item.get('reason'))} |") + + lines.extend(['', '## 完整 Technique Audit Table', '', '| 技法 | 状态 | 来源 | 审计说明 |', '|------|------|------|----------|']) + for row in technique_audit_table: + if isinstance(row, dict): + lines.append(f"| {_md_cell(row.get('technique'))} | {_md_cell(row.get('status'))} | {_md_cell(row.get('source'))} | {_md_cell(row.get('note'))} |") + + lines.extend(['', '## 逐模块执行与结论追踪审计', '', '| 模块 | 状态 | Producer | 报告入口 | 结论权限 | 限制/缺失输入 |', '|------|------|----------|----------|----------|----------------|']) + for row in module_execution_audit: + if isinstance(row, dict): + limitation = row.get('required_inputs_if_separate') or row.get('limitation') + lines.append( + f"| `{_md_cell(row.get('module_id'))}` | {_md_cell(row.get('status'))} | {_md_cell(row.get('producer'))} | " + f"{_md_cell(';'.join(row.get('report_sections', [])))} | {_md_cell(row.get('conclusion_use'))} | {_md_cell(limitation)} |" + ) + + lines.extend([ + '', + '## 计算口径', + '', + f"- 出生日期:{birth.get('date', 'unknown')} {birth.get('time', '')}".rstrip(), + f"- 地点:纬度 {birth.get('lat', 'unknown')} / 经度 {birth.get('lon', 'unknown')} / 时区 {birth.get('tz', 'unknown')}", + f"- 岁差:{birth.get('ayanamsa_display', birth.get('ayanamsa_name', 'unknown'))}", + f"- 交点模式:{birth.get('node_mode', 'unknown')}", + ]) + + # Keep every non-empty calculation module discoverable even when it has + # not yet earned a dedicated thematic section. + raw_modules = raw_full_reading.get('modules') if isinstance(raw_full_reading, dict) else {} + if isinstance(raw_modules, dict) and raw_modules: + lines.extend([ + '', + '## 原始模块索引与未下沉字段附录', + '', + '本附录逐项登记当前 packet 中的非空原始模块。独立章节已经展开的模块仍保留这里的路径;尚未下沉到正文或专门技术页的模块,明确标为“仅原始附录”,不代表缺失或已升级为主判断证据。完整原始数值保留在同一导出的 JSON packet 的 raw_full_reading.modules 下;Markdown 不重复嵌入 JSON,避免正文与原始负载双重膨胀。', + '', + '| 模块 | 状态 | 原始路径 | 报告使用/结论入口 | 未下沉字段 |', + '|------|------|----------|---------------------|------------|', + ]) + for module_id in sorted(raw_modules): + module = raw_modules.get(module_id) + if module in (None, '', [], {}): + continue + if isinstance(module, dict): + status = module.get('status') or module.get('execution_status') or 'available' + field_names = ', '.join(str(key) for key in sorted(module, key=str)) + elif isinstance(module, list): + status = 'available' + field_names = f'list[{len(module)}]' + else: + status = 'available' + field_names = type(module).__name__ + usage = raw_module_usage_map.get(str(module_id)) if isinstance(raw_module_usage_map.get(str(module_id)), dict) else {} + usage_sections = usage.get('report_sections') if isinstance(usage.get('report_sections'), list) else ['原始模块索引与未下沉字段附录'] + lines.append( + f"| `{_md_cell(module_id)}` | {_md_cell(status)} | " + f"`raw_full_reading.modules.{_md_cell(module_id)}` | " + f"{_md_cell(';'.join(usage_sections))} | {_md_cell(field_names)} |" + ) + lines.append('') + + lines.extend([ + '', + '## Report Layers', + '', + ]) + for key, value in report_layers.items(): + if isinstance(value, dict): + lines.append(f"- `{key}`: {value.get('status', 'unknown')}") + + lines.extend(['', '## Worksheet Index', '']) + for item in report_catalog.get('worksheets', []): + if not isinstance(item, dict): + continue + page_groups = item.get('page_groups', []) + page_titles = ' / '.join(group.get('title', '') for group in page_groups if isinstance(group, dict)) + lines.append( + f"- `{item.get('id')}` · {item.get('title')} · status={item.get('status')} · pages={page_titles}" + ) + + lines.extend(['', '## Report Pack Manifest', '']) + pack_manifest = packet.get('report_pack_manifest', {}) if isinstance(packet, dict) else {} + for pack in pack_manifest.get('packs', []): + if isinstance(pack, dict): + lines.append( + f"- `{pack.get('id')}` · {pack.get('title')} · worksheets={', '.join(pack.get('worksheet_ids', []))} · targets={', '.join(pack.get('target_sections', []))}" + ) + + lines.extend(['', '## Thematic Workbooks', '']) + for worksheet_id in report_index.get('worksheet_ids', []): + data = worksheets.get(worksheet_id, {}) + if not isinstance(data, dict): + continue + summary_card = data.get('summary_card', {}) + detail_blocks = data.get('detail_blocks', {}) + extra_fields = { + key: value + for key, value in data.items() + if key not in {'summary_card', 'detail_blocks', 'ai_evidence_bundle', 'full_report_pack'} + and not str(key).endswith('_pack') + } + lines.extend([f"### {worksheet_id}", '']) + if summary_card: + lines.append('- Summary Card') + for key, value in summary_card.items(): + if isinstance(value, dict): + lines.append(f" - {key}: {', '.join(f'{k}={v}' for k, v in value.items())}") + else: + lines.append(f" - {key}: {value}") + if detail_blocks: + lines.append('- Detail Blocks') + for key, value in detail_blocks.items(): + if isinstance(value, dict): + nested_parts = [] + for sub_key, sub_value in value.items(): + rendered = ', '.join(map(str, sub_value)) if isinstance(sub_value, list) else sub_value + nested_parts.append(f"{sub_key}={rendered}") + lines.append(f" - {key}: {'; '.join(nested_parts)}") + else: + lines.append(f" - {key}: {value}") + if extra_fields: + lines.append('- Full Payload Fields') + for key in sorted(extra_fields, key=str): + lines.append(f" - {key}: {_render_compact_payload(extra_fields[key])}") + lines.append('') + + lines.extend([ + '## Blocked / Audit Appendix', + '', + f"- output_level: {blocked_audit.get('output_level', 'unknown')}", + f"- segment_ids: {', '.join(blocked_audit.get('segment_ids', []))}", + f"- blocked_policy: {blocked_audit.get('blocked_policy', 'unknown')}", + f"- segment_details: {json.dumps(blocked_audit.get('segment_details', {}), ensure_ascii=False)}", + '', + '## Reference Parity', + '', + f"- 状态:{parity.get('status', 'unknown')}", + f"- 边界:{parity.get('boundary', 'unknown')}", + '- 参照引擎:', + ]) + for engine in parity.get('engines', []): + if isinstance(engine, dict): + lines.append( + f" - {engine.get('engine')}: role={engine.get('role')} | license={engine.get('license_boundary')} | status={engine.get('status')} | focus={', '.join(engine.get('focus', []))}" + ) + lines.append('- 本地闭环目标:') + for target in parity.get('local_targets', []): + lines.append(f" - {target}") + + ai_audit = worksheets.get('ai_and_audit', {}) if isinstance(worksheets.get('ai_and_audit'), dict) else {} + profile_aware_appendix = _profile_aware_benchmark_appendix() + if profile_aware_appendix: + lines.extend(['']) + lines.extend(profile_aware_appendix) + lines.extend([ + '', + '## Audit Appendix', + '', + f"- AI Prompt Pack: {_bool_text(bool(ai_audit.get('ai_prompt_pack')))}", + f"- Evidence Profiles: {_bool_text(bool(ai_audit.get('evidence_profiles')))}", + f"- Timing Precision Contract: {_bool_text(bool(ai_audit.get('timing_precision_contract')))}", + ]) + + lines.extend(['', render_pl9_continuation_prompt()]) + + return '\n'.join(lines).strip() + '\n' + + +def write_pl9_pdf(packet: dict, output_path: str | None = None) -> dict: + """将 PL9 风格 Markdown 报告渲染为 PDF,并返回 manifest。""" + from pathlib import Path + from reportlab.lib.enums import TA_LEFT + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + import pdfplumber + from pypdf import PdfReader + + target = Path(output_path or os.path.join(ROOT_DIR, 'output', 'pdf', 'pl9_export_professional_report.pdf')) + target.parent.mkdir(parents=True, exist_ok=True) + markdown = render_pl9_markdown(packet) + + font_name = 'Helvetica' + cjk_candidates = [ + ('PL9ReportFont', '/System/Library/Fonts/Supplemental/Arial Unicode.ttf'), + ('PL9ReportFont', '/System/Library/Fonts/Hiragino Sans GB.ttc'), + ('PL9ReportFont', '/System/Library/Fonts/PingFang.ttc'), + ('PL9ReportFont', '/System/Library/Fonts/STHeiti Medium.ttc'), + ('PL9ReportFont', '/Library/Fonts/Arial Unicode.ttf'), + ] + for candidate_name, candidate_path in cjk_candidates: + if not os.path.exists(candidate_path): + continue + try: + pdfmetrics.registerFont(TTFont(candidate_name, candidate_path)) + font_name = candidate_name + break + except Exception: + continue + + styles = getSampleStyleSheet() + body = ParagraphStyle( + 'PL9Body', + parent=styles['BodyText'], + fontName=font_name, + fontSize=9.5, + leading=14, + alignment=TA_LEFT, + spaceAfter=6, + ) + h2 = ParagraphStyle('PL9H2', parent=styles['Heading2'], fontName=font_name, fontSize=15, leading=20, spaceAfter=8) + h3 = ParagraphStyle('PL9H3', parent=styles['Heading3'], fontName=font_name, fontSize=12, leading=16, spaceAfter=6) + title = ParagraphStyle('PL9Title', parent=styles['Title'], fontName=font_name, fontSize=19, leading=24, spaceAfter=12) + + story = [] + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if not line: + story.append(Spacer(1, 6)) + continue + escaped = ( + raw_line.replace('&', '&') + .replace('<', '<') + .replace('>', '>') + ) + if line.startswith('# '): + story.append(Paragraph(escaped[2:], title)) + elif line.startswith('## '): + story.append(Paragraph(escaped[3:], h2)) + elif line.startswith('### '): + story.append(Paragraph(escaped[4:], h3)) + else: + story.append(Paragraph(escaped.replace(' ', '  ').replace(' - ', ' - '), body)) + + doc = SimpleDocTemplate( + str(target), + pagesize=A4, + leftMargin=36, + rightMargin=36, + topMargin=36, + bottomMargin=36, + title='PL9 风格专业排盘导出', + author='jyotish_engine', + ) + doc.build(story) + + page_count = len(PdfReader(str(target)).pages) + with pdfplumber.open(str(target)) as pdf: + first_page_text = (pdf.pages[0].extract_text() or '') if pdf.pages else '' + return { + 'format': 'pdf', + 'path': str(target), + 'page_count': page_count, + 'bytes': target.stat().st_size if target.exists() else 0, + 'verification': { + 'pdf_created': target.exists(), + 'page_count_gt_zero': page_count > 0, + 'first_page_has_pl9_title': 'PL9' in first_page_text, + }, + } + + def output_table(command, data): """Human-readable ASCII table output for selected commands.""" if command == 'chart': @@ -767,6 +9852,18 @@ def _ayanamsa_display_name(name): return ayanamsa_display_name(name) +def build_startrack_calculation_snapshot(source, args=None): + return {"status": "blocked", "reason": "startrack_monthly_log_absent"} + + +def build_startrack_monthly_log_payload(*args, **kwargs): + return {"status": "blocked", "reason": "startrack_monthly_log_absent"} + + +def _build_response_envelope(technique, payload, args=None, execution_status="executed"): + # Commercial CLI/API consume the inner payload. Research envelope wrapping is not applied. + return payload + def _add_chart_args(p): """为需要出生数据的子命令添加公共参数""" p.add_argument('--year', type=int, required=True) @@ -1055,6 +10152,26 @@ def _build_technique_audit_table(functional_layer, oracle_progress, modules): f"reference_date={vedastro_meta.get('reference_date') if isinstance(vedastro_meta, dict) else None}." ), }) + rows.extend([ + { + 'technique': 'Exact Cusp Oracle', + 'status': 'blocked', + 'source': 'exact_cusp_oracle promotion gate', + 'note': '缺少可复现的精确宫头外部 oracle 与独立日级 holdout;不得生成日级确定事件或精确落日。', + }, + { + 'technique': 'Prashna', + 'status': 'blocked', + 'source': 'prashna runtime / question-time input', + 'note': '本命报告未提供独立问时、提问文本和问事地点;不能以本命或年度盘替代即时 yes/no 问断。', + }, + { + 'technique': 'Narayana / Patyayini / Tajika Parity', + 'status': 'parameter_sensitive', + 'source': 'modules.narayana_dasha + annual_tajika_pack', + 'note': '保留为交叉时间窗口与年度结构;外部 parity 未闭环时,不作为事件先后、强弱或精确应期的最终真值。', + }, + ]) return rows @@ -1140,6 +10257,7 @@ def _build_relationship_narrative_payload(relationship_strict): risks.append(f"阻力来源:{monthly_frame.get('friction_source', {}).get('value')}。") boundaries.append('婚恋高严谨模式至少需要 D1、D9、UL、Vimshottari 与 Narayana dual dasha 同时在场。') + boundaries.append(_build_domain_methodology_focus_summary('relationship')) boundaries.append('protective kuta support、Mahendra、Stree Deergha 等合盘细信号只能辅助,不得越权抬升 legal_marriage。') boundaries.append('若 dual dasha、external timing 或 marriage convergence 冲突,必须明确降置信度,而不是把关系窗口包装成婚姻必然落地。') if monthly_frame.get('time_confidence', {}).get('value'): @@ -1261,6 +10379,16 @@ def _base_strict_narrative_payload(route_label, strict, *, fallback_headline, st } +def _build_domain_methodology_focus_summary(theme): + mapping = { + 'career': '主干判断来自 D10 / 10H / 10L / Vimshottari / Narayana;增强来自 A10 / AMK / Karakamsha;裁决优先看 KP 10H 或同级裁决层;年运触发只负责把年度窗口压到更细的落点;仍 blocked 的 Tajika named yogas 不能越权改写事业主结论。', + 'relationship': '主干判断来自 D9 / 7H / 7L / Vimshottari / Narayana;增强来自 UL / DK / Jaimini 配偶线索;裁决优先看 KP 7H 或婚恋 strict label;年运触发只负责公开化或承诺窗口;仍 blocked 的层不能越权抬升 legal_marriage。', + 'finance': '主干判断来自 D2 / D11 / 2H / 11H / Vimshottari / Narayana;增强来自 Arudha 财富可见度与财富类 Yoga;裁决优先看 KP 2H/11H 与兑现摩擦层;年运触发只负责回款与现金流节奏;仍 blocked 的层不能越权写成全年确定增收。', + 'timing': '主干判断来自 Vimshottari / Narayana / Transit / Double Transit;增强来自 Jaimini timing 与 annual shell;裁决优先看 KP timing 与现实事件回放;年运触发只负责把月份窗口压成更细候选;仍 blocked 的 holdout 与 Tajika closure 会限制精确应期断言。', + } + return mapping.get(theme, '') + + def _build_career_narrative_payload(career_strict): if not isinstance(career_strict, dict) or not career_strict: return _base_strict_narrative_payload( @@ -1278,6 +10406,7 @@ def _build_career_narrative_payload(career_strict): strengths = [] risks = [] boundaries = [ + _build_domain_methodology_focus_summary('career'), '事业高严谨模式至少需要 D1、D10、A10、Functional Benefic/Malefic、Vimshottari 与 Narayana dual dasha 同时在场。', 'VedAstro 官方事件日可以给时间支撑,但不得越权改写本命 promise 与 strict workflow 的边界。', ] @@ -1313,18 +10442,21 @@ def _build_finance_narrative_payload(finance_strict): return _base_strict_narrative_payload( '财富', {}, - fallback_headline='财富严格裁决证据尚未完成,当前不能生成高严谨财富叙事。', + fallback_headline='财富主题证据尚未完成,当前不能生成高严谨财富叙事。', strengths=[], risks=['缺少 finance strict workflow 的核心证据,财富正文需降级。'], boundaries=['未完成 D2/D11、财富 promise、Vimshottari 与 Narayana 交叉前,不得把单一财富信号写成高置信度结论。'], ) event_judgement = finance_strict.get('event_judgement') if isinstance(finance_strict, dict) else {} + event_judgement = event_judgement if isinstance(event_judgement, dict) else {} secondary_context = event_judgement.get('secondary_context') if isinstance(event_judgement, dict) else [] secondary_context = secondary_context if isinstance(secondary_context, list) else [] + present = finance_strict.get('present_evidence') if isinstance(finance_strict.get('present_evidence'), dict) else {} missing = finance_strict.get('missing_evidence') or [] strengths = [] risks = [] boundaries = [ + _build_domain_methodology_focus_summary('finance'), '财富高严谨模式至少需要 D2/D11 或等价财富 promise 层、Functional Benefic/Malefic、Vimshottari 与 Narayana dual dasha 同时在场。', '官方财富日窗口只能帮助判断回款/交易/现金流节奏,不能单独替代本命财富 promise。', ] @@ -1332,6 +10464,39 @@ def _build_finance_narrative_payload(finance_strict): strengths.append('财富 strict workflow 已判到 income_growth,说明更偏向真实入账增长,而不是空泛的财运变好。') if event_judgement.get('dominant_label') == 'public_wealth_status': strengths.append('财富 strict workflow 已判到 public_wealth_status,说明更像项目回款、公开收入状态或外部可见的收益变化。') + promise = present.get('wealth_promise_strength') if isinstance(present.get('wealth_promise_strength'), dict) else {} + if promise: + sources = promise.get('supporting_sources') if isinstance(promise.get('supporting_sources'), list) else [] + strengths.append( + f"财富 promise 已由 {promise.get('primary_source') or 'wealth_promise'} 折叠,强度为 {promise.get('level') or 'unknown'},来源包含 {', '.join(str(item) for item in sources) or '未展开'}。" + ) + varga_hooks = [] + if present.get('d2_hora'): + varga_hooks.append('D2 看正财与资源承接') + if present.get('d11_rudramsa'): + varga_hooks.append('D11 看收益兑现与愿望达成') + if present.get('d4_turyamsa'): + varga_hooks.append('D4 看资产/不动产承接') + if present.get('d16_shodasamsa'): + varga_hooks.append('D16 看享受、车辆与舒适度') + if present.get('d24_siddhamsa'): + varga_hooks.append('D24 看技能如何变现') + if varga_hooks: + strengths.append('财富分盘已展开:' + ';'.join(varga_hooks) + '。') + special_hooks = [] + if present.get('hora_lagna'): + special_hooks.append('Hora Lagna 分现金流入口') + if present.get('indu_lagna'): + special_hooks.append('Indu Lagna 分财富承接与福分') + if present.get('sree_lagna'): + special_hooks.append('Sree Lagna 分资源、人缘与贵气') + if special_hooks: + strengths.append('特殊财富点已进入解释层:' + ';'.join(special_hooks) + '。') + saham_hooks = [label for label, key in [('Dhan Saham', 'dhan_saham'), ('Artha Saham', 'artha_saham'), ('Labha Saham', 'labha_saham')] if present.get(key)] + if saham_hooks: + strengths.append('财富类 Saham 已进入年度/应期补充层:' + '、'.join(saham_hooks) + '。') + if present.get('bhava_bala'): + strengths.append('Bhava Bala 已进入财富叙事,用来区分“能赚钱”“能留钱”“收益是否顺滑”三件事。') if 'ashtakavarga_wealth_support' in secondary_context: strengths.append('Ashtakavarga 财富桥接已进入主链,可作为兑现能力的次级支持。') if missing: @@ -1342,7 +10507,7 @@ def _build_finance_narrative_payload(finance_strict): risks.append('财富桥接层已提示兑现摩擦,现金流并不等于可自由留存。') if 'shadbala_component_gap' in secondary_context: risks.append('Shadbala 六分量仍有缺口,财富强弱结论需继续保守。') - headline = '财富严格裁决已接入主链,当前结论会强制区分收入兑现、现金流动作与风险摩擦。' + headline = '财富主题已接入主链,当前结论会强制区分收入兑现、现金流动作与风险摩擦。' return _base_strict_narrative_payload( '财富', finance_strict, @@ -1353,6 +10518,80 @@ def _build_finance_narrative_payload(finance_strict): ) +def _build_timing_narrative_payload(modules): + modules = modules if isinstance(modules, dict) else {} + dasha = modules.get('dasha') if isinstance(modules.get('dasha'), dict) else {} + narayana = modules.get('narayana_dasha') if isinstance(modules.get('narayana_dasha'), dict) else {} + transit = modules.get('transit_multi_reference') if isinstance(modules.get('transit_multi_reference'), dict) else {} + vedastro = modules.get('vedastro_range_scan_result') if isinstance(modules.get('vedastro_range_scan_result'), dict) else {} + source_meta = vedastro.get('source_metadata') if isinstance(vedastro.get('source_metadata'), dict) else {} + top_daily = vedastro.get('top_daily_window') if isinstance(vedastro.get('top_daily_window'), dict) else {} + + current_dasha = dasha.get('current_dasha') if isinstance(dasha.get('current_dasha'), dict) else {} + current_narayana = narayana.get('current_dasha') if isinstance(narayana.get('current_dasha'), dict) else {} + monthly_frame = { + 'primary_state': { + 'value': top_daily.get('top_signal_label') or current_dasha.get('mahadasha') or 'blocked', + }, + 'manifestation_mode': { + 'value': top_daily.get('domain') or 'dasha_transit_cross_check', + }, + 'friction_source': { + 'value': 'timing_holdout_or_external_conflict' + if vedastro.get('status') not in {'ok', 'partial'} + else 'day_level_truth_not_closed', + }, + 'time_confidence': { + 'value': 'month_level_candidate_window', + }, + } + + strengths = [] + risks = [] + boundaries = [ + _build_domain_methodology_focus_summary('timing'), + '应期高严谨模式至少需要 Vimshottari、Narayana、Transit/Double Transit 与现实事件回放交叉,不能只看单一大运。', + '日级与月级 timing 仍受 holdout、外部校准与 Tajika closure 约束,不能把候选窗口写成必然发生。', + ] + + if current_dasha.get('mahadasha'): + strengths.append(f"主大运当前为 {current_dasha.get('mahadasha')},应期主干会优先围绕这条 dasha 解释。") + if current_narayana.get('sign'): + strengths.append(f"Narayana 当前落在 {current_narayana.get('sign')},可作为第二时轴交叉核验。") + if top_daily.get('date'): + strengths.append(f"年运/外部窗口当前给出的首个候选日为 {top_daily.get('date')}。") + + if not top_daily: + risks.append('当前未返回稳定的年度/日级窗口,应期正文将以大运与行运主链为主。') + if vedastro.get('status') not in {'ok', 'partial'}: + risks.append('外部 timing 概览未稳定闭环,日级细化窗口仍需降级为 blocked 或 parameter_sensitive。') + if transit: + risks.append('当前 timing 仍需把 Transit/Double Transit 与现实回放并列看待,不能把单日信号直接翻译成事件真值。') + + headline = '应期严格正文已接入大运、第二时轴与年度窗口,但日级精确度仍受 holdout 与外部校准边界约束。' + payload = _base_strict_narrative_payload( + '应期', + { + 'monthly_adjudication_summary': monthly_frame, + 'event_judgement': {'confidence_cap': 'parameter_sensitive'}, + 'prediction_boundary_contract': { + 'confidence_boundary': { + 'mevg_status': 'partial', + 'real_case_calibration_status': 'blocked', + 'unverified_claim_policy': 'downgrade_or_block', + } + }, + }, + fallback_headline=headline, + strengths=strengths, + risks=risks, + boundaries=boundaries, + ) + payload['reference_date'] = source_meta.get('reference_date') + payload['top_daily_window'] = top_daily + return payload + + def _build_vedastro_overview_payload(modules): overview = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} if not isinstance(overview, dict): @@ -1589,6 +10828,60 @@ def _build_vedastro_official_full_snapshot_payload(modules): } +def _build_mevg_chinese_full_layer_receipt(topic, runtime_evidence=None): + try: + try: + from mevg_chinese_receipt import DEFAULT_MANIFEST, build_runtime_receipt + from mevg_chinese_search_runner import run_mevg_chinese_search + except ModuleNotFoundError: # pragma: no cover - package import compatibility + from scripts.mevg_chinese_receipt import DEFAULT_MANIFEST, build_runtime_receipt + from scripts.mevg_chinese_search_runner import run_mevg_chinese_search + manifest = json.loads(DEFAULT_MANIFEST.read_text(encoding='utf-8')) + if not isinstance(runtime_evidence, dict): + search_run = run_mevg_chinese_search( + topic=str(topic or 'general_consultation'), + manifest=manifest, + allow_network=os.environ.get('MEVG_CHINESE_AUTO_SEARCH') == '1', + ) + runtime_evidence = search_run.get('runtime_evidence') if isinstance(search_run, dict) else None + receipt = build_runtime_receipt( + manifest, + topic=str(topic or 'general_consultation'), + runtime_evidence=runtime_evidence if isinstance(runtime_evidence, dict) else None, + ) + if not runtime_evidence: + receipt['status'] = 'scaffold' + receipt['source'] = 'scripts/mevg_chinese_receipt.py' + return receipt + except Exception as exc: + return { + 'schema': 'jyotish.mevg_chinese_full_layer_receipt.v1', + 'status': 'blocked', + 'topic': str(topic or 'general_consultation'), + 'platform_receipts': [], + 'source_dispositions': [], + 'mismatch_findings': [], + 'promotion_allowed': False, + 'reason': str(exc), + 'boundary': ( + 'MEVG Chinese full-layer receipt could not be scaffolded; ' + 'external verification must remain blocked.' + ), + } + + +def _extract_mevg_chinese_runtime_evidence(report, modules): + for candidate in ( + report.get('mevg_chinese_runtime_evidence') if isinstance(report, dict) else None, + report.get('mevg_chinese_search_results') if isinstance(report, dict) else None, + modules.get('mevg_chinese_runtime_evidence') if isinstance(modules, dict) else None, + modules.get('mevg_chinese_search_results') if isinstance(modules, dict) else None, + ): + if isinstance(candidate, dict): + return candidate + return None + + def _build_ai_prompt_pack(report): """Build a compact, evidence-first prompt pack for downstream AI/RAG reading.""" modules = report.get('modules', {}) if isinstance(report, dict) else {} @@ -1610,6 +10903,7 @@ def _build_ai_prompt_pack(report): relationship_narrative = _build_relationship_narrative_payload(modules.get('relationship_strict_evidence')) career_narrative = _build_career_narrative_payload(modules.get('career_strict_evidence')) finance_narrative = _build_finance_narrative_payload(modules.get('finance_strict_evidence')) + timing_narrative = _build_timing_narrative_payload(modules) vimsopaka_semantic_summary = _build_vimsopaka_semantic_summary(modules.get('vimsopaka')) vedastro_overview = _build_vedastro_overview_payload(modules) vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload(modules) @@ -1681,7 +10975,16 @@ def _build_ai_prompt_pack(report): else {} ) guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else build_guided_topics(report) + report_theme_catalog = ( + modules.get('report_theme_catalog') + if isinstance(modules.get('report_theme_catalog'), list) + else build_report_theme_catalog(report) + ) capability_evidence_pool = build_capability_evidence_pool_summary() + mevg_chinese_full_layer_receipt = _build_mevg_chinese_full_layer_receipt( + strict_workflow_primary_route or 'general_consultation', + runtime_evidence=_extract_mevg_chinese_runtime_evidence(report, modules), + ) shadbala_ranking = [] for planet_name, pdata in sorted( @@ -1701,8 +11004,8 @@ def _build_ai_prompt_pack(report): evidence_snapshot = { 'birth': report.get('birth_info', {}), 'ayanamsa': { - 'name': birth_info.get('ayanamsa_name', DEFAULT_AYANAMSA_NAME), - 'display': birth_info.get('ayanamsa_display', 'Lahiri'), +'name': birth_info.get('ayanamsa_name', DEFAULT_AYANAMSA_NAME), + 'display': birth_info.get('ayanamsa_display', 'Raman'), 'value': birth_info.get('ayanamsa'), 'node_mode': birth_info.get('node_mode'), }, @@ -1751,6 +11054,7 @@ def _build_ai_prompt_pack(report): 'external_oracle_status': 'D1/D9/VedAstro longitude boundary covered; Dasha/Shadbala external absolute calibration still requires multi-source oracle expansion.', }, 'oracle_progress': oracle_progress, + 'technique_audit_table': technique_audit_table, 'functional_benefic_malefic': functional_layer, 'interpretation_source_pack': { 'status': interpretation_source_audit.get('status') or fallback_source_pack.get('status') or 'blocked', @@ -1764,6 +11068,7 @@ def _build_ai_prompt_pack(report): 'domain_invocation_layers': primary_domain_invocation_layers or fallback_domain_layers or {}, 'output_template_contract': primary_output_template_contract or {}, 'mevg_collection_queue': primary_mevg_collection_queue or {}, + 'mevg_chinese_full_layer_receipt': mevg_chinese_full_layer_receipt, 'real_case_calibration_layer': primary_real_case_calibration_layer or {}, 'technical_debt_contract': primary_technical_debt_contract or {}, 'remaining_priority1_batch_queue': primary_remaining_priority1_batch_queue or {}, @@ -1780,11 +11085,13 @@ def _build_ai_prompt_pack(report): 'vedastro_official_full_snapshot': vedastro_official_full_snapshot, 'vedastro_overview': vedastro_overview, 'guided_topics': guided_topics, + 'report_theme_catalog': report_theme_catalog, 'capability_evidence_pool': capability_evidence_pool, 'technique_audit_table': technique_audit_table, 'career_narrative': career_narrative, 'relationship_narrative': relationship_narrative, 'finance_narrative': finance_narrative, + 'timing_narrative': timing_narrative, 'vimsopaka_semantic_summary': vimsopaka_semantic_summary, } @@ -1801,6 +11108,7 @@ def _build_ai_prompt_pack(report): "若 evidence_snapshot.capability_evidence_pool 存在,请把 89 项视为后台备选证据池;不要把所有能力条目平铺成结论,也不要让 audit_only/alias 条目影响占星判断。", "必须按 promise → activation → manifestation → label 输出;每个判断都要说明属于承诺、激活、落地形式还是标签层。", "未完成 MEVG / Real Case Calibration 时必须降级或标 blocked,不得把内部一致性写成已验证结论。", + "若 evidence_snapshot.mevg_chinese_full_layer_receipt 存在,必须把其中每个中文平台层的 searched/blocked/rejected 状态作为结构化收据保留;blocked_unavailable 不能写成已完成外部验证。", ] return { @@ -2107,7 +11415,7 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode=' result["ascendant"] = {"sign": asc_sign, "sign_cn": SIGNS_CN[asc_sign], "degree": round(deg_in_sign, 4), "degree_raw": round(asc_deg, 4), "degree_in_sign": round(deg_in_sign, 4), - "degree_in_sign_raw": asc_deg, + "degree_in_sign_raw": deg_in_sign, "lon": round(asc_deg, 4), "lord": SIGN_LORDS[asc_sign]} @@ -2120,9 +11428,10 @@ def compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode=' nak_span = 360.0 / 27 planets_swe = {**BASE_PLANETS_SWE, 'Rahu': node_pid} + calc_flags = getattr(swe, 'FLG_SWIEPH', 2) for pname, pid in planets_swe.items(): try: - pos, _ = swe.calc_ut(jd, pid) + pos, _ret_flags = swe.calc_ut(jd, pid, calc_flags | getattr(swe, 'FLG_SPEED', 256)) lon_p = (pos[0] - ayanamsa) % 360; lat_p = pos[1]; spd = pos[3] si = int(lon_p / 30); d_in_s = lon_p - si * 30; sign = SIGNS[si] retro = spd < 0 @@ -2181,6 +11490,34 @@ def cmd_chart(args): # ============================================================================ # 2. Dasha计算 # ============================================================================ +def _build_pratyantar_timeline(md_lord, ad_lord, ad_start, ad_end, reference_dt=None): + """Build all nine Pratyantar Dashas within one Antardasha.""" + total_seconds = (ad_end - ad_start).total_seconds() + start_index = DASHA_ORDER.index(ad_lord) + cursor = ad_start + timeline = [] + for index in range(len(DASHA_ORDER)): + lord = DASHA_ORDER[(start_index + index) % len(DASHA_ORDER)] + if index == len(DASHA_ORDER) - 1: + next_cursor = ad_end + else: + seconds = total_seconds * DASHA_YEARS[lord] / 120 + next_cursor = cursor + timedelta(seconds=seconds) + timeline.append({ + "lord": lord, + "lord_cn": PLANET_CN[lord], + "start": cursor.strftime("%Y-%m-%d"), + "end": next_cursor.strftime("%Y-%m-%d"), + "start_datetime": cursor.isoformat(timespec="seconds"), + "end_datetime": next_cursor.isoformat(timespec="seconds"), + "mahadasha_lord": md_lord, + "antardasha_lord": ad_lord, + "is_current": bool(reference_dt and cursor <= reference_dt < next_cursor), + }) + cursor = next_cursor + return timeline + + def cmd_dasha(args): nak_info = None; progress = 0.5 if args.moon_lon is not None: @@ -2227,7 +11564,7 @@ def cmd_dasha(args): "start_datetime": dt.isoformat(timespec="seconds"), "end_datetime": end_dt.isoformat(timespec="seconds"), "years": display_years, - "full_years": years, + "full_years": DASHA_YEARS[lord], "is_current": False, "is_balance": i == 0, "balance_years": round(remaining, 2) if i == 0 else None, @@ -2246,7 +11583,18 @@ def cmd_dasha(args): sl = DASHA_ORDER[(li + j) % 9]; sd = total_days * DASHA_YEARS[sl] / 120 se = sdt + timedelta(days=sd) is_cur = sdt <= today < se - sub.append({"lord": sl, "lord_cn": PLANET_CN[sl], "start": sdt.strftime("%Y-%m-%d"), "end": se.strftime("%Y-%m-%d"), "is_current": is_cur}) + sub.append({ + "lord": sl, + "lord_cn": PLANET_CN[sl], + "start": sdt.strftime("%Y-%m-%d"), + "end": se.strftime("%Y-%m-%d"), + "start_datetime": sdt.isoformat(timespec="seconds"), + "end_datetime": se.isoformat(timespec="seconds"), + "is_current": is_cur, + "pratyantar_dasha_timeline": _build_pratyantar_timeline( + d["lord"], sl, sdt, se, reference_dt=today, + ), + }) sdt = se d["antardasha_timeline"] = sub if ds <= today < de: @@ -2266,7 +11614,7 @@ def cmd_dasha(args): if birth_time: result["birth_time"] = birth_time result["birth_datetime"] = f"{birthdate} {birth_time}" - return result + return _build_response_envelope('dasha', result, args=args, execution_status='executed') # ============================================================================ @@ -2401,13 +11749,13 @@ def cmd_predict(args): "transit_signals": p.transit_signals, "timing_windows": p.timing_windows, }) - return { + return _build_response_envelope('predict', { "method": "三层验证法(EventPredictionModel规则引擎)", "engine": "event_prediction_model.py", "event_type": evt, "predictions": predictions, "note": "基于规则引擎的三层验证法,替代LAM神经网络(准确率从0.17%大幅提升)" - } + }, args=args, execution_status='executed') except Exception as e: # 降级到简化版 result = {"method": "三层验证法(简化版)", "fallback_reason": str(e), @@ -2428,7 +11776,7 @@ def cmd_predict(args): found.append({"planet": pn, "house": hn, "sign": pd.get("sign", ""), "status": pd.get("status", "中性")}) if found: result["predictions"].append({"event": ei["cn"], "key": ek, "static_indicators": found, "note": "需要结合Dasha和Transit进行精确预测"}) - return result + return _build_response_envelope('predict', result, args=args, execution_status='executed_with_warnings') # ============================================================================ @@ -2544,18 +11892,17 @@ def cmd_varga(args): except ImportError as e: return {"error": f"varga模块导入失败: {e}"} - swe.set_ephe_path('') - hd = _birth_hour_decimal(args.hour, args.minute, _arg_second(args)) - args.tz - jd = swe.julday(args.year, args.month, args.day, hd) - - # Lahiri Ayanamsa(恒星黄道修正,与cmd_chart一致) - ayanamsa = swe.get_ayanamsa(jd) - + chart, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args(args) + planets = chart.get('planets', {}) if isinstance(chart, dict) else {} natal = {} - for pn, pid in PLANETS_SWE.items(): - pos, _ = swe.calc_ut(jd, pid); natal[pn] = (pos[0] - ayanamsa) % 360 # 恒星黄道 - if 'Rahu' in natal: natal['Ketu'] = (natal['Rahu'] + 180) % 360 - asc_lon, _ = swe.houses(jd, args.lat, args.lon, b'A'); asc_deg = (asc_lon[0] - ayanamsa) % 360 # 恒星黄道 + for pn, pd in planets.items(): + if not isinstance(pd, dict) or pn == 'Ketu': + continue + natal[pn] = pd.get('degree_raw', pd.get('degree')) + if 'Rahu' in natal and 'Ketu' not in natal: + natal['Ketu'] = (natal['Rahu'] + 180) % 360 + ascendant = chart.get('ascendant', {}) if isinstance(chart, dict) else {} + asc_deg = ascendant.get('degree_raw', ascendant.get('lon', ascendant.get('degree', 0))) def short_varga_row(lon, div): row = calc_varga(lon, div) @@ -2571,7 +11918,7 @@ def cmd_varga(args): for p, l in natal.items(): d10[p] = short_varga_row(l, 10) result["divisional_charts"]["D10_Dasamsa"] = d10 if not result["divisional_charts"]: result["note"] = "请指定 --d9, --d10 或 --all" - return result + return _build_response_envelope('varga', result, args=args, execution_status='executed') # ============================================================================ @@ -2599,7 +11946,7 @@ def cmd_celebrity(args): if len(matches) >= 10: break result["person_list_matches"] = matches; result["person_list_total"] = 15807 except Exception as e: result["csv_error"] = str(e) - return result + return _build_response_envelope('celebrity', result, args=args, execution_status='executed') # ============================================================================ @@ -2618,12 +11965,26 @@ def cmd_db_stats(args): except Exception as e: result["error"] = str(e) else: result["error"] = f"数据库不存在: {DB_PATH}" - return result + return _build_response_envelope('db-stats', result, args=args, execution_status='executed') # ============================================================================ # 8. 过境查询 # ============================================================================ +def _transit_ayanamsa_mixed_review_policy(command_name, *, requested_ayanamsa=None, transit_ayanamsa=None): + return { + 'status': 'mixed_review', + 'command': command_name, + 'requested_ayanamsa': requested_ayanamsa, + 'transit_ayanamsa': round(transit_ayanamsa, 4) if isinstance(transit_ayanamsa, (int, float)) else transit_ayanamsa, + 'degree_claim_policy': 'sign_house_month_only_until_same_ayanamsa_transit_parity_closes', + 'runtime_note': ( + 'Transit longitude outputs may use legacy Swiss-Ephemeris ayanamsa semantics; ' + 'degree-level triggers must remain mixed until same-ayanamsa transit parity is audited.' + ), + } + + def cmd_transit(args): """ 实时 Transit 行星过境计算(v3.7.2 改用 Swiss Ephemeris) @@ -2712,12 +12073,38 @@ def cmd_transit(args): 'ayanamsa': round(ayanamsa, 4), 'node_mode': node_mode, 'data_layer': 'true_transit_positions', + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'transit', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=ayanamsa, + ), 'planets': transit_data, 'aspects': aspects_found, 'note': f'使用Swiss Ephemeris实时计算{t_year}年{t_month}月行星过境位置,不再依赖静态JSON' } + if getattr(args, 'retrograde_calendar', False): + from transit_search import build_retrograde_calendar + result['retrograde_calendar'] = build_retrograde_calendar( + result['target_date'], + getattr(args, 'months', 12), + getattr(args, 'tz', None) or 8, + getattr(args, 'ayanamsa', 'raman'), + ) + if getattr(args, 'find_conjunction', None): + from transit_search import find_exact_conjunctions + target = getattr(args, 'target_longitude', None) + if target is None: + return {'error': '--target-longitude is required with --find-conjunction'} + result['conjunctions'] = find_exact_conjunctions( + args.find_conjunction, + float(target), + result['target_date'], + getattr(args, 'tz', None) or 8, + getattr(args, 'ayanamsa', 'raman'), + getattr(args, 'months', 12) * 31, + ) - return result + return _build_response_envelope('transit', result, args=args, execution_status='executed') # ============================================================================ @@ -2961,6 +12348,11 @@ def cmd_double_transit_pac(args): # 5. PAC 检查 results = { 'transit_date': args.date, + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'double-transit-pac', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=transit_ayanamsa, + ), 'event_house': event_house, 'd1': {'jupiter': {}, 'saturn': {}}, 'd9': {'jupiter': {}, 'saturn': {}}, @@ -3104,7 +12496,7 @@ def cmd_double_transit_pac(args): 'chandra_lagna': SIGNS[moon_idx], } - return results + return _build_response_envelope('double-transit-pac', results, args=args, execution_status='executed') # ============================================================================ @@ -3156,6 +12548,11 @@ def cmd_transit_ll7l(args): result = { 'transit_date': args.date, + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'transit-ll7l', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=swe.get_ayanamsa_ut(transit_jd), + ), 'lagna_lord': ll_name, 'seventh_lord': seven_lord, 'p5': {'hit': False, 'details': []}, @@ -3203,7 +12600,7 @@ def cmd_transit_ll7l(args): elif t_7l_in_ll: result['parivartana']['details'].append(f'部分: Transit {seven_lord}在{n_ll_sign}') - return result + return _build_response_envelope('transit-ll7l', result, args=args, execution_status='executed') # ============================================================================ @@ -3222,6 +12619,12 @@ def cmd_planetary_congregation(args): result = { 'natal': {'lagna': [], 'house_7': [], f'house_{event_house}': []}, 'transit': None, + 'transit_date': args.transit_date, + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'planetary-congregation', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=None, + ), 'summary': '', } @@ -3243,6 +12646,11 @@ def cmd_planetary_congregation(args): t_year, t_month, t_day = map(int, args.transit_date.split('-')) transit_jd = swe.julday(t_year, t_month, t_day, 12.0 - args.tz) transit_aya = swe.get_ayanamsa(transit_jd) + result['transit_ayanamsa_policy'] = _transit_ayanamsa_mixed_review_policy( + 'planetary-congregation', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=transit_aya, + ) result['transit'] = {str(h): [] for h in range(1, 13)} for pname, pid in PLANETS_SWE.items(): try: @@ -3278,7 +12686,7 @@ def cmd_planetary_congregation(args): result['flags'] = flags result['hit'] = len(flags) > 0 result['summary'] = ' | '.join(flags) if flags else '无显著聚集' - return result + return _build_response_envelope('planetary-congregation', result, args=args, execution_status='executed') # ============================================================================ @@ -3312,6 +12720,12 @@ def cmd_vivah_saham(args): 'degree_in_sign': round(sahams_deg, 4), }, 'formula': f'norm({venus_lon:.2f} Venus - {saturn_lon:.2f} Saturn + {asc_deg:.2f} Asc)', + 'transit_date': args.transit_date, + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'vivah-saham', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=None, + ), 'transit_activation': None, } @@ -3319,6 +12733,11 @@ def cmd_vivah_saham(args): if args.transit_date: t_year, t_month, t_day = map(int, args.transit_date.split('-')) transit_jd = swe.julday(t_year, t_month, t_day, 12.0 - args.tz) + result['transit_ayanamsa_policy'] = _transit_ayanamsa_mixed_review_policy( + 'vivah-saham', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=swe.get_ayanamsa_ut(transit_jd), + ) result['transit_activation'] = {'jupiter': [], 'saturn': [], 'double_activation': False} @@ -3344,7 +12763,7 @@ def cmd_vivah_saham(args): if SIGNS[int(venus_t / 30)] == sahams_sign: result['transit_activation']['venus_in_saham_sign'] = True - return result + return _build_response_envelope('vivah-saham', result, args=args, execution_status='executed') # ============================================================================ @@ -3381,7 +12800,37 @@ def cmd_ashtakavarga(args): except ImportError as e: return {"error": f"ashtakavarga模块导入失败: {e}"} planets = chart.get("planets", {}) - return calc_ashtakavarga(planets, asc_idx) + result = calc_ashtakavarga(planets, asc_idx) + if isinstance(result, dict): + result["canonical_jyotish_profile"] = build_canonical_jyotish_profile() + result["canonical_method_policy"] = build_disputed_method_policy()["ashtakavarga"] + result["engine_views"] = { + "default": "jhora_pyjhora_field_profile", + "jhora_pyjhora_field_profile": { + "role": "default_canonical", + "source": "local SAV/BAV explicit-field runtime aligned to JHora/PyJHora field semantics", + "score_fields": result.get("score_fields", {}), + "comparison_rule": "compare SAV sign score, SAV house score, full-with-lagna, and external LifeMap bindu as separate fields", + }, + "local_legacy": { + "role": "parallel_engine_view", + "source": "legacy flat house_scores/full_sav fields retained for compatibility", + "house_scores": result.get("house_scores", []), + "full_sav": result.get("full_sav", []), + }, + "vedastro": { + "role": "parallel_engine_view_when_artifact_supplied", + "source": "external_observation", + "field_mapping": { + "VedAstro_AshtakvargaLifeMap_bindu": "score_fields.house_scores[].external_lifemap_bindus", + "SAV_sign_score": "score_fields.sign_scores[].sav_sign_score", + "SAV_house_score": "score_fields.house_scores[].sav_house_score", + "full_with_lagna": "score_fields.house_scores[].full_with_lagna_house_score", + }, + "default_arbitration": "does_not_override_jhora_pyjhora_field_profile", + }, + } + return _build_response_envelope('ashtakavarga', attach_calculation_profile(result, args), args=args, execution_status='executed') # ============================================================================ @@ -3416,31 +12865,94 @@ def cmd_ashtakoot(args): m_chart_full = {"lagna": m_chart.get("ascendant", {}), "planets": m_chart.get("planets", {})} f_chart_full = {"lagna": f_chart.get("ascendant", {}), "planets": f_chart.get("planets", {})} - return calculate_ashtakoot(m_moon_lon, f_moon_lon, m_chart_full, f_chart_full) + return _build_response_envelope('ashtakoot', calculate_ashtakoot(m_moon_lon, f_moon_lon, m_chart_full, f_chart_full), args=args, execution_status='executed') # ============================================================================ # 10b. KP 系统(v6.9.10新增) # ============================================================================ def cmd_kp(args): - chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args) + requested_report_ayanamsa = _current_ayanamsa_name(args) + kp_args = SimpleNamespace(**vars(args)) + kp_args.ayanamsa = 'kp' + + chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(kp_args) if chart is None: return {"error": "swisseph未安装"} asc_sign = chart.get("ascendant", {}).get("sign", "Aries") planets = chart.get("planets", {}) + planet_lons = { + pname: pdata.get('degree_raw', pdata.get('degree')) + for pname, pdata in planets.items() + if isinstance(pdata, dict) and pdata.get('degree_raw', pdata.get('degree')) is not None + } + + # KP uses its own Placidus cusp layer. Keep it separate from the report's + # default house profile so the two systems cannot be silently conflated. + from bhava_chalit import BhavaChalitCalculator + kp_house_calculator = BhavaChalitCalculator() + asc_lon = chart.get('ascendant', {}).get('degree_raw', chart.get('ascendant', {}).get('degree', 0.0)) + boundaries = kp_house_calculator.calculate_bhava_boundaries( + float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon, + ) + bhava_chart = kp_house_calculator.get_bhava_chalit_chart( + planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon, + ) + bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {} # 构建KP需要的行星位置格式 planet_positions = {} for pname, pdata in planets.items(): if isinstance(pdata, dict) and 'sign' in pdata: + bhava_row = bhava_planets.get(pname) if isinstance(bhava_planets.get(pname), dict) else {} planet_positions[pname] = { 'sign': pdata['sign'], 'degree': pdata.get('degree_in_sign', pdata.get('degree', 0) % 30), - 'house': pdata.get('house', 1), + 'house': bhava_row.get('bhava_house', pdata.get('house', 1)), } - return calc_kp_analysis(planet_positions, asc_sign) + result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps')) + moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0 + try: + birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0)) + weekday_lord = ['Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Sun'][birth_moment.weekday()] + except Exception: + weekday_lord = '' + asc_kp = get_kp_lords(float(asc_lon)) + moon_kp = get_kp_lords(float(moon_lon)) + result['ruling_planets'] = { + 'day_lord': weekday_lord, + 'moon_sign_lord': moon_kp.get('rasi_lord'), + 'moon_star_lord': moon_kp.get('nakshatra_lord'), + 'moon_sub_lord': moon_kp.get('sub_lord'), + 'ascendant_sign_lord': asc_kp.get('rasi_lord'), + 'ascendant_star_lord': asc_kp.get('nakshatra_lord'), + 'ascendant_sub_lord': asc_kp.get('sub_lord'), + 'status': 'parameter_sensitive', + 'timing_truth_status': 'blocked', + 'source_boundary': ( + 'Birth-moment KP ruling planets are displayable evidence only. ' + 'External worked examples and timing parity remain unclosed.' + ), + } + result['kp_profile'] = { + 'schema': 'jyotish.kp_profile.v1', + 'ayanamsa': 'kp', + 'ayanamsa_display': _ayanamsa_display_name('kp'), + 'requested_report_ayanamsa': requested_report_ayanamsa, + 'house_system': 'placidus', + 'cusp_source': 'swiss_ephemeris_bhava_chalit', + 'status': 'parameter_sensitive', + 'external_same_input_parity': 'pending', + 'truth_upgrade_allowed': False, + 'timing_policy': 'candidate_window_only', + 'boundary': ( + 'Local KP uses an independent Krishnamurti/KP ayanamsa profile with Swiss Ephemeris Placidus cusps. ' + 'The main report calculation profile is not overwritten. External same-input KP replay and timing parity remain unclosed.' + ), + } + return _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed') # ============================================================================ @@ -3482,7 +12994,7 @@ def cmd_memory(args): result["db_path"] = db_file else: result["error"] = f"未知action: {args.action},支持: store/search/context/stats" - return result + return _build_response_envelope('memory', result, args=args, execution_status='executed') # ============================================================================ @@ -3500,7 +13012,7 @@ def cmd_validate(args): asht_result = None try: from validate import validate_chart - return validate_chart(chart, asht_result) + return _build_response_envelope('validate', validate_chart(chart, asht_result), args=args, execution_status='executed') except ImportError as e: return {"error": f"validate模块导入失败: {e}"} @@ -3833,7 +13345,7 @@ def cmd_audit(args): # 冲突仲裁(CNWU16框架3条规则) report['conflict_arbitration'] = _conflict_arbitration(report) - return report + return _build_response_envelope('full-reading', report, args=args, execution_status='executed' if not report.get('errors') else 'executed_with_warnings') # ============================================================================ @@ -3907,7 +13419,7 @@ def cmd_varga_full(args): result['Ascendant'] = calc.calc_custom_varga(asc_deg, custom_n) for pn, lon in planet_lons.items(): result[pn] = calc.calc_custom_varga(lon, custom_n) - return result + return _build_response_envelope('varga-full', result, args=args, execution_status='executed') # --- Composite D-m×n mode (v6.9.12) --- composite = getattr(args, 'composite', None) @@ -3929,7 +13441,7 @@ def cmd_varga_full(args): result['Ascendant'] = calc.calc_composite_varga(asc_deg, outer, inner) for pn, lon in planet_lons.items(): result[pn] = calc.calc_composite_varga(lon, outer, inner) - return result + return _build_response_envelope('varga-full', result, args=args, execution_status='executed') # --- Standard / variant mode --- variant = getattr(args, 'variant', None) @@ -3956,7 +13468,7 @@ def cmd_varga_full(args): result['Ascendant'] = calc.calc_varga_with_variant(asc_deg, divisions[0], variant) for pn, lon in planet_lons.items(): result[pn] = calc.calc_varga_with_variant(lon, divisions[0], variant) - return result + return _build_response_envelope('varga-full', result, args=args, execution_status='executed') try: sys.path.insert(0, SCRIPT_DIR) @@ -3969,13 +13481,13 @@ def cmd_varga_full(args): for varga in selected: key = f'D{varga.division}_{varga.varga_name}' result[key] = calc._calculate_single_varga(varga, planet_lons, asc_deg) - return result + return _build_response_envelope('varga-full', result, args=args, execution_status='executed') except KeyError as e: return {"error": f"不支持的D{e.args[0]}。请使用 --custom N 计算任意D-N分盘。"} except ImportError: pass - return calc_all_vargas(planet_lons, asc_deg, divisions) + return _build_response_envelope('varga-full', calc_all_vargas(planet_lons, asc_deg, divisions), args=args, execution_status='executed') # ============================================================================ @@ -3996,7 +13508,7 @@ def cmd_aspects(args): if isinstance(pd, dict) and 'degree' in pd: planet_lons[pn] = pd['degree'] asc_deg = chart.get('ascendant', {}).get('lon', chart.get('ascendant', {}).get('degree', 0)) - return calc_all_aspects(planet_lons, asc_deg) + return _build_response_envelope('aspects', calc_all_aspects(planet_lons, asc_deg), args=args, execution_status='executed') # ============================================================================ @@ -4031,10 +13543,36 @@ def cmd_jaimini(args): result['chara_karaka_8'] = calc_chara_karaka_8(planet_degs) if mode in ('all', 'dasha'): use_antardasha = getattr(args, 'antardasha', False) - if use_antardasha: - result['chara_dasha'] = calc_chara_dasha_with_antardasha(asc_idx, planet_lons, args.year, args.month) + chara_profile = getattr(args, 'chara_profile', 'kn_rao') + chara_gender = getattr(args, 'chara_gender', None) + if chara_profile == "pyjhora_blackbox": + result['chara_dasha'] = pyjhora_raasi_dasha_target( + family="chara", + year=args.year, + month=args.month, + day=args.day, + hour=args.hour, + minute=args.minute, + second=_arg_second(args), + lat=args.lat, + lon=args.lon, + tz=args.tz, + ayanamsa=_current_ayanamsa_name(args), + reference_year=getattr(args, 'reference_year', None) or 2026, + reference_month=getattr(args, 'reference_month', None) or 8, + reference_day=getattr(args, 'reference_day', None) or 28, + ) else: - result['chara_dasha'] = calc_chara_dasha(asc_idx, planet_lons, args.year, args.month) + if use_antardasha: + result['chara_dasha'] = calc_chara_dasha_with_antardasha( + asc_idx, planet_lons, args.year, args.month, + profile=chara_profile, gender=chara_gender, + ) + else: + result['chara_dasha'] = calc_chara_dasha( + asc_idx, planet_lons, args.year, args.month, + profile=chara_profile, gender=chara_gender, + ) if mode in ('all', 'karakamsha'): # AK(灵魂星)的D9位置 — Karakamsha定义是AK在Navamsa中的星座 # ⚠️ 2026-05-03修正:此前错误使用DK,现已修正为AK @@ -4048,7 +13586,7 @@ def cmd_jaimini(args): result['graha_padas'] = calc_graha_padas(planet_lons) if mode in ('all', 'special'): result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0) - return result + return _build_response_envelope('jaimini', result, args=args, execution_status='executed') # ============================================================================ @@ -4058,22 +13596,22 @@ def cmd_narayana_dasha(args): chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args) if chart is not None: chart['ascendant_index'] = asc_idx - return _cmd_narayana_dasha_impl(args, chart) + return _build_response_envelope('narayana-dasha', _cmd_narayana_dasha_impl(args, chart), args=args, execution_status='executed') def cmd_nakshatra_adv(args): from cmd_nakshatra_adv import cmd_nakshatra_adv as _impl - return _impl(args) + return _build_response_envelope('nakshatra-adv', _impl(args), args=args, execution_status='executed') def cmd_nakshatra_dasha(args): from cmd_nakshatra_adv import cmd_nakshatra_dasha as _impl - return _impl(args) + return _build_response_envelope('nakshatra-dasha', _impl(args), args=args, execution_status='executed') def cmd_nakshatra_full(args): from cmd_nakshatra_adv import cmd_nakshatra_full as _impl - return _impl(args) + return _build_response_envelope('nakshatra-full', _impl(args), args=args, execution_status='executed') # ============================================================================ @@ -4095,7 +13633,7 @@ def cmd_argala(args): if isinstance(pd, dict) and 'sign' in pd: si = SIGNS.index(pd['sign']) if pd['sign'] in SIGNS else 0 planet_sign_indices[pn] = si - return calc_argala(planet_sign_indices, asc_idx) + return _build_response_envelope('argala', calc_argala(planet_sign_indices, asc_idx), args=args, execution_status='executed') # ============================================================================ @@ -4121,6 +13659,40 @@ def cmd_tajika(args): if age is None: return {"error": "请提供 --age 参数(当前年龄)"} + def _resolve_tajika_year_lord() -> Dict: + fallback = calc_year_lord(asc_si, age) + try: + second = _arg_second(args) + target_year = int(args.year) + int(age) + with contextlib.redirect_stdout(io.StringIO()): + sr = calc_solar_return_chart( + int(args.year), int(args.month), int(args.day), + int(args.hour), int(args.minute), + float(args.lat), float(args.lon), float(args.tz), + target_year, + getattr(args, 'ayanamsa', 'raman'), + ) + if not isinstance(sr, dict) or sr.get('error'): + return fallback + + with contextlib.redirect_stdout(io.StringIO()): + year_lord = calc_panchadhikari_year_lord( + sr.get('planet_lons') or planet_lons, + annual_asc_sign_idx=sr.get('asc_sign_idx', asc_si), + natal_lagna_sign_idx=asc_si, + years_from_dob=int(age), + annual_jd=((sr.get('solar_return') or {}).get('jd_ut')), + annual_dt=((sr.get('solar_return') or {}).get('dt_local') or (sr.get('solar_return') or {}).get('dt_ut')), + lat=float(args.lat), + lon=float(args.lon), + tz=float(args.tz), + ) + if isinstance(year_lord, dict) and year_lord.get('status') == 'partial_verified': + return year_lord + except Exception: + pass + return fallback + result = {} mode = args.mode or 'all' if mode in ('all', 'muntha'): @@ -4156,7 +13728,7 @@ def cmd_synastry(args): if args.mars2 is not None: p2['mars_lon'] = args.mars2 if args.asc1 is not None: p1['asc_lon'] = args.asc1 if args.asc2 is not None: p2['asc_lon'] = args.asc2 - return calc_synastry(p1, p2) + return _build_response_envelope('synastry', calc_synastry(p1, p2), args=args, execution_status='executed') # ============================================================================ @@ -4301,6 +13873,11 @@ def _calc_transit_multi_reference(planets, asc_idx, asc_deg, planet_lons, transi 'target_date': transit_date, 'node_mode': node_mode, 'data_layer': data_layer, + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'transit_multi_reference', + requested_ayanamsa=None, + transit_ayanamsa=None, + ), 'transit_analysis': transit_analysis, 'divergences': divergences, 'divergence_count': len(divergences), @@ -4703,6 +14280,171 @@ def _calc_actionable_context(planets, asc_idx): 'actionable_hint': 'AI应基于此上下文生成Transit Actionable Output:每条Transit预测必须包含时间段+行动类型+置信度。详见SKILL.md Transit Actionable Output规范。', } +def _build_natal_foundation_modules(args, planet_lons, *, asc_lon=None, jd=None): + """Build birth-time Panchanga and the currently calibrated Upagraha payload.""" + birth_dt = _birth_datetime_from_args(args) + ayanamsa_name = _current_ayanamsa_name(args) + try: + from muhurta import calc_panchanga + from saham_daynight import determine_daytime + + daynight = determine_daytime(birth_dt, lat=args.lat, lon=args.lon, tz=args.tz) + hours_from_sunrise = (daynight["julian_day_ut"] - daynight["sunrise_jd_ut"]) * 24 + if hours_from_sunrise < 0: + hours_from_sunrise += 24 + # muhurta.py uses Sunday=0 while datetime.weekday() uses Monday=0. + weekday = (birth_dt.weekday() + 1) % 7 + panchanga = calc_panchanga( + float(planet_lons["Sun"]), + float(planet_lons["Moon"]), + weekday, + hour_from_sunrise=hours_from_sunrise, + ) + panchanga["calculation_profile"] = { + "ayanamsa": ayanamsa_name, + "position_mode": _normalize_position_mode(getattr(args, "position_mode", "legacy")), + "sunrise_jd_ut": daynight["sunrise_jd_ut"], + "sunset_jd_ut": daynight["sunset_jd_ut"], + } + panchanga_result = {"status": "computed", "raw": panchanga} + except Exception as exc: + panchanga_result = {"status": "blocked", "reason": f"birth panchanga producer failed: {exc}"} + + try: + from gulika import calculate_upagrahas + + upagrahas_result = calculate_upagrahas( + birth_dt, + lat=args.lat, + lon=args.lon, + tz=args.tz, + sun_longitude=float(planet_lons["Sun"]), + ayanamsa=ayanamsa_name, + ) + if ayanamsa_name != "lahiri": + upagrahas_result["parity_boundary"] = ( + "Upagraha/Gulika now follows the selected ayanamsa for internal consistency; " + "non-Lahiri external numeric parity remains pending." + ) + except Exception as exc: + upagrahas_result = {"status": "blocked", "reason": f"Upagraha producer failed: {exc}"} + + try: + moon_lon = float(planet_lons["Moon"]) % 360 + moon_sign_idx = int(moon_lon // 30) % 12 + moon_chart = { + "Ascendant": { + "sign": SIGNS[moon_sign_idx], + "longitude": round(moon_lon, 6), + "reference": "Moon", + }, + "planets": { + name: { + "longitude": round(float(longitude) % 360, 6), + "sign": SIGNS[int(float(longitude) % 360 // 30) % 12], + "house_from_moon": ((int(float(longitude) % 360 // 30) - moon_sign_idx) % 12) + 1, + } + for name, longitude in planet_lons.items() + }, + } + moon_chart_result = {"status": "computed", "raw": moon_chart} + except Exception as exc: + moon_chart_result = {"status": "blocked", "reason": f"Moon chart producer failed: {exc}"} + + if asc_lon is None: + sudarshana_result = { + "status": "blocked", + "reason": "full-reading did not supply the native ascendant", + } + else: + try: + sudarshana = calc_sudarshana_chakra(planet_lons, float(asc_lon)) + sudarshana_result = { + "status": "computed", + "raw": { + "method": sudarshana.get("method"), + "version": sudarshana.get("version"), + "reference_points": sudarshana.get("reference_points", {}), + "three_charts": sudarshana.get("three_charts", {}), + }, + "boundary": ( + "Only three reference-chart positions are retained. Composite scores, " + "life-area analysis, convergence, and producer interpretations are excluded." + ), + } + except Exception as exc: + sudarshana_result = {"status": "blocked", "reason": f"Sudarshana producer failed: {exc}"} + + functional_result = _functional_benefic_malefic_snapshot( + planet_lons, + {"sign": SIGNS[int(float(asc_lon) % 360 // 30) % 12]} if asc_lon is not None else {}, + ) + + if asc_lon is None or jd is None: + bhava_result = { + "status": "blocked", + "reason": "full-reading did not supply the native ascendant and Julian day", + } + else: + try: + from bhava_chalit import BhavaChalitCalculator + + _mode, flags, _ = _sidereal_calculation_profile(jd, getattr(args, "position_mode", "legacy")) + _, ascmc = swe.houses_ex(jd, args.lat, args.lon, b"R", flags | swe.FLG_SIDEREAL) + bhava = BhavaChalitCalculator().get_bhava_chalit_chart( + planet_lons, + float(asc_lon), + float(ascmc[1]), + "sripati", + jd, + args.lat, + args.lon, + ) + bhava["boundaries"] = BhavaChalitCalculator().calculate_bhava_boundaries( + float(asc_lon), float(ascmc[1]), "sripati", jd, args.lat, args.lon + ) + bhava["calculation_profile"] = { + "ayanamsa": ayanamsa_name, + "position_mode": _normalize_position_mode(getattr(args, "position_mode", "legacy")), + "house_system": "sripati", + } + bhava_result = {"status": "computed", "raw": bhava} + except Exception as exc: + bhava_result = {"status": "blocked", "reason": f"Bhava Chalit producer failed: {exc}"} + + return { + "panchanga": panchanga_result, + "upagrahas": upagrahas_result, + "bhava_chalit": bhava_result, + "moon_chart": moon_chart_result, + "sudarshana": sudarshana_result, + "functional_benefic_malefic": functional_result, + } + + +def _build_research_high_varga(planet_lons, asc_lon): + """Return the non-standard D150 raw chart without overstating its lineage.""" + from divisional_charts_extended import DivisionalChartsCalculator + + calculator = DivisionalChartsCalculator() + return { + "D150_NadiAmsa": { + "_meta": { + "division": 150, + "name": "Nadi Amsa", + "formula_class": "research_generic_dn", + "status": "computed_pending_validation", + "claim_boundary": "Generic D-N mapping; no independent D150 oracle closure is attached.", + }, + "Ascendant": calculator.calc_custom_varga(float(asc_lon), 150), + **{ + name: calculator.calc_custom_varga(float(longitude), 150) + for name, longitude in planet_lons.items() + }, + } + } + + def cmd_full_reading(args): """ 用户只需提供出生信息,引擎自动串起全链路分析: @@ -4787,7 +14529,9 @@ def cmd_full_reading(args): report['chart'] = chart report['modules']['chart'] = chart planets = chart.get('planets', {}) - asc_deg = chart.get('ascendant', {}).get('lon', chart.get('ascendant', {}).get('degree', 0)) + report['modules']['planetary_friendship'] = _build_planetary_friendship_snapshot(planets) + ascendant = chart.get('ascendant', {}) + asc_deg = ascendant.get('lon', ascendant.get('degree', 0)) asc_sign = chart.get('ascendant', {}).get('sign', 'Unknown') planet_lons = {pn: pd.get('degree_raw', pd['degree']) for pn, pd in planets.items() if isinstance(pd, dict) and 'degree' in pd} planet_degs = {pn: pd.get('degree_in_sign_raw', pd.get('degree_in_sign', pd['degree'] % 30)) for pn, pd in planets.items() if isinstance(pd, dict) and 'degree' in pd} @@ -4936,6 +14680,18 @@ def cmd_full_reading(args): } report['modules']['varga_full'] = varga_result + requested_planets = [ + planet.strip() + for planet in str(getattr(args, 'crosscheck_planets', '') or '').split(',') + if planet.strip() + ] + requested_planets = list(dict.fromkeys(requested_planets)) + if requested_planets: + from varga_crosscheck import crosscheck_vargas + report['modules']['varga_crosscheck'] = crosscheck_vargas( + varga_result, requested_planets, + ) + # v6.1.7: Re-run Yoga with D9/D60 context after varga-full is available. # The earlier Step 3 remains a D1-only fallback for backward compatibility. try: @@ -5270,14 +15026,37 @@ def cmd_full_reading(args): # Bhrigu Pada Dasha(通用近似版,精确公式因流派而异) from bhrigu_pada_dasha import bhrigu_pada_dasha_full_report moon_lon = planet_lons.get('Moon', 0) - birth_jd = report.get('metadata', {}).get('birth_jd', 0) or 0 + birth_jd = jd if moon_lon and birth_jd: + d9_data = report['modules'].get('varga_full', {}).get('D9_Navamsa', {}) + d9_asc = d9_data.get('Ascendant', {}) if isinstance(d9_data, dict) else {} + d9_asc_sign = d9_asc.get('sign') if isinstance(d9_asc, dict) else None + d9_7lord_sign = None + d9_planets = _varga_planet_lons(d9_data) + if d9_asc_sign in SIGNS: + d9_seventh_sign = SIGNS[(SIGNS.index(d9_asc_sign) + 6) % 12] + d9_seventh_lord = SIGN_LORDS[d9_seventh_sign] + d9_seventh_lord_data = d9_data.get(d9_seventh_lord, {}) if isinstance(d9_data, dict) else {} + if isinstance(d9_seventh_lord_data, dict) and d9_seventh_lord_data.get('sign') in SIGNS: + d9_7lord_sign = SIGNS.index(d9_seventh_lord_data['sign']) bpd_result = bhrigu_pada_dasha_full_report( moon_lon, birth_jd, - d9_7lord_sign=None # 将在 D9 数据可用时补充 + d9_7lord_sign=d9_7lord_sign, + d9_planets=d9_planets, + ) + bpd_result['status'] = 'parameter_sensitive' + bpd_result['claim_boundary'] = ( + 'Bhrigu Pada / BCP is a source-limited approximate progression family. ' + 'Its raw sequence is visible for comparison only and cannot establish event timing or marriage claims.' ) report['modules']['bhrigu_pada_dasha'] = bpd_result + else: + report['modules']['bhrigu_pada_dasha'] = { + 'status': 'blocked', + 'reason': 'birth_moon_longitude_or_julian_day_missing', + } except Exception as e: + report['modules']['bhrigu_pada_dasha'] = {'status': 'blocked', 'reason': f'bhrigu_pada_producer_failed:{e}'} report['errors'].append(f"bhrigu-pada-dasha: {e}") try: @@ -5358,6 +15137,80 @@ def cmd_full_reading(args): report['modules']['narayana_dasha'] = narayana_result except Exception as e: report['errors'].append(f"narayana-dasha: {e}") + + # These independent timing tools are retained as raw reference-day snapshots. + # They are intentionally not fed into conclusion generation or timing promotion. + reference_date = getattr(args, 'transit_date', None) or getattr(args, 'today', None) + if reference_date: + transit_args = SimpleNamespace(**vars(args)) + transit_args.date = reference_date + transit_args.transit_date = reference_date + transit_args.house = 7 + for module_name, producer in ( + ('double_transit_pac', cmd_double_transit_pac), + ('transit_ll7l', cmd_transit_ll7l), + ('planetary_congregation', cmd_planetary_congregation), + ): + try: + observation = producer(transit_args) + if not isinstance(observation, dict): + observation = {'raw_result': observation} + observation['status'] = 'parameter_sensitive' + observation['reference_date'] = reference_date + observation['claim_boundary'] = ( + 'Reference-date observation only. It is not an event prediction, ' + 'not a parity-closed timing oracle, and not a retrospective calibration result.' + ) + report['modules'][module_name] = observation + except Exception as e: + report['modules'][module_name] = { + 'status': 'blocked', + 'reference_date': reference_date, + 'reason': f'{module_name}_producer_failed:{e}', + } + try: + from unified_western_reading import build_unified_western_reading + + western_raw = build_unified_western_reading( + year=args.year, + month=args.month, + day=args.day, + hour=args.hour, + minute=args.minute, + second=_arg_second(args), + latitude=args.lat, + longitude=args.lon, + timezone=args.tz, + reference_date=reference_date, + duration_scan_end_date=reference_date, + lunar_return_months=3, + ) + report['modules']['unified_western_reading'] = { + 'status': 'parameter_sensitive', + 'reference_date': reference_date, + 'raw_result': western_raw, + 'claim_boundary': ( + 'Raw native Western calculations are preserved as an independent comparison layer. ' + 'They are not used to upgrade Indian-astrology conclusions or timing precision.' + ), + } + except Exception as e: + report['modules']['unified_western_reading'] = { + 'status': 'blocked', + 'reference_date': reference_date, + 'reason': f'unified_western_reading_producer_failed:{e}', + } + else: + for module_name in ( + 'double_transit_pac', + 'transit_ll7l', + 'planetary_congregation', + 'unified_western_reading', + ): + report['modules'][module_name] = { + 'status': 'blocked', + 'reason': 'reference_date_missing', + } _record_stage_timing( stage_timings, 'dasha_and_core_varga_stack', @@ -5370,8 +15223,13 @@ def cmd_full_reading(args): # ── Step 5: 精确相位 ── stage_started = time.perf_counter() try: - from aspects import calc_all_aspects + from aspects import calc_all_aspects, calc_house_aspects aspects_result = calc_all_aspects(planet_lons, asc_deg) + aspects_result['house_aspects'] = [ + calc_house_aspects(float(planet_lons[planet]), planet, asc_deg) + for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn') + if planet in planet_lons + ] report['modules']['aspects'] = aspects_result except Exception as e: report['errors'].append(f"aspects: {e}") @@ -5416,6 +15274,55 @@ def cmd_full_reading(args): jaimini_result['graha_padas'] = calc_graha_padas(planet_lons) jaimini_result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0) + # Restricted Yogi/Ava Yogi point producer: coordinates only, no verdict text. + try: + from yogi_sphuta import build_yogi_sphuta_local + jaimini_result['yogi_avayogi_local'] = build_yogi_sphuta_local(report.get('chart') or {}) + except Exception as yogi_local_exc: + jaimini_result['yogi_avayogi_local'] = { + 'schema': 'jyotish.yogi_sphuta.local.v1', + 'status': 'blocked', + 'reason': f'yogi_sphuta_local_unavailable:{yogi_local_exc.__class__.__name__}', + } + + # External raw replay: comparison support only, never a strict verdict input. + try: + from pyjhora_yogi_replay import replay_pyjhora_yogi + + yogi_profile = build_calculation_profile(_annual_payload_from_args(args)) + jaimini_result['yogi_avayogi_external_replay'] = replay_pyjhora_yogi( + yogi_profile, + year=int(args.year), + month=int(args.month), + day=int(args.day), + hour=int(args.hour), + minute=int(args.minute), + second=int(_arg_second(args)), + latitude=float(args.lat), + longitude=float(args.lon), + timezone=float(args.tz), + ) + except Exception as yogi_exc: + jaimini_result['yogi_avayogi_external_replay'] = { + 'schema_version': 'jyotish.pyjhora_yogi_replay.v1', + 'status': 'blocked', + 'reason': f'pyjhora_yogi_replay_unavailable:{yogi_exc.__class__.__name__}', + 'evidence_scope': 'pyjhora_behavior_only', + 'parity_status': 'not_multiengine_parity', + } + try: + from yogi_sphuta import compare_yogi_sphuta_to_replay + jaimini_result['yogi_avayogi_local_external_comparison'] = compare_yogi_sphuta_to_replay( + jaimini_result.get('yogi_avayogi_local') or {}, + jaimini_result.get('yogi_avayogi_external_replay') or {}, + ) + except Exception as yogi_cmp_exc: + jaimini_result['yogi_avayogi_local_external_comparison'] = { + 'schema': 'jyotish.yogi_sphuta.local_external_comparison.v1', + 'status': 'blocked', + 'reason': f'yogi_sphuta_comparison_unavailable:{yogi_cmp_exc.__class__.__name__}', + } + # Darakaraka 深度解读(v6.1.10) # Registry 已将 modules.jaimini.darakaraka 标为 covered;这里把独立 DK # reader 接入 full-reading,使婚姻主题报告可消费真实 DK 画像、D9 状态、 @@ -5556,6 +15463,26 @@ def cmd_full_reading(args): shadbala_result['strong_planets'] = strong_count shadbala_result['weak_planets'] = len(sb_planets) - strong_count shadbala_result['status'] = '优秀' if strong_count >= 5 else '良好' if strong_count >= 3 else '一般' + sun_longitude = float(planet_lons.get('Sun', 0.0)) + combustion_orbs = {'Mars': 17, 'Mercury': 12, 'Jupiter': 11, 'Venus': 10, 'Saturn': 15} + for planet_name, planet in planets.items(): + if not isinstance(planet, dict) or planet_name in {'Rahu', 'Ketu'}: + continue + longitude = planet.get('degree_raw') + if longitude is None: + continue + planet.update(get_kp_lords(float(longitude))) + separation = abs(((float(longitude) - sun_longitude + 180) % 360) - 180) + planet['combust'] = planet_name != 'Sun' and separation <= combustion_orbs.get(planet_name, 0) + planet['r_c'] = 'R' if planet.get('retrograde') else 'C' if planet['combust'] else '' + strength = sb_planets.get(planet_name) if isinstance(sb_planets.get(planet_name), dict) else {} + if strength: + planet['sb'] = round(float(strength.get('ishta_bala_pct', 0.0)) / 100.0, 2) + for planet_name in ('Rahu', 'Ketu'): + planet = planets.get(planet_name) + if isinstance(planet, dict): + planet.update(get_kp_lords(float(planet.get('degree_raw', 0.0)))) + planet['r_c'] = '' report['modules']['shadbala'] = shadbala_result except Exception as e: report['errors'].append(f"shadbala: {e}") @@ -5615,8 +15542,10 @@ def cmd_full_reading(args): # ── Step 11: Ashtakavarga八分法 ── asht_data = None try: - from ashtakavarga import calc_ashtakavarga + from ashtakavarga import calc_ashtakavarga, calc_prastara_av, calc_sodhita_av asht_data = calc_ashtakavarga(planets, asc_idx) + asht_data['sodhita'] = calc_sodhita_av(asht_data.get('bav', {}), planets, asc_idx) + asht_data['pav'] = calc_prastara_av(planets, asc_idx) report['modules']['ashtakavarga'] = asht_data except Exception as e: report['errors'].append(f"ashtakavarga: {e}") @@ -5680,6 +15609,11 @@ def cmd_full_reading(args): 'ayanamsa': round(transit_ayanamsa, 4) if transit_ayanamsa is not None else None, 'node_mode': getattr(args, 'node_mode', 'mean'), 'data_layer': 'true_transit_positions', + 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( + 'full-reading.transit_positions', + requested_ayanamsa=getattr(args, 'ayanamsa', None), + transit_ayanamsa=transit_ayanamsa, + ), 'planets': transit_planets, } transit_multi = _calc_transit_multi_reference(planets, asc_idx, asc_deg, planet_lons, transit_planets=transit_planets, transit_date=transit_reference_date, node_mode=getattr(args, 'node_mode', 'mean')) @@ -5910,7 +15844,1008 @@ def cmd_full_reading(args): 'next_step': '⭐ v6.1.6: full-reading 已输出 transit_multi_reference(四参考点) + dasa_convergence(五系统交叉) + yogini_dasha + ashtottari_dasha + kalachakra_dasha + d9_navamsa_expanded。AI必须使用四参考点分析Transit,Dasa预测必须标注多系统收敛等级。', } - return report + return _build_response_envelope('full-reading', attach_calculation_profile(report, args), args=args, execution_status='executed' if not report.get('errors') else 'executed_with_warnings') + + +def build_pl9_style_export_packet(full_reading: dict, include_raw: bool = False) -> dict: + def _load_public_chara_narayana_dated_examples_summary() -> dict[str, Any]: + fixture_path = Path(ROOT_DIR) / 'references' / 'oracle' / 'chara_narayana_public_dated_examples_round1_2026_08_29.json' + try: + payload = json.loads(fixture_path.read_text(encoding='utf-8')) + except Exception as exc: + return { + 'status': 'blocked', + 'claim_boundary': f'public_chara_narayana_dated_examples_unavailable: {exc}', + 'fixture_path': str(fixture_path), + } + fixtures = payload.get('fixtures') if isinstance(payload.get('fixtures'), list) else [] + first_fixture = fixtures[0] if fixtures and isinstance(fixtures[0], dict) else {} + event_rows = first_fixture.get('event_rows') if isinstance(first_fixture.get('event_rows'), list) else [] + period_rows = first_fixture.get('period_rows') if isinstance(first_fixture.get('period_rows'), list) else [] + runtime_replay = first_fixture.get('runtime_replay') if isinstance(first_fixture.get('runtime_replay'), dict) else {} + return { + 'status': payload.get('status') or 'unknown', + 'schema': payload.get('schema'), + 'fixture_count': len(fixtures), + 'dated_event_row_count': len(event_rows), + 'md_row_count': len(period_rows), + 'runtime_replay_status': runtime_replay.get('status', 'unknown'), + 'formula_change_allowed_now': bool((payload.get('summary') or {}).get('formula_change_allowed_now')), + 'runtime_truth_promoted': bool((payload.get('summary') or {}).get('runtime_truth_promoted')), + 'fixture_path': str(fixture_path), + } + + def _load_chara_narayana_book_runtime_replay_summary() -> dict[str, Any]: + replay_path = Path(ROOT_DIR) / 'references' / 'oracle' / 'chara_narayana_book_runtime_replay_2026_08_28.json' + try: + payload = json.loads(replay_path.read_text(encoding='utf-8')) + except Exception as exc: + return { + 'status': 'blocked', + 'claim_boundary': f'chara_narayana_book_runtime_replay_unavailable: {exc}', + 'fixture_path': str(replay_path), + } + summary = payload.get('summary') if isinstance(payload.get('summary'), dict) else {} + return { + 'status': payload.get('status') or 'unknown', + 'case_count': summary.get('case_count'), + 'row_count': summary.get('row_count'), + 'match': summary.get('match'), + 'mismatch': summary.get('mismatch'), + 'blocked': summary.get('blocked'), + 'fixture_path': str(replay_path), + } + + def _load_chara_narayana_book_golden_cases_summary() -> dict[str, Any]: + golden_path = Path(ROOT_DIR) / 'references' / 'oracle' / 'chara_narayana_book_golden_cases_2026_08_28.json' + try: + payload = json.loads(golden_path.read_text(encoding='utf-8')) + except Exception as exc: + return { + 'status': 'blocked', + 'claim_boundary': f'chara_narayana_book_golden_cases_unavailable: {exc}', + 'fixture_path': str(golden_path), + } + summary = payload.get('summary') if isinstance(payload.get('summary'), dict) else {} + return { + 'status': payload.get('status') or 'unknown', + 'case_count': summary.get('case_count'), + 'period_row_count': summary.get('period_row_count'), + 'level_counts': summary.get('level_counts') or {}, + 'profile_counts': summary.get('profile_counts') or {}, + 'pd_status': summary.get('pd_status'), + 'fixture_path': str(golden_path), + } + + def _load_public_chara_narayana_dated_examples_replay_summary() -> dict[str, Any]: + replay_path = Path(ROOT_DIR) / 'references' / 'oracle' / 'chara_narayana_public_dated_examples_replay_2026_08_29.json' + try: + payload = json.loads(replay_path.read_text(encoding='utf-8')) + except Exception as exc: + return { + 'status': 'blocked', + 'claim_boundary': f'public_chara_narayana_dated_examples_replay_unavailable: {exc}', + 'fixture_path': str(replay_path), + } + summary = payload.get('summary') if isinstance(payload.get('summary'), dict) else {} + comparison = payload.get('comparison') if isinstance(payload.get('comparison'), list) else [] + event_rows = payload.get('event_rows') if isinstance(payload.get('event_rows'), list) else [] + return { + 'status': payload.get('status') or 'unknown', + 'fixture_case_id': payload.get('fixture_case_id'), + 'fixture_md_rows': summary.get('fixture_md_rows'), + 'fixture_event_rows': summary.get('fixture_event_rows'), + 'runtime_sequence_length': summary.get('runtime_sequence_length'), + 'sign_match_count': summary.get('sign_match_count'), + 'sign_mismatch_count': summary.get('sign_mismatch_count'), + 'md_duration_match_count': summary.get('md_duration_match_count'), + 'md_duration_mismatch_count': summary.get('md_duration_mismatch_count'), + 'event_boundary_match_count': summary.get('event_boundary_match_count'), + 'event_boundary_mismatch_count': summary.get('event_boundary_mismatch_count'), + 'event_boundary_layer_counts': summary.get('event_boundary_layer_counts') or {}, + 'pd_order_match_count': summary.get('pd_order_match_count'), + 'pd_order_mismatch_count': summary.get('pd_order_mismatch_count'), + 'pd_age_drift_positive_count': summary.get('pd_age_drift_positive_count'), + 'pd_age_drift_negative_count': summary.get('pd_age_drift_negative_count'), + 'pd_age_drift_total_years': summary.get('pd_age_drift_total_years'), + 'md_sequence_offset_positive_count': summary.get('md_sequence_offset_positive_count'), + 'md_sequence_offset_negative_count': summary.get('md_sequence_offset_negative_count'), + 'md_sequence_offset_total_steps': summary.get('md_sequence_offset_total_steps'), + 'ad_order_offset_positive_count': summary.get('ad_order_offset_positive_count'), + 'ad_order_offset_negative_count': summary.get('ad_order_offset_negative_count'), + 'ad_order_offset_total_steps': summary.get('ad_order_offset_total_steps'), + 'md_rule_source_counts': summary.get('md_rule_source_counts') or {}, + 'ad_rule_source_counts': summary.get('ad_rule_source_counts') or {}, + 'pd_rule_source_counts': summary.get('pd_rule_source_counts') or {}, + 'comparison_row_count': len(comparison), + 'event_row_count': len(event_rows), + 'first_row_status': comparison[0].get('status') if comparison and isinstance(comparison[0], dict) else None, + 'fixture_path': str(replay_path), + } + + def _normalize_strict_time_confidence(value: Any) -> str: + mapping = { + # A source-side daily signal is preserved as raw evidence, but the + # report-wide timing contract still blocks day-level event claims. + 'day_supported': 'blocked', + 'month_only': 'parameter_sensitive', + 'blocked': 'blocked', + } + text = str(value or '').strip() + return mapping.get(text, text or 'parameter_sensitive') + + def _build_pl9_key_time_nodes_from_strict_evidence(source: dict[str, Any]) -> list[dict[str, Any]]: + source_modules = source.get('modules') if isinstance(source.get('modules'), dict) else {} + theme_specs = ( + ('career', 'career_strict_evidence'), + ('marriage', 'relationship_strict_evidence'), + ('wealth', 'finance_strict_evidence'), + ) + nodes: list[dict[str, Any]] = [] + for theme, module_key in theme_specs: + strict_pack = source_modules.get(module_key) if isinstance(source_modules.get(module_key), dict) else {} + monthly = strict_pack.get('monthly_adjudication_summary') if isinstance(strict_pack.get('monthly_adjudication_summary'), dict) else {} + if not monthly: + continue + supporting_days = monthly.get('supporting_days') if isinstance(monthly.get('supporting_days'), list) else [] + top_day = next((item for item in supporting_days if isinstance(item, dict) and item.get('date')), None) + if not top_day: + continue + primary_state = monthly.get('primary_state') if isinstance(monthly.get('primary_state'), dict) else {} + manifestation_mode = monthly.get('manifestation_mode') if isinstance(monthly.get('manifestation_mode'), dict) else {} + friction_source = monthly.get('friction_source') if isinstance(monthly.get('friction_source'), dict) else {} + time_confidence = monthly.get('time_confidence') if isinstance(monthly.get('time_confidence'), dict) else {} + basis_parts = [] + if manifestation_mode.get('value'): + basis_parts.append(f"落地形式:{manifestation_mode.get('value')}") + if friction_source.get('value'): + basis_parts.append(f"阻力来源:{friction_source.get('value')}") + basis_parts.append('来源:strict_workflow_monthly_adjudication_v1') + trigger_parts = [] + if primary_state.get('value'): + trigger_parts.append(f"月度主状态:{primary_state.get('value')}") + if top_day.get('day_type'): + trigger_parts.append(f"day_type={top_day.get('day_type')}") + nodes.append({ + 'theme': theme, + 'section': 'monthly_adjudication_summary', + 'label': str(top_day.get('summary') or '').strip() or '关键时间节点', + 'window': str(top_day.get('date')), + 'status': _normalize_strict_time_confidence(time_confidence.get('value')), + 'raw_time_confidence': time_confidence.get('value') or 'unknown', + 'source_systems': [ + monthly.get('source', 'strict_workflow_monthly_adjudication_v1'), + top_day.get('source', 'source_not_recorded'), + ], + 'basis': ';'.join(basis_parts), + 'trigger_condition': ';'.join(trigger_parts), + 'verification_hint': f"confidence={top_day.get('confidence') or 'unknown'}", + }) + return nodes + + summary_payload = dict(full_reading.get('summary') or {}) + executive_summary_payload = ( + dict(summary_payload.get('executive_summary')) + if isinstance(summary_payload.get('executive_summary'), dict) + else {} + ) + if not isinstance(executive_summary_payload.get('key_time_nodes'), list) or not executive_summary_payload.get('key_time_nodes'): + generated_key_time_nodes = _build_pl9_key_time_nodes_from_strict_evidence(full_reading) + if generated_key_time_nodes: + executive_summary_payload['key_time_nodes'] = generated_key_time_nodes + if executive_summary_payload: + summary_payload['executive_summary'] = executive_summary_payload + public_chara_narayana_dated_examples = _load_public_chara_narayana_dated_examples_summary() + public_chara_narayana_dated_examples_replay = _load_public_chara_narayana_dated_examples_replay_summary() + chara_narayana_book_runtime_replay = _load_chara_narayana_book_runtime_replay_summary() + chara_narayana_book_golden_cases = _load_chara_narayana_book_golden_cases_summary() + + modules = full_reading.get('modules', {}) + try: + from shadbala_component_status_view import build_shadbala_component_status_view + shadbala_component_status = build_shadbala_component_status_view() + except Exception as exc: + shadbala_component_status = { + 'status': 'blocked', + 'claim_boundary': f'shadbala_component_status_view_unavailable: {exc}', + } + try: + from ashtottari_pl9_report_boundary_packet import ( + build_ashtottari_pl9_report_boundary_packet, + ) + + ashtottari_reference_packet = build_ashtottari_pl9_report_boundary_packet() + except Exception as exc: + ashtottari_reference_packet = { + 'status': 'blocked', + 'claim_boundary': f'ashtottari_pl9_report_boundary_packet_unavailable: {exc}', + } + try: + from narayana_pl9_report_boundary_packet import ( + build_narayana_pl9_report_boundary_packet, + ) + + narayana_reference_packet = build_narayana_pl9_report_boundary_packet() + except Exception as exc: + narayana_reference_packet = { + 'status': 'blocked', + 'claim_boundary': f'narayana_pl9_report_boundary_packet_unavailable: {exc}', + } + try: + from kalachakra_pl9_report_boundary_packet import ( + build_kalachakra_pl9_report_boundary_packet, + ) + + kalachakra_reference_packet = build_kalachakra_pl9_report_boundary_packet() + except Exception as exc: + kalachakra_reference_packet = { + 'status': 'blocked', + 'claim_boundary': f'kalachakra_pl9_report_boundary_packet_unavailable: {exc}', + } + nakshatra_advanced = modules.get('nakshatra_advanced') or modules.get('nakshatra_adv') + reference_parity = { + 'status': 'scaffolded', + 'boundary': 'This section records local parity targets and reusable reference families. It is not a numeric match claim by itself.', + 'engines': [ + { + 'engine': 'PyJHora', + 'role': 'breadth_and_traditional_reference', + 'license_boundary': 'AGPL_reference_only', + 'focus': ['divisional_charts', 'dasha', 'shadbala', 'ashtakavarga'], + 'status': 'reference_ready', + }, + { + 'engine': 'VedAstro', + 'role': 'api_and_ecosystem_reference', + 'license_boundary': 'open_reference', + 'focus': ['api_shape', 'structured_outputs', 'cross_platform_delivery'], + 'status': 'reference_ready', + }, + { + 'engine': 'jyotishganit', + 'role': 'precision_and_jsonld_reference', + 'license_boundary': 'MIT_reference_ready', + 'focus': ['d1_d60', 'panchanga', 'shadbala', 'vimshottari', 'json_output'], + 'status': 'reference_ready', + }, + ], + 'local_targets': [ + 'shadbala_parity_closure', + 'vimshottari_boundary_profile', + 'divisional_chart_consistency', + 'professional_export_richness', + ], + 'ashtottari_pl9_boundary_packet': ashtottari_reference_packet, + 'dasha_boundary_packets': { + 'ashtottari': ashtottari_reference_packet, + 'narayana': narayana_reference_packet, + 'kala_chakra': kalachakra_reference_packet, + }, + } + report_pack_manifest = { + 'pack_family': 'pl9_style_professional_report_pack_v1', + 'target_model': 'multi_workbook_longform_package', + 'derived_from_real_sample': { + 'sample_pdf': 'pl9.pdf', + 'sample_pages': 204, + 'sample_manifest_path': 'output/pl9_real_sample/pl9_refined_manifest_2026_08_09.md', + }, + 'packs': [ + { + 'id': 'base_charts_pack', + 'title': 'Base Charts / Identity Pack', + 'priority': 1, + 'worksheet_ids': ['birth_and_parameters', 'd1_rasi_bhava', 'divisional_and_special_charts'], + 'target_sections': ['cover', 'birth_particulars', 'birth_chart', 'moon_chart', 'navamsha', 'sudarshan', 'divisional_pages'], + }, + { + 'id': 'visual_chart_pack', + 'title': 'Visual Chart / Panel Manifest Pack', + 'priority': 2, + 'worksheet_ids': ['divisional_and_special_charts'], + 'target_sections': ['visual_panel_inventory', 'north_indian_chart_panels', 'chart_content_placement_boundary'], + }, + { + 'id': 'strengths_and_aspects_pack', + 'title': 'Strengths / Aspects / Technical Tables Pack', + 'priority': 3, + 'worksheet_ids': ['strengths_and_scores', 'advanced_systems'], + 'target_sections': ['shadbala', 'bhava_bala', 'friendship', 'avasthas', 'aspects', 'ashtakavarga'], + }, + { + 'id': 'dasha_master_pack', + 'title': 'Major and Auxiliary Dasha Pack', + 'priority': 4, + 'worksheet_ids': ['timing_and_predictive_systems', 'advanced_systems'], + 'target_sections': ['vimshottari', 'ashtottari', 'yogini', 'kala_chakra', 'jaimini', 'other_rashi_dashas'], + }, + { + 'id': 'dasha_interpretation_pack', + 'title': 'Dasha Interpretation Pack', + 'priority': 4, + 'worksheet_ids': ['timing_and_predictive_systems', 'ai_and_audit'], + 'target_sections': ['dasha_interpretations', 'md_ad_pd_trigger_narrative', 'dasha_evidence_boundary'], + }, + { + 'id': 'annual_tajika_pack', + 'title': 'Annual / Tajika / Varshaphala Pack', + 'priority': 5, + 'worksheet_ids': ['timing_and_predictive_systems', 'advanced_systems'], + 'target_sections': ['sade_sati', 'kp', 'varshaphala', 'tajika_yogas', 'sahams', 'mudda_dasha', 'annual_results'], + }, + { + 'id': 'three_year_predictive_ephemeris_pack', + 'title': 'Three-Year Monthly Predictive Ephemeris Pack', + 'priority': 5, + 'worksheet_ids': ['timing_and_predictive_systems', 'advanced_systems'], + 'target_sections': ['monthly_transits', 'vimshottari_five_levels', 'kp_monthly_support', 'annual_tajika_bridge', 'narayana_dasha_bridge'], + }, + { + 'id': 'dosha_remedy_pack', + 'title': 'Dosha / Saturn / Remedy Pack', + 'priority': 5, + 'worksheet_ids': ['timing_and_predictive_systems', 'ai_and_audit'], + 'target_sections': ['mangala_dosha', 'sade_sati_results', 'remedies'], + }, + { + 'id': 'yoga_and_interpretation_pack', + 'title': 'Yoga / Interpretation / Forecast Pack', + 'priority': 6, + 'worksheet_ids': ['advanced_systems', 'ai_and_audit', 'timing_and_predictive_systems'], + 'target_sections': ['yoga_interpretations', 'dasha_interpretations', 'forecast_text'], + }, + { + 'id': 'audit_appendix_pack', + 'title': 'Audit / Parity / AI Appendix Pack', + 'priority': 7, + 'worksheet_ids': ['ai_and_audit'], + 'target_sections': ['reference_parity', 'ai_prompt_pack', 'evidence_appendix'], + }, + ], + } + worksheet_page_groups = { + 'birth_and_parameters': [ + {'id': 'birth_identity', 'title': 'Birth Identity Card'}, + {'id': 'calculation_profile', 'title': 'Calculation Profile'}, + {'id': 'source_and_warnings', 'title': 'Source / Warning Audit'}, + ], + 'd1_rasi_bhava': [ + {'id': 'rasi_core', 'title': 'Rasi Core Grid'}, + {'id': 'planetary_positions', 'title': 'Planetary Position Table'}, + {'id': 'house_and_bhava', 'title': 'House / Bhava Overlay'}, + ], + 'strengths_and_scores': [ + {'id': 'strength_summary', 'title': 'Strength Summary'}, + {'id': 'planet_strength_rows', 'title': 'Per-Planet Strength Rows'}, + {'id': 'functional_benefics', 'title': 'Functional Benefic / Malefic Layer'}, + ], + 'divisional_and_special_charts': [ + {'id': 'classical_vargas', 'title': 'Classical Vargas'}, + {'id': 'research_vargas', 'title': 'Research Vargas'}, + {'id': 'special_points', 'title': 'Special Lagnas / Sensitive Points'}, + ], + 'timing_and_predictive_systems': [ + {'id': 'dasha_timeline', 'title': 'Dasha Timeline'}, + {'id': 'transit_windows', 'title': 'Transit Windows'}, + {'id': 'annual_and_tajika', 'title': 'Annual / Tajika Pages'}, + ], + 'advanced_systems': [ + {'id': 'jaimini_and_kp', 'title': 'Jaimini / KP Pages'}, + {'id': 'nakshatra_and_yoga', 'title': 'Nakshatra / Yoga Pages'}, + {'id': 'chains_and_linkages', 'title': 'Dispositor / Inter-chart Linkages'}, + ], + 'ai_and_audit': [ + {'id': 'executive_summary', 'title': 'Executive Summary Card'}, + {'id': 'ai_prompt_and_evidence', 'title': 'AI Prompt / Evidence'}, + {'id': 'reference_parity', 'title': 'Reference Parity Audit'}, + ], + } + try: + from professional_parity_closure import build_dasha_interpretation_pack, build_dasha_master_pack + chart_planets = modules.get('chart', {}).get('planets', {}) + functional_layer = modules.get('functional_benefic_malefic', {}) + owned_houses = functional_layer.get('owned_houses', {}) if isinstance(functional_layer, dict) else {} + functional_classification = {} + for label, members in ( + ('functional_benefic', functional_layer.get('functional_benefics', [])), + ('functional_malefic', functional_layer.get('functional_malefics', [])), + ('functional_neutral', functional_layer.get('functional_neutrals', [])), + ): + for planet in members: + functional_classification[planet] = label + lord_facts = { + planet: { + 'house': data.get('house'), + 'ownership': owned_houses.get(planet), + 'dignity': data.get('status'), + 'nakshatra': data.get('nakshatra'), + 'pada': data.get('nakshatra_pada'), + } + for planet, data in chart_planets.items() + if isinstance(data, dict) + } + dasha_master_pack = build_dasha_master_pack( + modules, + { + 'ayanamsa': full_reading.get('birth_info', {}).get('ayanamsa_name'), + 'node_mode': full_reading.get('birth_info', {}).get('node_mode'), + 'timezone': full_reading.get('birth_info', {}).get('tz'), + 'dasha_year_days': modules.get('dasha', {}).get('year_days'), + 'pl9_profile_status': 'parameter_sensitive', + }, + { + 'vimshottari': { + 'status': 'blocked', + 'reason': 'Formal external Dasha oracle contract remains incomplete.', + }, + }, + lord_facts, + functional_classification, + ) + dasha_interpretation_pack = build_dasha_interpretation_pack( + dasha_master_pack, + { + **(dasha_master_pack.get('profiles', {}).get('profile') or {}), + 'profile_id': full_reading.get('calculation_profile_id'), + }, + ) + except Exception as exc: + dasha_master_pack = { + 'schema': 'dasha_master_report_pack_v1', + 'audit': {'status': 'blocked', 'reason': f'Dasha master pack assembly failed: {exc}'}, + } + dasha_interpretation_pack = { + 'schema': 'pl9.dasha_interpretation_pack.v1', + 'status': 'blocked', + 'reason': f'Dasha interpretation pack assembly failed: {exc}', + } + + # Control-case PDF/fixture contracts are not imported. + pl9_p33_declination_contract = {} + pl9_p41_friendship_contract = {} + pl9_p42_shodashvarga_contract = {} + pl9_p43_44_shadbala_contract = {} + pl9_p89_jaimini_special_points_contract = _build_pl9_p89_jaimini_special_points_contract( + modules.get('jaimini') if isinstance(modules.get('jaimini'), dict) else {} + ) + + worksheet_payloads = { + 'birth_and_parameters': { + 'summary_card': { + 'date': full_reading.get('birth_info', {}).get('date'), + 'time': full_reading.get('birth_info', {}).get('time'), + 'location': { + 'lat': full_reading.get('birth_info', {}).get('lat'), + 'lon': full_reading.get('birth_info', {}).get('lon'), + 'tz': full_reading.get('birth_info', {}).get('tz'), + }, + }, + 'birth_info': full_reading.get('birth_info', {}), + 'panchanga': modules.get('panchanga'), + 'meta': { + 'version': full_reading.get('version'), + 'source': full_reading.get('source'), + 'primary_source': full_reading.get('primary_source'), + 'warnings': full_reading.get('warnings', []), + 'errors': full_reading.get('errors', []), + }, + 'detail_blocks': { + 'calculation_profile': { + 'ayanamsa_name': full_reading.get('birth_info', {}).get('ayanamsa_name'), + 'ayanamsa_display': full_reading.get('birth_info', {}).get('ayanamsa_display'), + 'node_mode': full_reading.get('birth_info', {}).get('node_mode'), + }, + }, + }, + 'd1_rasi_bhava': { + 'summary_card': { + 'ascendant': full_reading.get('ascendant'), + 'planet_count': len(full_reading.get('planets', {}) or {}), + 'house_count': len(full_reading.get('houses', {}) or {}), + }, + 'ascendant': full_reading.get('ascendant'), + 'planets': full_reading.get('planets'), + 'houses': full_reading.get('houses'), + 'chart': full_reading.get('chart'), + 'bhava_chalit': modules.get('bhava_chalit'), + 'moon_chart': modules.get('moon_chart'), + 'detail_blocks': { + 'overlay_status': { + 'has_chart': bool(full_reading.get('chart')), + 'has_bhava_chalit': bool(modules.get('bhava_chalit')), + 'has_moon_chart': bool(modules.get('moon_chart')), + }, + }, + }, + 'strengths_and_scores': { + 'summary_card': { + 'has_shadbala': bool(modules.get('shadbala')), + 'has_bhava_bala': bool(modules.get('bhava_bala')), + 'has_ashtakavarga': bool(modules.get('ashtakavarga')), + 'has_vimsopaka': bool(modules.get('vimsopaka')), + 'has_functional_layer': bool(modules.get('functional_benefic_malefic')), + 'has_planetary_friendship': bool(modules.get('planetary_friendship')), + 'has_pl9_p33_declination_contract': bool(pl9_p33_declination_contract), + 'has_pl9_p41_friendship_contract': bool(pl9_p41_friendship_contract), + 'has_pl9_p43_44_shadbala_contract': bool(pl9_p43_44_shadbala_contract), + }, + 'shadbala': modules.get('shadbala'), + 'shadbala_component_status': shadbala_component_status, + 'pl9_p33_declination_contract': pl9_p33_declination_contract, + 'pl9_p43_44_shadbala_contract': pl9_p43_44_shadbala_contract, + 'bhava_bala': modules.get('bhava_bala'), + 'ashtakavarga': modules.get('ashtakavarga'), + 'vimsopaka': modules.get('vimsopaka'), + 'pushkara': modules.get('pushkara'), + 'vargottama': modules.get('vargottama'), + 'lagna_vargottamamsa': modules.get('lagna_vargottamamsa'), + 'functional_benefic_malefic': modules.get('functional_benefic_malefic'), + 'planetary_friendship': modules.get('planetary_friendship'), + 'pl9_p41_friendship_contract': pl9_p41_friendship_contract, + 'detail_blocks': { + 'coverage': { + 'score_sources': [ + name for name, present in { + 'shadbala': bool(modules.get('shadbala')), + 'bhava_bala': bool(modules.get('bhava_bala')), + 'ashtakavarga': bool(modules.get('ashtakavarga')), + 'vimsopaka': bool(modules.get('vimsopaka')), + 'pushkara': bool(modules.get('pushkara')), + 'vargottama': bool(modules.get('vargottama')), + 'planetary_friendship': bool(modules.get('planetary_friendship')), + 'pl9_p33_declination_contract': bool(pl9_p33_declination_contract), + 'pl9_p41_friendship_contract': bool(pl9_p41_friendship_contract), + 'pl9_p43_44_shadbala_contract': bool(pl9_p43_44_shadbala_contract), + }.items() if present + ], + }, + }, + }, + 'divisional_and_special_charts': { + 'summary_card': { + 'has_varga_full': bool(modules.get('varga_full')), + 'has_varga_extended': bool(modules.get('varga_extended')), + 'has_varga_research_high': bool(modules.get('varga_research_high')), + 'has_special_lagnas': bool(modules.get('special_lagnas')), + 'has_pl9_p42_shodashvarga_contract': bool(pl9_p42_shodashvarga_contract), + }, + 'varga_full': modules.get('varga_full'), + 'varga_extended': modules.get('varga_extended'), + 'varga_research_high': modules.get('varga_research_high'), + 'pl9_p42_shodashvarga_contract': pl9_p42_shodashvarga_contract, + 'special_lagnas': modules.get('special_lagnas'), + 'sensitive_points': modules.get('sensitive_points'), + 'upagrahas': modules.get('upagrahas'), + 'sudarshana': modules.get('sudarshana'), + 'detail_blocks': { + 'varga_coverage': { + 'classical': bool(modules.get('varga_full')), + 'extended': bool(modules.get('varga_extended')), + 'research_high': bool(modules.get('varga_research_high')), + 'sudarshana': bool(modules.get('sudarshana')), + }, + }, + }, + 'timing_and_predictive_systems': { + 'summary_card': { + 'has_vimshottari': bool(modules.get('dasha')), + 'has_narayana': bool(modules.get('narayana_dasha')), + 'has_transit_multi_reference': bool(modules.get('transit_multi_reference')), + 'has_tajika': bool(modules.get('tajika')), + }, + 'dasha_master_pack': dasha_master_pack, + 'dasha_interpretation_pack': dasha_interpretation_pack, + 'dasha': modules.get('dasha'), + 'dasha_sandhi': modules.get('dasha_sandhi'), + 'bhrigu_pada_dasha': modules.get('bhrigu_pada_dasha'), + 'narayana_dasha': modules.get('narayana_dasha'), + 'transit_multi_reference': modules.get('transit_multi_reference'), + 'double_transit_pac': modules.get('double_transit_pac'), + 'transit_ll7l': modules.get('transit_ll7l'), + 'planetary_congregation': modules.get('planetary_congregation'), + 'unified_western_reading': modules.get('unified_western_reading'), + 'tajika': modules.get('tajika'), + 'tajika_yogas': modules.get('tajika_yogas'), + 'muhurta': modules.get('muhurta'), + 'detail_blocks': { + 'timing_coverage': { + 'dasha_systems': [ + name for name, present in { + 'vimshottari': bool(modules.get('dasha')), + 'narayana': bool(modules.get('narayana_dasha')), + 'sandhi': bool(modules.get('dasha_sandhi')), + }.items() if present + ], + 'transit_systems': [ + name for name, present in { + 'transit_multi_reference': bool(modules.get('transit_multi_reference')), + 'double_transit_pac': bool(modules.get('double_transit_pac')), + 'transit_ll7l': bool(modules.get('transit_ll7l')), + 'tajika': bool(modules.get('tajika')), + }.items() if present + ], + }, + }, + }, + 'advanced_systems': { + 'summary_card': { + 'has_jaimini': bool(modules.get('jaimini')), + 'has_kp': bool(modules.get('kp')), + 'has_nakshatra_adv': bool(nakshatra_advanced), + 'has_avasthas': bool(modules.get('avasthas')), + 'has_yoga': bool(modules.get('yoga')), + 'has_pl9_p89_jaimini_special_points_contract': bool(pl9_p89_jaimini_special_points_contract), + }, + 'jaimini': modules.get('jaimini'), + 'pl9_p89_jaimini_special_points_contract': pl9_p89_jaimini_special_points_contract, + 'kp': modules.get('kp'), + 'argala': modules.get('argala'), + 'aspects': modules.get('aspects'), + 'avasthas': modules.get('avasthas'), + 'nakshatra_full': modules.get('nakshatra_full'), + 'nakshatra_adv': nakshatra_advanced, + 'nakshatra_dasha': modules.get('nakshatra_dasha'), + 'dispositor_chains': modules.get('dispositor_chains'), + 'inter_chart_linkage': modules.get('inter_chart_linkage'), + 'final_dispositors': modules.get('final_dispositors'), + 'yoga': modules.get('yoga'), + 'yogas_doshas': modules.get('yogas_doshas'), + 'sahams': modules.get('sahams'), + 'marriage_counting': modules.get('marriage_counting'), + 'detail_blocks': { + 'advanced_coverage': { + 'techniques': [ + name for name, present in { + 'jaimini': bool(modules.get('jaimini')), + 'kp': bool(modules.get('kp')), + 'argala': bool(modules.get('argala')), + 'avasthas': bool(modules.get('avasthas')), + 'nakshatra_adv': bool(nakshatra_advanced), + 'yoga': bool(modules.get('yoga')), + 'sahams': bool(modules.get('sahams')), + 'pl9_p89_jaimini_special_points_contract': bool(pl9_p89_jaimini_special_points_contract), + }.items() if present + ], + }, + }, + }, + 'ai_and_audit': { + 'summary_card': { + 'has_summary': bool(summary_payload), + 'has_ai_prompt_pack': bool(full_reading.get('ai_prompt_pack')), + 'has_reference_parity': True, + }, + 'summary': summary_payload, + 'evidence_profiles': full_reading.get('evidence_profiles'), + 'timing_precision_contract': full_reading.get('timing_precision_contract'), + 'ai_prompt_pack': full_reading.get('ai_prompt_pack'), + 'reference_parity': reference_parity, + 'public_chara_narayana_dated_examples': public_chara_narayana_dated_examples, + 'public_chara_narayana_dated_examples_replay': public_chara_narayana_dated_examples_replay, + 'chara_narayana_book_runtime_replay': chara_narayana_book_runtime_replay, + 'chara_narayana_book_golden_cases': chara_narayana_book_golden_cases, + 'detail_blocks': { + 'audit_status': { + 'has_evidence_profiles': bool(full_reading.get('evidence_profiles')), + 'has_timing_precision_contract': bool(full_reading.get('timing_precision_contract')), + 'parity_engine_count': len(reference_parity.get('engines', [])), + }, + }, + }, + } + worksheet_specs = [ + { + 'id': 'birth_and_parameters', + 'title': 'Birth Data & Calculation Parameters', + 'priority': 1, + 'source_modules': ['birth_info', 'meta'], + 'present': bool(full_reading.get('birth_info')), + }, + { + 'id': 'd1_rasi_bhava', + 'title': 'D1 Rasi / Bhava Core Sheet', + 'priority': 2, + 'source_modules': ['chart', 'ascendant', 'planets', 'houses'], + 'present': bool(full_reading.get('chart') or full_reading.get('planets')), + }, + { + 'id': 'strengths_and_scores', + 'title': 'Strength, Bala & Score Matrix', + 'priority': 3, + 'source_modules': ['shadbala', 'ashtakavarga', 'vimsopaka', 'functional_benefic_malefic'], + 'present': bool( + modules.get('shadbala') + or modules.get('ashtakavarga') + or modules.get('vimsopaka') + or modules.get('functional_benefic_malefic') + ), + }, + { + 'id': 'divisional_and_special_charts', + 'title': 'Divisional / Special Chart Workbook', + 'priority': 4, + 'source_modules': ['varga_full', 'varga_extended', 'varga_research_high', 'special_lagnas', 'sudarshana'], + 'present': bool( + modules.get('varga_full') + or modules.get('varga_extended') + or modules.get('special_lagnas') + or modules.get('sudarshana') + ), + }, + { + 'id': 'timing_and_predictive_systems', + 'title': 'Dasha / Transit / Annual Timing Workbook', + 'priority': 5, + 'source_modules': ['dasha', 'narayana_dasha', 'transit_multi_reference', 'tajika', 'muhurta'], + 'present': bool( + modules.get('dasha') + or modules.get('narayana_dasha') + or modules.get('transit_multi_reference') + or modules.get('tajika') + ), + }, + { + 'id': 'advanced_systems', + 'title': 'Advanced Systems & Cross-Technique Workbook', + 'priority': 6, + 'source_modules': ['jaimini', 'kp', 'argala', 'nakshatra_adv', 'yoga', 'sahams'], + 'present': bool( + modules.get('jaimini') + or modules.get('kp') + or modules.get('argala') + or nakshatra_advanced + or modules.get('yoga') + ), + }, + { + 'id': 'ai_and_audit', + 'title': 'AI Prompt / Audit / Evidence Appendix', + 'priority': 7, + 'source_modules': ['summary', 'evidence_profiles', 'timing_precision_contract', 'ai_prompt_pack'], + 'present': bool( + summary_payload + or full_reading.get('evidence_profiles') + or full_reading.get('timing_precision_contract') + or full_reading.get('ai_prompt_pack') + ), + }, + ] + worksheet_index = [ + { + 'id': spec['id'], + 'title': spec['title'], + 'priority': spec['priority'], + 'status': 'ready' if spec['present'] else 'empty', + 'source_modules': spec['source_modules'], + 'section_keys': list(worksheet_payloads.get(spec['id'], {}).keys()), + 'page_groups': worksheet_page_groups.get(spec['id'], []), + } + for spec in worksheet_specs + ] + ready_worksheets = [item for item in worksheet_index if item['status'] == 'ready'] + packet = { + 'schema': 'pl9_style_professional_export_v1', + 'generated_from': 'jyotish_engine.full-reading', + 'birth_info': full_reading.get('birth_info', {}), + 'meta': { + 'version': full_reading.get('version'), + 'source': full_reading.get('source'), + 'primary_source': full_reading.get('primary_source'), + 'warnings': full_reading.get('warnings', []), + 'errors': full_reading.get('errors', []), + }, + 'core_chart': { + 'ascendant': full_reading.get('ascendant'), + 'planets': full_reading.get('planets'), + 'houses': full_reading.get('houses'), + 'chart': full_reading.get('chart'), + }, + 'strengths_and_scores': { + 'shadbala': modules.get('shadbala'), + 'shadbala_component_status': shadbala_component_status, + 'bhava_bala': modules.get('bhava_bala'), + 'ashtakavarga': modules.get('ashtakavarga'), + 'vimsopaka': modules.get('vimsopaka'), + 'pushkara': modules.get('pushkara'), + 'vargottama': modules.get('vargottama'), + 'lagna_vargottamamsa': modules.get('lagna_vargottamamsa'), + 'functional_benefic_malefic': modules.get('functional_benefic_malefic'), + 'planetary_friendship': modules.get('planetary_friendship'), + }, + 'divisional_and_special_charts': { + 'varga_full': modules.get('varga_full'), + 'varga_extended': modules.get('varga_extended'), + 'varga_research_high': modules.get('varga_research_high'), + 'bhava_chalit': modules.get('bhava_chalit'), + 'moon_chart': modules.get('moon_chart'), + 'special_lagnas': modules.get('special_lagnas'), + 'sensitive_points': modules.get('sensitive_points'), + 'upagrahas': modules.get('upagrahas'), + 'sudarshana': modules.get('sudarshana'), + }, + 'timing_and_predictive_systems': { + 'dasha_master_pack': dasha_master_pack, + 'dasha': modules.get('dasha'), + 'dasha_sandhi': modules.get('dasha_sandhi'), + 'bhrigu_pada_dasha': modules.get('bhrigu_pada_dasha'), + 'narayana_dasha': modules.get('narayana_dasha'), + 'transit_multi_reference': modules.get('transit_multi_reference'), + 'double_transit_pac': modules.get('double_transit_pac'), + 'transit_ll7l': modules.get('transit_ll7l'), + 'planetary_congregation': modules.get('planetary_congregation'), + 'unified_western_reading': modules.get('unified_western_reading'), + 'tajika': modules.get('tajika'), + 'tajika_yogas': modules.get('tajika_yogas'), + 'muhurta': modules.get('muhurta'), + }, + 'advanced_systems': { + 'jaimini': modules.get('jaimini'), + 'kp': modules.get('kp'), + 'argala': modules.get('argala'), + 'aspects': modules.get('aspects'), + 'avasthas': modules.get('avasthas'), + 'nakshatra_full': modules.get('nakshatra_full'), + 'nakshatra_adv': nakshatra_advanced, + 'nakshatra_dasha': modules.get('nakshatra_dasha'), + 'dispositor_chains': modules.get('dispositor_chains'), + 'inter_chart_linkage': modules.get('inter_chart_linkage'), + 'final_dispositors': modules.get('final_dispositors'), + 'yoga': modules.get('yoga'), + 'yogas_doshas': modules.get('yogas_doshas'), + 'sahams': modules.get('sahams'), + 'marriage_counting': modules.get('marriage_counting'), + }, + 'ai_and_audit': { + 'summary': summary_payload, + 'evidence_profiles': full_reading.get('evidence_profiles'), + 'timing_precision_contract': full_reading.get('timing_precision_contract'), + 'ai_prompt_pack': full_reading.get('ai_prompt_pack'), + }, + 'report_catalog': { + 'format_family': 'pl9_style_professional_export', + 'render_order': [ + 'birth_info', + 'meta', + 'report_layers', + 'report_index', + 'core_chart', + 'strengths_and_scores', + 'divisional_and_special_charts', + 'timing_and_predictive_systems', + 'advanced_systems', + 'ai_and_audit', + ], + 'worksheet_count': len(worksheet_index), + 'ready_worksheet_count': len(ready_worksheets), + 'worksheets': worksheet_index, + }, + 'report_layers': { + 'executive_summary': { + 'status': 'ready' if summary_payload else 'empty', + 'source_path': 'summary', + }, + 'thematic_narrative': { + 'status': 'ready' if ready_worksheets else 'empty', + 'source_paths': [ + 'strengths_and_scores', + 'divisional_and_special_charts', + 'timing_and_predictive_systems', + 'advanced_systems', + ], + }, + 'evidence_appendix': { + 'status': 'ready', + 'source_paths': [ + 'birth_info', + 'meta', + 'core_chart', + 'ai_and_audit', + ], + 'ch10_ashtama_shani_anchor_correction': _load_ch10_ashtama_shani_anchor_correction(), + }, + }, + 'report_index': { + 'primary_workbook': 'full_jyotish_professional_export', + 'recommended_render_sequence': [item['id'] for item in worksheet_index], + 'worksheet_ids': [item['id'] for item in worksheet_index], + }, + 'report_pack_manifest': report_pack_manifest, + 'reference_parity': reference_parity, + 'worksheets': worksheet_payloads, + 'coverage': { + 'module_count': len(modules), + 'non_empty_sections': [ + key for key, value in { + 'core_chart': full_reading.get('chart'), + 'strengths_and_scores': modules.get('shadbala') or modules.get('ashtakavarga'), + 'divisional_and_special_charts': modules.get('varga_full') or modules.get('special_lagnas'), + 'timing_and_predictive_systems': modules.get('dasha') or modules.get('transit_multi_reference'), + 'advanced_systems': modules.get('jaimini') or modules.get('kp') or modules.get('tajika'), + }.items() if value + ], + 'ready_worksheet_ids': [item['id'] for item in ready_worksheets], + 'report_pack_ids': [item['id'] for item in report_pack_manifest['packs']], + }, + } + if include_raw: + packet['raw_full_reading'] = full_reading + return packet + + +def _attach_default_chart_identity(packet: dict) -> dict: + """Attach delivery identity without granting approval authority. + + A normal export can be computed from user-provided birth data, but that is + distinct from an approved rectification profile. The identity is therefore + explicit and auditable while the publication boundary remains unchanged. + """ + existing = packet.get('chart_identity') + if isinstance(existing, dict) and existing.get('chart_profile_id'): + return packet + packet['chart_identity'] = { + 'chart_profile_id': packet.get('calculation_profile_id'), + 'birth_data_status': 'user_provided', + 'rectification_status': 'not_reviewed', + 'approval_status': 'not_approved', + 'claim_boundary': ( + 'This calculation profile is derived from supplied birth data. ' + 'It is not an approved rectification profile and cannot bypass review or promotion.' + ), + } + return packet + + +def cmd_pl9_export(args): + full = cmd_full_reading(args) + # PL9 is the research/authority export surface: never drop the raw reading + # behind an opt-in flag. The legacy flag remains accepted for CLI compatibility. + packet = build_pl9_style_export_packet(full, include_raw=True) + try: + packet = attach_calculation_profile(packet, args) + packet = _attach_annual_tajika_pack(packet, args) + packet = _attach_report_governance_contracts(packet, args) + packet = _attach_base_charts_pack(packet) + packet = _attach_visual_chart_pack(packet, args) + selected = _apply_pl9_pack_selection(packet, _normalize_pl9_pack_selection(args)) + final_packet = _attach_full_report_pack(selected, args) + final_packet = _attach_startrack_language_bridge(final_packet, args) + final_packet = attach_calculation_profile(final_packet, args) + final_packet = _attach_default_chart_identity(final_packet) + final_packet = _attach_report_governance_contracts(final_packet, args) + # Added after result hashing so delivery provenance cannot alter chart computation. + final_packet['generated_at'] = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z' + if final_packet.get('selected_report_scope') == 'full': + evaluate_full_report = _try_attr_import("full_report_quality_gate", "evaluate_full_report") + if evaluate_full_report is None: + final_packet['report_quality_gate'] = { + 'status': 'blocked', + 'reason': 'full_report_quality_gate_absent', + } + else: + reader_markdown_for_quality = render_pl9_markdown(final_packet) + final_packet['report_quality_gate'] = evaluate_full_report( + final_packet, + reader_markdown_for_quality, + ) + build_shared_full_report_authority = _try_attr_import( + "shared_full_report_authority", "build_shared_full_report_authority" + ) + if build_shared_full_report_authority is None: + final_packet['shared_full_report_authority'] = { + 'schema_version': 'jyotish.shared_full_report_authority.v1', + 'status': 'blocked', + 'reason': 'shared_full_report_authority_absent', + 'read_only': True, + } + else: + final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet) + return _build_response_envelope('pl9-export', final_packet, args=args, execution_status='executed') + except ValueError as exc: + return { + 'scope': 'pl9_export_pack_selection', + 'status': 'blocked', + 'reason': str(exc), + } # ============================================================================ @@ -5971,7 +16906,7 @@ def cmd_sudarshana(args): print(report) return {"format": "text", "report_printed": True} - return calc_sudarshana_chakra(planet_lons, asc_lon, house=house) + return _build_response_envelope('sudarshana', calc_sudarshana_chakra(planet_lons, asc_lon, house=house), args=args, execution_status='executed') # ============================================================================ @@ -6052,6 +16987,10 @@ def main(): p.add_argument('--day', type=int, default=15, help='指定日期(默认15日取月中代表)') p.add_argument('--planet', default=None, help='目标行星,逗号分隔(默认全部,如:Jupiter,Saturn)') p.add_argument('--tz', type=float, default=None, help='时区') + p.add_argument('--months', type=int, default=12, help='搜索未来月数(1-24)') + p.add_argument('--retrograde-calendar', action='store_true', help='输出逆行日历') + p.add_argument('--find-conjunction', default=None, help='查找指定行星合相目标黄经') + p.add_argument('--target-longitude', type=float, default=None, help='合相目标黄经(0-360)') p.add_argument('--node-mode', default='mean', choices=['mean', 'true'], help='Rahu/Ketu节点口径:mean=Mean Node(默认),true=True Node') # 9. shadbala (v3.4新增) @@ -6131,6 +17070,14 @@ def main(): _add_chart_args(p) p.add_argument('--mode', default='all', choices=['all','karaka','dasha','karakamsha','arudha','special'], help='分析模式') p.add_argument('--antardasha', action='store_true', help='Chara Dasha含Antardasha子周期(covered;仍需多系统确认)') + p.add_argument('--chara-profile', default='kn_rao', + choices=['kn_rao', 'goel_lagna_start', 'goel_gendered', 'goel_shastri_method2', 'pyjhora_blackbox'], + help='Chara Dasha 变体:默认KN Rao;也可选Goel lagna-start、Goel gendered、Goel/Shastri Method 2、PyJHora黑盒') + p.add_argument('--chara-gender', default=None, choices=['male', 'female'], + help='Goel gendered Chara 起运规则所需性别;仅该profile使用') + p.add_argument('--reference-year', type=int, default=2026, help='PyJHora Chara black-box参考年') + p.add_argument('--reference-month', type=int, default=8, help='PyJHora Chara black-box参考月') + p.add_argument('--reference-day', type=int, default=28, help='PyJHora Chara black-box参考日') # 18. nakshatra-adv (v3.7新增 → v6.0.22 升级) p = sub.add_parser('nakshatra-adv', help='高级Nakshatra分析(Tara/Chandra/Sub-Lord/综合)') @@ -6173,6 +17120,9 @@ def main(): p = sub.add_parser('narayana-dasha', help='Narayana Dasha(Rishi Dasha)星座大运分析') _add_chart_args(p) p.add_argument('--age', type=float, default=None, help='当前年龄(用于定位大运位置)') + p.add_argument('--variant-profile', default='legacy_local', + choices=['legacy_local', 'kn_rao', 'goel_lagna_start', 'goel_gendered', 'goel_shastri_method2', 'pyjhora_blackbox'], + help='Narayana/Chara式星座大运profile:legacy保持旧输出;kn_rao/goel映射到显式本地规则;pyjhora_blackbox只输出PyJHora黑盒当前路径') # 21.7 muhurta (v6.0.21新增) p = sub.add_parser('muhurta', help='Muhurta 择时分析(Panchanga 五要素)') @@ -6202,8 +17152,39 @@ def main(): p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') p.add_argument('--target-year', type=int, default=None, help='太阳返照盘目标年份(默认不计算 Varshaphala)') + p.add_argument('--crosscheck-planets', default=None, help='D4/D9/D10交叉检查行星,逗号分隔') p.add_argument('--profile-stages', action='store_true', help='输出 full-reading 粗粒度阶段耗时,并在 summary 中附带 stage timings') + p = sub.add_parser('pl9-export', help='PL9 风格专业排盘导出(基于 full-reading 聚合专业模块)') + _add_chart_args(p) + p.add_argument('--age', type=int, default=None, help='当前年龄(不提供则自动计算)') + p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') + p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') + p.add_argument('--target-year', type=int, default=None, help='太阳返照盘目标年份(默认不计算 Varshaphala)') + p.add_argument('--profile-stages', action='store_true', help='输出 full-reading 粗粒度阶段耗时,并在 summary 中附带 stage timings') + p.add_argument('--include-raw', action='store_true', help='兼容参数:PL9 导出始终附带原始 full-reading 完整输出') + p.add_argument('--format', choices=['json', 'markdown', 'pdf', 'authority'], default='json', help='导出格式:json / markdown / pdf / authority') + p.add_argument('--output', default=None, help='pdf 导出目标路径(仅 --format pdf 生效)') + p.add_argument('--archive-dir', default=None, help='显式指定可复现执行归档目录;不指定时不落盘归档') + p.add_argument('--pack', action='append', default=[], help='选择单个报告包,可重复;或传 full 导出全部') + p.add_argument('--packs', default=None, help='逗号分隔批量选择报告包;可与 --pack 混用') + p.add_argument('--visual-chart-observations', default=None, help='visual_chart_pack panel_observations JSON artifact 路径') + p.add_argument('--startrack-language-bridge', action='store_true', help='可选:调用星轨人生中文解释增强层并挂入 full_report_pack') + p.add_argument('--startrack-events-file', default=None, help='星轨人生事件证据 JSON,用于 2 分钟候选排序') + p.add_argument('--startrack-gender', choices=['male', 'female'], default='female', help='传给星轨紫微 CLI 的性别参数') + p.add_argument('--startrack-location', default=None, help='传给星轨人生的地点名') + p.add_argument('--startrack-time-source', default='approximate', help='传给星轨人生的出生时间来源标签') + p.add_argument('--startrack-uncertainty', type=int, default=4, help='星轨候选扫描不确定分钟数') + p.add_argument('--startrack-locale', choices=['zh-CN', 'zh-TW', 'zh-HK'], default='zh-CN', help='星轨语言桥输出中文地区') + p.add_argument('--startrack-dir', default=None, help='星轨人生项目目录;默认使用本机 GitHub/star-track-life2.0') + p.add_argument('--startrack-timeout-seconds', type=int, default=60, help='星轨增强层调用超时秒数') + p.add_argument('--birthplace-label', default=None, help='出生地点名称或医院级地址;仅作为来源记录,不自动提升坐标精度') + p.add_argument('--coordinate-precision', choices=['hospital_geocoded', 'address_geocoded', 'city_centroid', 'unverified_user_coordinates'], default='unverified_user_coordinates', help='坐标精度来源分级') + p.add_argument('--coordinate-source', default=None, help='坐标来源,如 geocoder 记录、病历地址或用户输入') + p.add_argument('--time-source', default=None, help='出生时间来源,如出生证明、家属转述或未知') + p.add_argument('--uncertainty-minutes', type=int, default=None, help='出生时间不确定范围(分钟)') + p.add_argument('--event-replay-file', default=None, help='用户已知经历 JSON 数组,用于事件回放校验入口') + # 23. prashna (v3.9新增) p = sub.add_parser('prashna', help='Prashna问事占星(提问时刻星盘+Arudha+Sphuta+Sahams)') p.add_argument('--datetime', required=True, help='提问时间 ISO-8601,例如 2026-07-12T12:00:00+08:00') @@ -6219,24 +17200,24 @@ def main(): # 24. double-transit-pac (v3.9新增) p = sub.add_parser('double-transit-pac', help='Double Transit PAC + D9层(KN Rao完整实现)') _add_chart_args(p) - p.add_argument('--date', required=True, help='过境日期 YYYY-MM-DD') + p.add_argument('--date', '--transit-date', dest='date', required=True, help='过境日期 YYYY-MM-DD') p.add_argument('--house', type=int, default=7, help='目标宫位(默认7=婚姻)') # 25. transit-ll7l (v3.9新增) p = sub.add_parser('transit-ll7l', help='Transit LL/7L连接+互换检测') _add_chart_args(p) - p.add_argument('--date', required=True, help='过境日期 YYYY-MM-DD') + p.add_argument('--date', '--transit-date', dest='date', required=True, help='过境日期 YYYY-MM-DD') # 26. planetary-congregation (v3.9新增) p = sub.add_parser('planetary-congregation', help='行星聚集检测(Lagna/7H+Transit)') _add_chart_args(p) p.add_argument('--house', type=int, default=7, help='目标宫位') - p.add_argument('--transit-date', default=None, help='过境日期 YYYY-MM-DD(可选)') + p.add_argument('--transit-date', '--date', dest='transit_date', default=None, help='过境日期 YYYY-MM-DD(可选)') # 27. vivah-saham (v3.9新增) p = sub.add_parser('vivah-saham', help='Vivah Saham计算+Transit激活') _add_chart_args(p) - p.add_argument('--transit-date', default=None, help='过境日期 YYYY-MM-DD(可选)') + p.add_argument('--transit-date', '--date', dest='transit_date', default=None, help='过境日期 YYYY-MM-DD(可选)') # 29. bhava-chalit (v6.9.13新增) p = sub.add_parser('bhava-chalit', help='Bhava Chalit 不等宫边界调整(Rashi vs Bhava 宫位对比)') @@ -6301,7 +17282,7 @@ def main(): 'nakshatra-full': cmd_nakshatra_full, 'argala': cmd_argala, 'tajika': cmd_tajika, 'synastry': cmd_synastry, 'solar-return': cmd_solar_return, 'narayana-dasha': cmd_narayana_dasha, 'muhurta': cmd_muhurta, - 'full-reading': cmd_full_reading, 'prashna': cmd_prashna, + 'full-reading': cmd_full_reading, 'pl9-export': cmd_pl9_export, 'prashna': cmd_prashna, 'double-transit-pac': cmd_double_transit_pac, 'transit-ll7l': cmd_transit_ll7l, 'planetary-congregation': cmd_planetary_congregation, 'vivah-saham': cmd_vivah_saham, @@ -6317,8 +17298,50 @@ def main(): if args.command in {'predict', 'full-reading'} and isinstance(result, dict): from timing_precision_contract import build_timing_precision_contract result['timing_precision_contract'] = build_timing_precision_contract(result.get('timing')) + if args.command == 'pl9-export' and getattr(args, 'archive_dir', None) and isinstance(result, dict): + try: + from calculation_execution_archive import write_execution_archive + except ModuleNotFoundError: + result['execution_archive'] = { + 'status': 'blocked', + 'reason': 'calculation_execution_archive_absent', + } + else: + archive_report = None + if getattr(args, 'format', 'json') == 'markdown': + archive_report = render_pl9_markdown(result) + result['execution_archive'] = write_execution_archive( + args.archive_dir, + calculation_profile=result.get('calculation_profile') or {}, + result=result, + report_text=archive_report, + ) if getattr(args, 'table', False): output_table(args.command, result) + elif args.command == 'pl9-export' and result.get('schema') == 'pl9_style_professional_export_v1' and getattr(args, 'format', 'json') == 'markdown': + markdown = render_pl9_markdown(result) + output_path = getattr(args, 'output', None) + if output_path: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(markdown, encoding='utf-8') + print(markdown, end='') + elif args.command == 'pl9-export' and result.get('schema') == 'pl9_style_professional_export_v1' and getattr(args, 'format', 'json') == 'pdf': + output_json(write_pl9_pdf(result, output_path=getattr(args, 'output', None))) + elif args.command == 'pl9-export' and result.get('schema') == 'pl9_style_professional_export_v1' and getattr(args, 'format', 'json') == 'authority': + output_json(result.get('shared_full_report_authority') or { + 'schema_version': 'jyotish.shared_full_report_authority.v1', + 'status': 'blocked', + 'reason': 'shared_full_report_authority_missing', + 'read_only': True, + }) + elif args.command == 'pl9-export' and result.get('schema') == 'pl9_style_professional_export_v1' and getattr(args, 'format', 'json') == 'json': + output_path = getattr(args, 'output', None) + if output_path: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str) + '\n', encoding='utf-8') + output_json(result) else: output_json(result) diff --git a/scripts/kp_system.py b/scripts/kp_system.py index c1e4cf96..fcd1cbfb 100644 --- a/scripts/kp_system.py +++ b/scripts/kp_system.py @@ -10,8 +10,10 @@ KP (Krishnamurti Paddhati) 占星系统模块 3. House Significator ABCD体系 """ +import json from datetime import datetime, timedelta -from typing import Dict, List, Tuple, Optional +from pathlib import Path +from typing import Any, Dict, List, Tuple, Optional SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] @@ -38,6 +40,171 @@ VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION)) NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees +ROOT = Path(__file__).resolve().parents[1] + + +def _load_json_artifact(relative_path: str) -> Dict[str, Any]: + path = ROOT / relative_path + with open(path, encoding='utf-8') as f: + data = json.load(f) + data.setdefault('artifact_path', relative_path) + return data + + +def kp_maturity_profile() -> Dict[str, Any]: + """Return the current KP maturity boundary for reports and API consumers. + + This intentionally reuses pinned oracle/status packets instead of inventing + a second truth policy inside runtime code. It is a display/report guard: + KP layers can be shown, but prediction truth stays blocked until the status + packets say the numeric oracle and independent holdout gates are closed. + """ + event = _load_json_artifact('references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json') + gate = _load_json_artifact('references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json') + replay = _load_json_artifact('references/oracle/kp_real_event_replay_gate_2026_07_30.json') + cusp = _load_json_artifact('references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json') + table = _load_json_artifact('references/oracle/kp_external_table_hash_manifest_2026_07_20.json') + workflow_gate = _load_json_artifact('references/oracle/kp_significator_workflow_gate_2026_07_23.json') + + runtime_evidence_layers = workflow_gate.get('runtime_evidence_layers') or [] + remaining_hard_reasons = ((event.get('promotion_gate') or {}).get('remaining_hard_reasons')) or [] + holdout_counts = replay.get('current_holdout_counts') or {} + required_counts = replay.get('required_holdout_counts') or {} + closed = ( + event.get('truth_matrix_allowed') is True + and event.get('timing_truth_promoted') is True + and replay.get('timing_truth_promoted') is True + and (holdout_counts.get('frozen_positive_count') or 0) >= (required_counts.get('minimum_frozen_positive') or 20) + and (holdout_counts.get('frozen_negative_count') or 0) >= (required_counts.get('minimum_frozen_negative') or 80) + ) + + if closed: + claim_status = 'timing_truth_closed' + display_policy = 'verified_prediction_allowed_with_evidence' + else: + claim_status = 'observation_only_truth_blocked' + display_policy = 'show_kp_layers_as_research_evidence_only_do_not_claim_precise_timing' + + blockers = [] + blockers.extend([row.get('blocker') for row in (event.get('remaining_blockers') or []) if isinstance(row, dict)]) + blockers.extend(replay.get('maturity_gap') or []) + blockers.extend(cusp.get('remaining_blockers') or []) + blockers = list(dict.fromkeys(str(item) for item in blockers if item)) + + return { + 'scope': 'kp_maturity_profile', + 'claim_status': claim_status, + 'display_policy': display_policy, + 'timing_truth_promoted': closed, + 'truth_matrix_allowed': closed, + 'production_tuning_allowed': closed, + 'source_artifacts': { + 'event_closure_status': event['artifact_path'], + 'event_closure_dashboard': gate['artifact_path'], + 'real_event_replay_gate': replay['artifact_path'], + 'cusp_numeric_oracle_readiness': cusp['artifact_path'], + 'external_table_hash_manifest': table['artifact_path'], + 'significator_workflow_gate': workflow_gate['artifact_path'], + }, + 'runtime_evidence_layers': runtime_evidence_layers, + 'runtime_evidence_layer_count': len(runtime_evidence_layers), + 'remaining_hard_reasons': remaining_hard_reasons, + 'remaining_hard_reason_count': len(remaining_hard_reasons), + 'selected_lane': ((event.get('selector') or {}).get('selected_lane')), + 'fallback_lane': ((event.get('selector') or {}).get('fallback_lane')), + 'promotion_gate_passed': bool((event.get('promotion_gate') or {}).get('gate_passed', False)), + 'closed_numeric_assets': { + 'kp_sub_lord_fixture_hash_fixed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249, + 'kp_sub_lord_midpoint_all_249_guarded': True, + 'kp_sub_lord_249_segment_parity_closed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249, + 'public_12_cusp_packet_ready': bool(cusp.get('ready_evidence')), + }, + 'closed_oracle_parity_assets': { + 'kp_sub_lord_249_segment_parity': { + 'status': 'closed', + 'scope': 'structure_only_not_event_timing', + 'source_artifact': table['artifact_path'], + 'test_guard': 'tests/test_kp_system.py::test_kp_sublord_matches_vedicastro_csv_all_249_segments', + 'boundary': 'This closes the 249 SubLord segmentation/table parity only; it does not close cusp, ruling-planet, significator workflow, DBA, real-event, or timing-outcome truth.', + }, + }, + 'holdout_counts': holdout_counts, + 'required_holdout_counts': required_counts, + 'remaining_blockers': blockers, + 'claim_boundary': ( + 'KP star/sub/sub-sub/significator/DBA layers may be displayed in the personal report, ' + 'but exact event timing remains blocked until numeric oracle settings, ruling planets, ' + 'and independent 20 positive / 80 negative holdout replay are closed.' + ), + } + + +def build_kp_western_support_surface( + western_support: Optional[Dict[str, Any]] = None, + *, + maturity_profile: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Normalize Western support for KP report-facing surfaces only. + + This packages support/convergence/negative-evidence summaries without + changing the underlying KP maturity boundary. + """ + maturity = maturity_profile or kp_maturity_profile() + source = western_support if isinstance(western_support, dict) else {} + convergence_source = source.get('convergence') if isinstance(source.get('convergence'), dict) else {} + negative_source = source.get('negative_evidence') if isinstance(source.get('negative_evidence'), dict) else {} + + shared_signal_count = convergence_source.get('shared_signal_count') + shared_signal_count = shared_signal_count if isinstance(shared_signal_count, int) else 0 + conflict_count = convergence_source.get('conflict_count') + conflict_count = conflict_count if isinstance(conflict_count, int) else 0 + missing_layers = negative_source.get('missing_layers') + missing_layers = [str(item) for item in missing_layers] if isinstance(missing_layers, list) else [] + rejected_windows = negative_source.get('rejected_windows') + rejected_windows = [str(item) for item in rejected_windows] if isinstance(rejected_windows, list) else [] + + convergence_status = str(convergence_source.get('status') or ('not_provided' if not source else 'partial')) + negative_status = str(negative_source.get('status') or ('not_provided' if not source else 'partial')) + if not source: + status = 'not_provided' + elif 'blocked' in {convergence_status, negative_status}: + status = 'blocked' + elif 'partial' in {convergence_status, negative_status}: + status = 'partial' + elif convergence_status == negative_status == 'used': + status = 'used' + else: + status = 'partial' + + convergence_summary = convergence_source.get('summary') + if not convergence_summary: + convergence_summary = f'{shared_signal_count} shared signals; {conflict_count} conflicts' + negative_summary = negative_source.get('summary') + if not negative_summary: + negative_summary = f'{len(missing_layers)} missing layers; {len(rejected_windows)} rejected windows' + + return { + 'status': status, + 'convergence': { + 'status': convergence_status, + 'shared_signal_count': shared_signal_count, + 'conflict_count': conflict_count, + 'summary': str(convergence_summary), + }, + 'negative_evidence': { + 'status': negative_status, + 'missing_layers': missing_layers, + 'rejected_windows': rejected_windows, + 'summary': str(negative_summary), + }, + 'claim_boundary': ( + 'Western support remains a KP support layer only; it cannot upgrade blocked KP timing truth ' + 'or replace Jyotish-first adjudication.' + ), + 'maturity_claim_status': maturity.get('claim_status'), + 'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True, + } + def get_kp_lords(degree: float) -> Dict: """ @@ -200,13 +367,18 @@ def get_house_significators(planet_positions: Dict, houses: List[Dict]) -> Dict: return results -def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict: +def calc_kp_analysis( + planet_positions: Dict, + asc_sign: str = 'Aries', + house_cusps: Optional[List[float]] = None, +) -> Dict: """ 完整KP分析(基于 diliprk/VedicAstro MIT 算法)。 Args: planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}} - asc_sign: 上升星座名称 + asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理) + house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。 Returns: 完整KP分析结果 @@ -232,12 +404,20 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict: 'kp_lords': kp_lords, } - # 2. 构建宫位信息(含KP lords) + explicit_cusps = house_cusps is not None + if explicit_cusps and len(house_cusps) != 12: + raise ValueError('house_cusps must contain exactly 12 longitudes') + + # 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。 houses = [] for house_num in range(1, 13): - sign_idx = (asc_sign_idx + house_num - 1) % 12 + if explicit_cusps: + house_center_degree = float(house_cusps[house_num - 1]) % 360.0 + sign_idx = int(house_center_degree // 30) % 12 + else: + sign_idx = (asc_sign_idx + house_num - 1) % 12 + house_center_degree = sign_idx * 30 + 15.0 sign_name = SIGNS[sign_idx] - house_center_degree = sign_idx * 30 + 15.0 # 宫位中点 kp_lords = get_kp_lords(house_center_degree) houses.append({ @@ -245,6 +425,7 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict: 'sign': sign_name, 'rasi_lord': SIGN_LORDS.get(sign_name, ''), 'kp_lords': kp_lords, + 'cusp_longitude': round(house_center_degree, 6), }) # 3. 计算significators @@ -255,13 +436,153 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict: 'method': 'KP (Krishnamurti Paddhati) 系统', 'version': '1.0', 'source': 'diliprk/VedicAstro MIT License', + 'maturity_profile': kp_maturity_profile(), + 'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy', 'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})} for pname, data in kp_planets.items()}, - 'houses': {h['house']: {'sign': h['sign'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})} + 'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})} for h in houses}, } +def build_kp_report_pack( + kp_analysis: Dict[str, Any], + timeline: Optional[Dict[str, Any]] = None, + *, + profile: Optional[Dict[str, Any]] = None, + western_support: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a report-ready KP evidence pack without upgrading prediction truth. + + This is a thin packaging layer for personal reports. It does not recompute + astrology, does not adjudicate event truth, and keeps the KP maturity profile + visible so downstream PL9+ renderers can show KP layers without presenting + blocked timing evidence as verified prediction. + """ + maturity = profile or kp_analysis.get('maturity_profile') or kp_maturity_profile() + planets = kp_analysis.get('planets') or {} + houses = kp_analysis.get('houses') or {} + periods = (timeline or {}).get('periods') or [] + runtime_layers = maturity.get('runtime_evidence_layers') or [] + runtime_layer_names = [str(layer.get('layer')) for layer in runtime_layers if isinstance(layer, dict) and layer.get('layer')] + closure_gap_matrix = maturity.get('closure_gap_matrix') or [ + {'gap_id': reason, 'truth_upgrade_allowed': False} + for reason in (maturity.get('remaining_hard_reasons') or []) + ] + closure_gap_ids = [str(row.get('gap_id')) for row in closure_gap_matrix if isinstance(row, dict) and row.get('gap_id')] + claim_boundary = maturity.get('claim_boundary') or 'KP timing truth remains blocked.' + western_support_surface = build_kp_western_support_surface( + western_support, + maturity_profile=maturity, + ) + status = 'verified_prediction_allowed' if maturity.get('truth_matrix_allowed') is True else 'observation_only' + must_not_claim = [ + 'kp_precise_event_timing_truth_closed', + 'kp_real_event_replay_completed', + 'kp_holdout_20_positive_80_negative_closed', + 'kp_dba_periods_are_verified_predictions', + ] + executive = ( + 'KP layers are report-ready as research evidence, but precise event timing remains blocked.' + if status == 'observation_only' + else 'KP timing evidence is marked verified by the maturity profile.' + ) + closed_parity_assets = maturity.get('closed_oracle_parity_assets') or {} + markdown = '\n'.join([ + '## KP Evidence Pack', + f"- status: `{status}`", + f"- claim_status: `{maturity.get('claim_status')}`", + f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`", + f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`", + f"- planet_count: `{len(planets)}`", + f"- house_count: `{len(houses)}`", + f"- dba_period_count: `{len(periods)}`", + f"- runtime_evidence_layer_count: `{len(runtime_layers)}`", + f"- runtime_evidence_layers: `{', '.join(runtime_layer_names)}`", + f"- closure_gap_count: `{len(closure_gap_matrix)}`", + f"- closure_gap_ids: `{', '.join(closure_gap_ids)}`", + f"- western_support_status: `{western_support_surface.get('status')}`", + f"- western_support_convergence_status: `{western_support_surface.get('convergence', {}).get('status')}`", + f"- western_support_negative_evidence_status: `{western_support_surface.get('negative_evidence', {}).get('status')}`", + f"- claim_boundary: {claim_boundary}", + ]) + return { + 'schema': 'jyotish.kp_report_pack.v1', + 'status': status, + 'summary': { + 'planet_count': len(planets), + 'house_count': len(houses), + 'dba_period_count': len(periods), + 'runtime_evidence_layer_count': len(runtime_layers), + 'closure_gap_count': len(closure_gap_matrix), + 'claim_status': maturity.get('claim_status'), + 'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True, + 'closed_oracle_parity_asset_count': len(closed_parity_assets), + 'western_support': { + 'status': western_support_surface.get('status'), + 'convergence_status': western_support_surface.get('convergence', {}).get('status'), + 'negative_evidence_status': western_support_surface.get('negative_evidence', {}).get('status'), + }, + }, + 'kp_analysis': kp_analysis, + 'kp_dba_timeline': timeline or {}, + 'maturity_profile': maturity, + 'report_sections': { + 'executive_summary': [executive], + 'thematic_narrative': [ + { + 'domain': 'kp_research_evidence', + 'paragraph': ( + 'KP star, sub-lord, significator, and DBA layers can enrich the personal report as ' + 'auditable research evidence. The report must keep the current blocked timing boundary visible.' + ), + 'evidence_label': status, + 'must_not_claim': list(must_not_claim), + } + ], + 'evidence_appendix': [ + { + 'segment_id': 'kp_maturity_profile', + 'status': maturity.get('claim_status'), + 'source_artifacts': maturity.get('source_artifacts') or {}, + 'runtime_evidence_layers': runtime_layers, + 'closed_oracle_parity_assets': closed_parity_assets, + 'closure_gap_matrix': closure_gap_matrix, + 'remaining_blockers': maturity.get('remaining_blockers') or [], + 'western_support': western_support_surface, + 'claim_boundary': claim_boundary, + } + ], + 'pdf_sections': [executive, claim_boundary], + }, + 'exports': { + 'markdown': markdown, + 'ai_evidence_bundle': { + 'schema': 'jyotish.kp_report_pack.ai_evidence.v1', + 'contains_private_pl9_text': False, + 'maturity_profile': maturity, + 'runtime_evidence_layers': runtime_layers, + 'closed_oracle_parity_assets': closed_parity_assets, + 'closure_gap_matrix': closure_gap_matrix, + 'western_support': western_support_surface, + 'summary': { + 'planet_count': len(planets), + 'house_count': len(houses), + 'dba_period_count': len(periods), + 'runtime_evidence_layer_count': len(runtime_layers), + 'closure_gap_count': len(closure_gap_matrix), + }, + }, + }, + 'audit': { + 'status': status, + 'must_not_claim': must_not_claim, + 'claim_boundary': claim_boundary, + 'normalization_boundary': 'packaging_only_no_astrological_recalculation', + }, + } + + def _kp_next_lords(start_lord: str) -> List[str]: idx = KP_LORDS.index(start_lord) return KP_LORDS[idx:] + KP_LORDS[:idx] diff --git a/scripts/muhurta.py b/scripts/muhurta.py index 3fff9fc5..92dfff0b 100644 --- a/scripts/muhurta.py +++ b/scripts/muhurta.py @@ -139,6 +139,163 @@ KARANA_QUALITY = { 'Naga': 'asubha', 'Kimstughna': 'subha' } +SIGNS = [ + 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', + 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces', +] + +PANCHAKA_BAD_REMAINDERS = { + 1: 'mrityu', + 2: 'agni', + 4: 'raja', + 6: 'chora', + 8: 'roga', +} +PANCHAKA_GOOD_REMAINDERS = {0, 3, 5, 7} +PANCHAKA_ACTIVITY_AVOIDANCE = { + 'business': {'raja'}, + 'education': {'raja'}, + 'travel': {'chora'}, + 'marriage': {'roga', 'mrityu'}, + 'medical': {'roga', 'mrityu'}, + 'house_building': {'raja', 'agni'}, + 'finance_lending_borrowing': {'raja'}, + 'finance_lending': {'raja'}, + 'finance_receiving': set(), +} +RAMAN_MONEY_LENDING_AVOID_NAKSHATRAS = { + 'Krittika', 'Magha', 'Mula', 'Shatabhisha', + 'Uttara Phalguni', 'Uttara Ashadha', 'Uttara Bhadrapada', + 'Punarvasu', +} +RAMAN_JOURNEY_GOOD_NAKSHATRAS = {'Anuradha', 'Mrigashira', 'Hasta'} +TARA_SEQUENCE = [ + 'janma', 'sampat', 'vipat', 'kshema', 'pratyak', + 'sadhana', 'naidhana', 'mitra', 'parama_mitra', +] +TARA_MEANINGS = { + 'janma': 'danger_to_body', + 'sampat': 'wealth_and_prosperity', + 'vipat': 'danger_loss_accident', + 'kshema': 'prosperity', + 'pratyak': 'obstacles', + 'sadhana': 'realisation_of_ambitions', + 'naidhana': 'danger', + 'mitra': 'good', + 'parama_mitra': 'very_favourable', +} +TARA_NEGATIVE_GHATIS = { + 'janma': 7, + 'vipat': 3, + 'pratyak': 8, + 'naidhana': 6, +} +TARA_HIGH_STAKES_AVOID = {'vipat', 'naidhana'} +JANMA_TARA_FAVOURABLE_ACTIVITIES = { + 'nuptials', 'sacrifice', 'first_feeding', 'agriculture', + 'upanayanam', 'coronation', 'buying_land', 'education', +} +JANMA_TARA_UNFAVOURABLE_ACTIVITIES = { + 'war', 'sexual_union', 'shaving', 'medical', 'travel', 'marriage', +} +CHANDRABALA_BAD_HOUSES = {6, 8, 12} +TITHI_GROUPS = { + 'nanda': {1, 6, 11}, + 'bhadra': {2, 7, 12}, + 'jaya': {3, 8, 13}, + 'riktha': {4, 9, 14}, + 'poorna': {5, 10, 15}, +} +SIDDHA_YOGA_WEEKDAY_RULES = { + 0: {'tithis': {1, 4, 6, 7, 12}, 'nakshatras': {'Pushya', 'Hasta', 'Uttara Phalguni', 'Uttara Ashadha', 'Mula', 'Shravana', 'Uttara Bhadrapada'}}, + 1: {'tithis': {2, 7, 12}, 'nakshatras': {'Rohini', 'Mrigashira', 'Punarvasu', 'Chitra', 'Shravana', 'Shatabhisha', 'Dhanishtha', 'Purva Bhadrapada'}}, + 2: {'tithis': None, 'nakshatras': {'Ashwini', 'Mrigashira', 'Chitra', 'Anuradha', 'Mula', 'Uttara Phalguni', 'Dhanishtha', 'Purva Bhadrapada'}}, + 3: {'groups': {'bhadra', 'jaya'}, 'nakshatras': {'Rohini', 'Mrigashira', 'Ardra', 'Uttara Phalguni', 'Uttara Ashadha', 'Anuradha'}}, + 4: {'tithis': {4, 5, 7, 9, 13, 14}, 'nakshatras': {'Magha', 'Pushya', 'Punarvasu', 'Swati', 'Purva Ashadha', 'Purva Bhadrapada', 'Revati', 'Ashwini'}}, + 5: {'groups': {'nanda', 'bhadra'}, 'nakshatras': {'Ashwini', 'Bharani', 'Ardra', 'Uttara Phalguni', 'Chitra', 'Swati', 'Purva Ashadha', 'Revati'}}, + 6: {'groups': {'bhadra', 'riktha'}, 'nakshatras': {'Swati', 'Rohini', 'Vishakha', 'Anuradha', 'Dhanishtha', 'Shatabhisha'}}, +} +SIDDHA_YOGA_TITHI_GROUP_RULES = { + 5: {'weekday': 5, 'group': 'nanda'}, + 3: {'weekday': 3, 'group': 'bhadra'}, + 2: {'weekday': 2, 'group': 'jaya'}, + 6: {'weekday': 6, 'group': 'riktha'}, + 4: {'weekday': 4, 'group': 'poorna'}, +} +AMRITA_SIDDHA_NAKSHATRA_BY_WEEKDAY = { + 0: 'Hasta', + 1: 'Shravana', + 2: 'Ashwini', + 3: 'Anuradha', + 4: 'Pushya', + 5: 'Revati', + 6: 'Rohini', +} +DURMUHURTHA_DAY_NAMES = [ + 'Rudra', 'Ahi', 'Mitra', 'Pitri', 'Vasu', 'Vara', 'Vishwedeva', + 'Vidhi', 'Sathamukhi', 'Puruhuta', 'Vahni', 'Naktanchara', + 'Varuna', 'Aryama', 'Bhaga', +] +DURMUHURTHA_NIGHT_NAMES = [ + 'Girisa', 'Ajipada', 'Ahirbudhnya', 'Pusha', 'Aswi', 'Yama', + 'Agni', 'Vidhatru', 'Chanda', 'Aditi', 'Jeeva', 'Vishnu', + 'Yumigadyuti', 'Thyasthur', 'Samdram', +] +DURMUHURTHA_GENERAL_BAD_DAY = {1, 2, 4, 10, 11, 12, 15} +DURMUHURTHA_GENERAL_BAD_NIGHT = {1, 2, 6, 7} +DURMUHURTHA_WEEKDAY_BAD_DAY = { + 0: {14}, # Sunday + 1: {8, 12}, # Monday + 2: {4, 11}, # Tuesday + 3: {8}, # Wednesday, Abhijit/Vidhi + 4: {12, 13}, # Thursday + 5: {4, 8}, # Friday + 6: {1, 2}, # Saturday +} +BENEFIC_PLANETS = {'Jupiter', 'Venus', 'Mercury'} +MALEFIC_PLANETS = {'Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu'} +GRAHA_DRISHTI_OFFSETS = { + 'Sun': {7}, + 'Moon': {7}, + 'Mercury': {7}, + 'Venus': {7}, + 'Mars': {4, 7, 8}, + 'Jupiter': {5, 7, 9}, + 'Saturn': {3, 7, 10}, +} +EXALTATION_SIGNS = { + 'Sun': 'Aries', + 'Moon': 'Taurus', + 'Mars': 'Capricorn', + 'Mercury': 'Virgo', + 'Jupiter': 'Cancer', + 'Venus': 'Pisces', + 'Saturn': 'Libra', +} +SIGN_LORDS = { + 'Aries': 'Mars', + 'Taurus': 'Venus', + 'Gemini': 'Mercury', + 'Cancer': 'Moon', + 'Leo': 'Sun', + 'Virgo': 'Mercury', + 'Libra': 'Venus', + 'Scorpio': 'Mars', + 'Sagittarius': 'Jupiter', + 'Capricorn': 'Saturn', + 'Aquarius': 'Saturn', + 'Pisces': 'Jupiter', +} +OWN_SIGNS = { + 'Sun': {'Leo'}, + 'Moon': {'Cancer'}, + 'Mars': {'Aries', 'Scorpio'}, + 'Mercury': {'Gemini', 'Virgo'}, + 'Jupiter': {'Sagittarius', 'Pisces'}, + 'Venus': {'Taurus', 'Libra'}, + 'Saturn': {'Capricorn', 'Aquarius'}, +} + RAHU_KALA_SEGMENTS = { 0: 8, # Sunday 1: 2, # Monday @@ -205,7 +362,7 @@ CHOGHADIYA_QUALITY = { def calc_tithi(sun_lon: float, moon_lon: float) -> Dict: """计算 Tithi(月相日)。 - sun_lon, moon_lon: 恒星黄经(Lahiri,0-360) + sun_lon, moon_lon: 恒星黄经(由调用方 ayanamsa 决定,0-360) 返回: tithi_num(1-30), paksha, name, quality """ diff = (moon_lon - sun_lon) % 360 @@ -317,6 +474,241 @@ def calc_vara(weekday: int) -> Dict: } +def _coerce_1_based_index(value: object, names: List[str], field_name: str) -> int: + if isinstance(value, str): + stripped = value.strip() + if stripped in names: + return names.index(stripped) + 1 + try: + value = int(stripped) + except ValueError as exc: + raise ValueError(f'{field_name} must be a known name or 1-based index') from exc + if isinstance(value, (int, float)): + idx = int(value) + if 0 <= idx < len(names): + return idx + 1 + if 1 <= idx <= len(names): + return idx + raise ValueError(f'{field_name} must be a known name, 0-based index, or 1-based index') + + +def calc_panchaka( + tithi_num: int, + weekday: int, + nakshatra: object, + lagna: object, + activity: Optional[str] = None, +) -> Dict: + """Calculate Raman-style Panchaka remainder and activity-specific avoidance. + + Formula source packet: Raman Muhurtha page rule p16-p17. + Terms are counted 1-based: tithi, weekday, nakshatra, and lagna; divide by 9. + """ + tithi_index = int(tithi_num) + if not (1 <= tithi_index <= 30): + raise ValueError('tithi_num must be 1..30') + weekday_index = int(weekday) % 7 + 1 + nakshatra_index = _coerce_1_based_index(nakshatra, NAKSHATRAS, 'nakshatra') + lagna_index = _coerce_1_based_index(lagna, SIGNS, 'lagna') + total = tithi_index + weekday_index + nakshatra_index + lagna_index + remainder = total % 9 + dosha = PANCHAKA_BAD_REMAINDERS.get(remainder) + quality = 'good' if remainder in PANCHAKA_GOOD_REMAINDERS else 'bad' + avoided_for_activity = False + if activity: + avoided_for_activity = bool(dosha and dosha in PANCHAKA_ACTIVITY_AVOIDANCE.get(activity, set())) + return { + 'formula_profile': 'raman_muhurtha_p16_p17', + 'terms': { + 'tithi_number': tithi_index, + 'weekday_number': weekday_index, + 'nakshatra_number': nakshatra_index, + 'lagna_number': lagna_index, + }, + 'sum': total, + 'divisor': 9, + 'remainder': remainder, + 'dosha': dosha, + 'quality': quality, + 'activity': activity, + 'avoided_for_activity': avoided_for_activity, + 'source_rule_ids': [ + 'raman_muhurtha_panchaka_formula', + 'raman_muhurtha_activity_panchaka_exceptions', + ], + } + + +def build_tarabala_chandrabala_input_contract( + *, + birth_nakshatra: Optional[object] = None, + current_nakshatra: Optional[object] = None, + birth_moon_sign: Optional[object] = None, + current_moon_sign: Optional[object] = None, +) -> Dict: + """Validate native-dependent inputs needed by Tarabala and Chandrabala.""" + required = { + 'birth_nakshatra': birth_nakshatra, + 'current_nakshatra': current_nakshatra, + 'birth_moon_sign': birth_moon_sign, + 'current_moon_sign': current_moon_sign, + } + missing = [key for key, value in required.items() if value is None or value == ''] + status = 'ready' if not missing else 'input_required' + return { + 'status': status, + 'required_fields': list(required), + 'missing_fields': missing, + 'inputs': required, + 'source_rule_ids': [ + 'raman_muhurtha_tarabala_chandrabala_panchaka_gate', + 'raman_muhurtha_vipat_naidhana_avoidance', + ], + 'boundary': 'Tarabala/Chandrabala cannot be scored without native birth Nakshatra and Moon sign.', + } + + +def calc_tarabala( + birth_nakshatra: object, + current_nakshatra: object, + activity: Optional[str] = None, +) -> Dict: + """Calculate ninefold Tara relationship from birth Nakshatra to current Nakshatra.""" + birth_index = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') + current_index = _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') + distance = (current_index - birth_index) % 27 + 1 + tara = TARA_SEQUENCE[(distance - 1) % 9] + cycle = ((distance - 1) // 9) + 1 + activity = activity or None + janma_exception = None + if tara == 'janma' and activity in JANMA_TARA_FAVOURABLE_ACTIVITIES: + janma_exception = 'favourable_for_activity' + elif tara == 'janma' and activity in JANMA_TARA_UNFAVOURABLE_ACTIVITIES: + janma_exception = 'unfavourable_for_activity' + generally_unfavourable = tara in {'janma', 'vipat', 'pratyak', 'naidhana'} + high_stakes_avoid = tara in TARA_HIGH_STAKES_AVOID and activity in {'marriage', 'travel', 'business'} + negative_ghatis = TARA_NEGATIVE_GHATIS.get(tara, 0) + if janma_exception == 'favourable_for_activity': + quality = 'activity_exception_usable' + elif tara in {'sampat', 'kshema', 'sadhana', 'mitra', 'parama_mitra'}: + quality = 'good' + elif cycle == 3 and tara not in TARA_HIGH_STAKES_AVOID: + quality = 'mildly_unfavourable' + else: + quality = 'bad' + return { + 'formula_profile': 'raman_muhurtha_p14_p17', + 'birth_nakshatra_number': birth_index, + 'current_nakshatra_number': current_index, + 'distance_inclusive': distance, + 'cycle': cycle, + 'tara': tara, + 'meaning': TARA_MEANINGS[tara], + 'quality': quality, + 'activity': activity, + 'generally_unfavourable': generally_unfavourable, + 'avoided_for_high_stakes_activity': high_stakes_avoid or janma_exception == 'unfavourable_for_activity', + 'negative_part_ghatis': negative_ghatis, + 'negative_part_minutes': negative_ghatis * 24, + 'negative_part_avoidance': f'avoid_first_{negative_ghatis}_ghatis' if negative_ghatis else None, + 'third_cycle_evil_reduced': cycle == 3 and generally_unfavourable, + 'janma_activity_exception': janma_exception, + 'source_rule_ids': [ + 'raman_muhurtha_tarabala_ninefold_remainders', + 'raman_muhurtha_tarabala_cycle_weighting', + 'raman_muhurtha_tarabala_negative_ghatis', + 'raman_muhurtha_janma_tara_activity_exceptions', + ], + } + + +def calc_chandrabala( + birth_moon_sign: object, + current_moon_sign: object, +) -> Dict: + """Calculate Raman Chandrabala avoidance by Moon-sign distance.""" + birth_index = _coerce_1_based_index(birth_moon_sign, SIGNS, 'birth_moon_sign') + current_index = _coerce_1_based_index(current_moon_sign, SIGNS, 'current_moon_sign') + distance = (current_index - birth_index) % 12 + 1 + avoided = distance in CHANDRABALA_BAD_HOUSES + return { + 'formula_profile': 'raman_muhurtha_p15_chandrabala', + 'birth_moon_sign_number': birth_index, + 'current_moon_sign_number': current_index, + 'distance_inclusive': distance, + 'bad_houses_from_birth_moon': sorted(CHANDRABALA_BAD_HOUSES), + 'status': 'evaluated', + 'quality': 'bad' if avoided else 'usable', + 'avoided': avoided, + 'source_rule_ids': ['raman_muhurtha_chandrabala_6_8_12_avoidance'], + } + + +def _tithi_in_paksha_number(tithi_num: int) -> int: + tithi = int(tithi_num) + if not (1 <= tithi <= 30): + raise ValueError('tithi_num must be 1..30') + return ((tithi - 1) % 15) + 1 + + +def _tithi_group(tithi_num: int) -> str: + paksha_tithi = _tithi_in_paksha_number(tithi_num) + for group, values in TITHI_GROUPS.items(): + if paksha_tithi in values: + return group + raise ValueError('unable to classify tithi group') + + +def calc_siddha_yoga( + weekday: int, + tithi_num: int, + nakshatra: object, +) -> Dict: + """Detect Raman p25-p26 Siddha and Amrita Siddha Yoga combinations.""" + weekday_idx = int(weekday) % 7 + paksha_tithi = _tithi_in_paksha_number(tithi_num) + group = _tithi_group(tithi_num) + nakshatra_index = _coerce_1_based_index(nakshatra, NAKSHATRAS, 'nakshatra') + nakshatra_name = NAKSHATRAS[nakshatra_index - 1] + hits = [] + + weekday_rule = SIDDHA_YOGA_WEEKDAY_RULES.get(weekday_idx, {}) + tithi_match = True + if weekday_rule.get('tithis') is not None: + tithi_match = paksha_tithi in weekday_rule.get('tithis', set()) + if weekday_rule.get('groups') is not None: + tithi_match = group in weekday_rule.get('groups', set()) + if tithi_match and nakshatra_name in weekday_rule.get('nakshatras', set()): + hits.append({'rule_id': 'weekday_tithi_nakshatra_siddha_yoga', 'quality': 'siddha', 'source_pages': [25, 26]}) + + tithi_group_rule = SIDDHA_YOGA_TITHI_GROUP_RULES.get(weekday_idx) + if tithi_group_rule and group == tithi_group_rule['group']: + hits.append({'rule_id': 'weekday_tithi_group_siddha_yoga', 'quality': 'siddha', 'source_pages': [26]}) + + if AMRITA_SIDDHA_NAKSHATRA_BY_WEEKDAY.get(weekday_idx) == nakshatra_name: + hits.append({'rule_id': 'amrita_siddha_yoga', 'quality': 'amrita_siddha', 'source_pages': [26]}) + + return { + 'formula_profile': 'raman_muhurtha_p25_p26_siddha_yoga', + 'weekday_number': weekday_idx, + 'weekday_name': VARA_LORDS[weekday_idx][0], + 'tithi_number': int(tithi_num), + 'tithi_in_paksha': paksha_tithi, + 'tithi_group': group, + 'nakshatra_number': nakshatra_index, + 'nakshatra': nakshatra_name, + 'status': 'siddha_yoga_present' if hits else 'not_present', + 'hits': hits, + 'quality': 'amrita_siddha' if any(hit['quality'] == 'amrita_siddha' for hit in hits) else 'siddha' if hits else 'none', + 'source_rule_ids': [ + 'raman_muhurtha_siddha_yoga_weekday_tithi_nakshatra', + 'raman_muhurtha_siddha_yoga_weekday_tithi_group', + 'raman_muhurtha_amrita_siddha_yoga_weekday_nakshatra', + ], + 'boundary': 'Special yoga support only; Lagna and broader Muhurta gates still apply.', + } + + def calc_hora(weekday: int, hour_from_sunrise: float) -> Dict: """计算当前 Hora(日出后的小时序号)。 @@ -572,6 +964,74 @@ def calc_choghadiya_windows( } +def calc_durmuhurtha_windows( + weekday: int, + sunrise: str = '06:00', + sunset: str = '18:00', + activity: Optional[str] = None, +) -> Dict: + """Calculate Raman p22 Durmuhurtha day/night segments from local sunrise/sunset.""" + sunrise_min = _clock_minutes(sunrise, '06:00') + sunset_min = _clock_minutes(sunset, '18:00') + next_sunrise_min = sunrise_min + 24 * 60 + weekday = weekday % 7 + + def build_rows(names: List[str], windows: List[Tuple[float, float]], general_bad: set[int], phase: str) -> List[Dict]: + rows = [] + weekday_bad = DURMUHURTHA_WEEKDAY_BAD_DAY.get(weekday, set()) if phase == 'day' else set() + for idx, (name, (start, end)) in enumerate(zip(names, windows), start=1): + reasons = [] + if idx in general_bad: + reasons.append('general_bad') + if idx in weekday_bad: + reasons.append('weekday_bad') + activity_reasons = [] + if activity == 'marriage' and idx in weekday_bad: + activity_reasons.append('marriage_weekday_durmuhurtha_avoid') + rows.append({ + 'index': idx, + 'name': name, + 'phase': phase, + 'quality': 'inauspicious' if reasons or activity_reasons else 'usable', + 'bad_reasons': reasons, + 'activity_reasons': activity_reasons, + **_window_from_minutes(start, end), + }) + return rows + + day_rows = build_rows( + DURMUHURTHA_DAY_NAMES, + _split_window(sunrise_min, sunset_min, 15), + DURMUHURTHA_GENERAL_BAD_DAY, + 'day', + ) + night_rows = build_rows( + DURMUHURTHA_NIGHT_NAMES, + _split_window(sunset_min, next_sunrise_min, 15), + DURMUHURTHA_GENERAL_BAD_NIGHT, + 'night', + ) + return { + 'policy': 'raman_muhurtha_p22_day_night_15_segments', + 'activity': activity, + 'source_rule_ids': [ + 'raman_muhurtha_durmuhurtha_unit', + 'raman_muhurtha_durmuhurtha_day_night_named_segments', + 'raman_muhurtha_marriage_weekday_durmuhurtha_page_22', + ], + 'weekday': weekday, + 'sunrise': _minutes_to_hhmm(sunrise_min), + 'sunset': _minutes_to_hhmm(sunset_min), + 'day': day_rows, + 'night': night_rows, + 'blocked_windows': [row for row in day_rows + night_rows if row['quality'] == 'inauspicious'], + 'activity_blocked_windows': [ + row for row in day_rows + night_rows + if row.get('activity_reasons') + ], + } + + def calc_hora_windows( weekday: int, sunrise: str = '06:00', @@ -600,6 +1060,1071 @@ def calc_hora_windows( } +def _planet_payload(chart: Dict, planet: str) -> Dict: + value = (chart.get('planets') or {}).get(planet) or {} + return value if isinstance(value, dict) else {} + + +def _chart_lagna(chart: Dict) -> Optional[str]: + value = chart.get('lagna') or chart.get('ascendant') or chart.get('Ascendant') + if isinstance(value, dict): + value = value.get('sign') or value.get('rashi') or value.get('name') + return str(value) if value in SIGNS else None + + +def normalize_muhurta_election_chart(chart: Dict) -> Dict: + """Normalize the minimal election-chart shape consumed by Raman Muhurta gates.""" + planets = chart.get('planets') or chart.get('bodies') or {} + if not isinstance(planets, dict): + planets = {} + navamsa = ( + chart.get('navamsa') + or chart.get('D9') + or chart.get('D9_Navamsa') + or (chart.get('varga_full') or {}).get('D9_Navamsa') + or {} + ) + if not isinstance(navamsa, dict): + navamsa = {} + normalized = { + 'lagna': _chart_lagna(chart), + 'planets': planets, + 'navamsa': { + 'lagna': _chart_lagna(navamsa), + 'planets': navamsa.get('planets') if isinstance(navamsa.get('planets'), dict) else { + key: value for key, value in navamsa.items() if isinstance(value, dict) + }, + }, + 'input_contract': { + 'schema': 'muhurta.election_chart_input.v1', + 'required_for_basic_dosha': ['lagna', 'planets.*.house', 'planets.*.sign'], + 'required_for_dignity': ['planets.*.sign or planets.*.dignity'], + 'required_for_navamsa_strength': ['navamsa.lagna', 'navamsa.planets.*.house/sign'], + }, + } + missing = [] + if not normalized['lagna']: + missing.append('lagna') + if not normalized['planets']: + missing.append('planets') + if not normalized['navamsa']['lagna']: + missing.append('navamsa.lagna') + if not normalized['navamsa']['planets']: + missing.append('navamsa.planets') + normalized['input_contract']['missing_fields'] = missing + normalized['input_contract']['status'] = 'ready' if not missing else 'partial' + return normalized + + +def _planet_house(chart: Dict, planet: str) -> Optional[int]: + value = _planet_payload(chart, planet).get('house') + if value is None: + return None + try: + house = int(value) + except (TypeError, ValueError): + return None + return house if 1 <= house <= 12 else None + + +def _planet_sign(chart: Dict, planet: str) -> Optional[str]: + sign = _planet_payload(chart, planet).get('sign') + return str(sign) if sign in SIGNS else None + + +def _planet_dignity(chart: Dict, planet: str) -> Optional[str]: + dignity = _planet_payload(chart, planet).get('dignity') + if dignity: + return str(dignity) + sign = _planet_sign(chart, planet) + if sign and EXALTATION_SIGNS.get(planet) == sign: + return 'exalted' + return None + + +def _house_from_lagna(lagna: str, sign: str) -> Optional[int]: + if lagna not in SIGNS or sign not in SIGNS: + return None + return (SIGNS.index(sign) - SIGNS.index(lagna)) % 12 + 1 + + +def _as_sign(value: object) -> Optional[str]: + if isinstance(value, dict): + value = value.get('sign') or value.get('rashi') or value.get('name') + return str(value) if value in SIGNS else None + + +def _planet_aspects_house(chart: Dict, planet: str, target_house: int) -> bool: + source_house = _planet_house_or_sign_house(chart, planet) + if source_house is None: + return False + distance = (target_house - source_house) % 12 + 1 + return distance in GRAHA_DRISHTI_OFFSETS.get(planet, {7}) + + +def _planet_house_or_sign_house(chart: Dict, planet: str) -> Optional[int]: + house = _planet_house(chart, planet) + if house is not None: + return house + lagna = _chart_lagna(chart) + sign = _planet_sign(chart, planet) + if lagna and sign: + return _house_from_lagna(lagna, sign) + return None + + +def _planets_in_house(chart: Dict, house: int) -> List[str]: + return [ + planet for planet in (chart.get('planets') or {}) + if _planet_house_or_sign_house(chart, planet) == house + ] + + +def build_muhurta_shadvarga_strength_contract(election_chart: Dict) -> Dict: + """Gate Raman's shadvarga-strength clause without inventing a new strength engine.""" + strength = ( + election_chart.get('shadvarga_strength') + or election_chart.get('varga_strength') + or election_chart.get('vimsopaka') + or {} + ) + missing = [] + if not isinstance(strength, dict) or not strength: + missing.append('shadvarga_strength_or_varga_strength') + return { + 'schema': 'muhurta.shadvarga_strength_contract.v1', + 'status': 'ready' if not missing else 'blocked', + 'missing_fields': missing, + 'strength_payload': strength if isinstance(strength, dict) else {}, + 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], + 'boundary': 'Strength values must come from the project strength/varga layer; Muhurta does not synthesize them ad hoc.', + } + + +def _extract_strength_score(payload: object) -> Optional[float]: + if isinstance(payload, (int, float)): + return float(payload) + if not isinstance(payload, dict): + return None + for key in ('score', 'total', 'value', 'strength', 'vimsopaka_score'): + value = payload.get(key) + if isinstance(value, (int, float)): + return float(value) + return None + + +def evaluate_muhurta_shadvarga_malefic_strength( + election_chart: Dict, + *, + threshold: float = 6.0, +) -> Dict: + """Replay Raman's strong-malefic-in-shadvarga warning from an existing strength payload.""" + contract = build_muhurta_shadvarga_strength_contract(election_chart) + if contract.get('status') == 'blocked': + return { + 'schema': 'muhurta.shadvarga_malefic_strength_replay.v1', + 'status': 'blocked', + 'threshold': threshold, + 'strong_malefics': [], + 'blocked': [{'rule_id': 'avoid_strong_malefics_in_shadvarga', 'missing_fields': contract.get('missing_fields')}], + 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], + 'boundary': contract['boundary'], + } + + strength = contract.get('strength_payload') or {} + strong_malefics = [] + blocked = [] + for planet in sorted(MALEFIC_PLANETS): + score = _extract_strength_score(strength.get(planet)) + if score is None: + blocked.append({'rule_id': 'malefic_shadvarga_strength_score', 'planet': planet, 'missing_fields': [f'shadvarga_strength.{planet}.score']}) + continue + if score >= threshold: + strong_malefics.append({ + 'rule_id': 'strong_malefic_in_shadvarga', + 'planet': planet, + 'score': score, + 'threshold': threshold, + 'severity': 10, + }) + return { + 'schema': 'muhurta.shadvarga_malefic_strength_replay.v1', + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'threshold': threshold, + 'strong_malefics': strong_malefics, + 'blocked': blocked, + 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], + 'boundary': 'This replays the Raman warning from supplied strength values; the strength engine remains upstream.', + } + + +def evaluate_muhurta_strength_supported_cancellations( + election_chart: Dict, + *, + weekday: Optional[object] = None, + threshold: float = 6.0, +) -> Dict: + """Replay strength-dependent Raman cancellation clauses from supplied strength payload.""" + normalized = normalize_muhurta_election_chart(election_chart) + contract = build_muhurta_shadvarga_strength_contract(election_chart) + if contract.get('status') == 'blocked': + return { + 'schema': 'muhurta.strength_supported_cancellation_replay.v1', + 'status': 'blocked', + 'threshold': threshold, + 'hits': [], + 'blocked': [{'rule_id': 'strength_supported_cancellations', 'missing_fields': contract.get('missing_fields')}], + 'source_rule_ids': ['fortified_angles', 'strong_weekday_lord'], + 'boundary': contract['boundary'], + } + + strength = contract.get('strength_payload') or {} + chart_view = {'lagna': normalized.get('lagna'), 'planets': normalized.get('planets') or {}} + hits = [] + blocked = [] + for planet in sorted(chart_view['planets']): + house = _planet_house_or_sign_house(chart_view, planet) + score = _extract_strength_score(strength.get(planet)) + if house in {1, 4, 7, 10}: + if score is None: + blocked.append({'rule_id': 'fortified_angles', 'planet': planet, 'missing_fields': [f'shadvarga_strength.{planet}.score']}) + elif score >= threshold: + hits.append({'rule_id': 'fortified_angles', 'planet': planet, 'house': house, 'score': score, 'priority': 7}) + + if weekday is None: + blocked.append({'rule_id': 'strong_weekday_lord', 'missing_fields': ['weekday']}) + else: + try: + weekday_idx = int(weekday) % 7 + weekday_lord = VARA_LORDS[weekday_idx][1] + weekday_score = _extract_strength_score(strength.get(weekday_lord)) + if weekday_score is None: + blocked.append({'rule_id': 'strong_weekday_lord', 'planet': weekday_lord, 'missing_fields': [f'shadvarga_strength.{weekday_lord}.score']}) + elif weekday_score >= threshold: + hits.append({'rule_id': 'strong_weekday_lord', 'planet': weekday_lord, 'weekday': VARA_LORDS[weekday_idx][0], 'score': weekday_score, 'priority': 6}) + except (TypeError, ValueError): + blocked.append({'rule_id': 'strong_weekday_lord', 'missing_fields': ['valid_weekday']}) + + return { + 'schema': 'muhurta.strength_supported_cancellation_replay.v1', + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'threshold': threshold, + 'hits': sorted(hits, key=lambda row: row.get('priority', 0), reverse=True), + 'blocked': blocked, + 'source_rule_ids': ['fortified_angles', 'strong_weekday_lord'], + 'boundary': 'This replays strength-supported cancellation candidates only; it is not a final Muhurta verdict.', + } + + +def evaluate_muhurta_lagna_navamsa_aspect_exchange(election_chart: Dict) -> Dict: + """Evaluate the Raman Lagna/Navamsa lord aspect-exchange clause with whole-sign graha drishti.""" + normalized = normalize_muhurta_election_chart(election_chart) + lagna = normalized['lagna'] + navamsa = normalized['navamsa'] + checks = [] + blocked = [] + if not lagna: + blocked.append({'rule_id': 'lagna_aspect_exchange', 'missing_fields': ['lagna']}) + if not navamsa.get('lagna'): + blocked.append({'rule_id': 'navamsa_aspect_exchange', 'missing_fields': ['navamsa.lagna']}) + if lagna: + lord = SIGN_LORDS[lagna] + chart_view = {'lagna': lagna, 'planets': normalized['planets']} + checks.append({ + 'rule_id': 'lagna_lord_aspects_lagna', + 'lord': lord, + 'target_house': 1, + 'status': 'supportive' if _planet_aspects_house(chart_view, lord, 1) else 'not_confirmed', + }) + if navamsa.get('lagna'): + nav_lagna = navamsa['lagna'] + nav_lord = SIGN_LORDS[nav_lagna] + nav_view = {'lagna': nav_lagna, 'planets': navamsa.get('planets') or {}} + checks.append({ + 'rule_id': 'navamsa_lord_aspects_navamsa_lagna', + 'lord': nav_lord, + 'target_house': 1, + 'status': 'supportive' if _planet_aspects_house(nav_view, nav_lord, 1) else 'not_confirmed', + }) + return { + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'aspect_policy': 'whole_sign_graha_drishti', + 'source_rule_ids': ['raman_muhurtha_lagna_navamsa_strength'], + 'checks': checks, + 'blocked': blocked, + 'supportive_count': sum(1 for row in checks if row['status'] == 'supportive'), + 'boundary': 'Only the lord-to-lagna aspect clause is replayed here; malefic aspect cancellation is reported separately.', + } + + +def evaluate_muhurta_jupiter_navamsa_aspect_protection(election_chart: Dict) -> Dict: + """Replay the Jupiter Lagna/Navamsa/aspect protection clause as separate auditable hits.""" + normalized = normalize_muhurta_election_chart(election_chart) + chart_view = {'lagna': normalized.get('lagna'), 'planets': normalized.get('planets') or {}} + navamsa = normalized.get('navamsa') or {} + nav_view = {'lagna': navamsa.get('lagna'), 'planets': navamsa.get('planets') or {}} + hits = [] + blocked = [] + jupiter_house = _planet_house_or_sign_house(chart_view, 'Jupiter') + if jupiter_house is None: + blocked.append({'rule_id': 'jupiter_position', 'missing_fields': ['planets.Jupiter.house_or_sign']}) + else: + if jupiter_house == 1: + hits.append({'rule_id': 'jupiter_dispels_lagna_evil', 'scope': 'lagna', 'priority': 9}) + if _planet_aspects_house(chart_view, 'Jupiter', 1): + hits.append({'rule_id': 'jupiter_aspects_lagna', 'scope': 'lagna_aspect', 'priority': 8}) + nav_jupiter_house = _planet_house_or_sign_house(nav_view, 'Jupiter') + if nav_jupiter_house is None: + blocked.append({'rule_id': 'jupiter_navamsa_position', 'missing_fields': ['navamsa.planets.Jupiter.house_or_sign']}) + else: + if nav_jupiter_house == 1: + hits.append({'rule_id': 'jupiter_dispels_navamsa_evil', 'scope': 'navamsa', 'priority': 9}) + if _planet_aspects_house(nav_view, 'Jupiter', 1): + hits.append({'rule_id': 'jupiter_aspects_navamsa_lagna', 'scope': 'navamsa_aspect', 'priority': 8}) + malefic_aspect_hits = [] + for planet in MALEFIC_PLANETS: + if _planet_aspects_house(chart_view, planet, 1): + malefic_aspect_hits.append({'planet': planet, 'target': 'lagna'}) + if malefic_aspect_hits and (jupiter_house in {1, 4, 7, 10} or any(hit['scope'].startswith('lagna') for hit in hits)): + hits.append({'rule_id': 'jupiter_offsets_malefic_lagna_aspects', 'scope': 'malefic_aspects', 'priority': 7, 'malefic_aspects': malefic_aspect_hits}) + return { + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'aspect_policy': 'whole_sign_graha_drishti', + 'source_rule_ids': ['jupiter_lagna_navamsa_malefic_aspect_protection'], + 'hits': sorted(hits, key=lambda row: row.get('priority', 0), reverse=True), + 'blocked': blocked, + 'quality': 'protective' if hits else 'unconfirmed', + 'boundary': 'This is a clause replay packet, not a final cancellation verdict.', + } + + +def evaluate_muhurta_lagna_navamsa_strength(election_chart: Dict) -> Dict: + """Evaluate Raman p21-p22 Lagna/Navamsa strength clauses as a guarded packet.""" + normalized = normalize_muhurta_election_chart(election_chart) + lagna = normalized['lagna'] + navamsa = normalized['navamsa'] + checks = [] + blocked = [] + aspect_exchange = evaluate_muhurta_lagna_navamsa_aspect_exchange(election_chart) + if not lagna: + blocked.append({'rule_id': 'lagna_strength', 'missing_fields': ['lagna']}) + else: + lord = SIGN_LORDS[lagna] + lord_house = _planet_house_or_sign_house({'lagna': lagna, 'planets': normalized['planets']}, lord) + checks.append({ + 'rule_id': 'lagna_occupied_or_supported_by_lagna_lord', + 'source_pages': [21, 22], + 'lagna': lagna, + 'lord': lord, + 'lord_house': lord_house, + 'status': 'supportive' if lord_house == 1 else 'not_confirmed' if lord_house else 'blocked', + }) + if lord_house is None: + blocked.append({'rule_id': 'lagna_lord_house', 'missing_fields': [f'planets.{lord}.house_or_sign']}) + + nav_lagna = navamsa.get('lagna') + nav_planets = navamsa.get('planets') or {} + if not nav_lagna: + blocked.append({'rule_id': 'navamsa_lagna_strength', 'missing_fields': ['navamsa.lagna']}) + else: + nav_lord = SIGN_LORDS[nav_lagna] + nav_lord_house = _planet_house_or_sign_house({'lagna': nav_lagna, 'planets': nav_planets}, nav_lord) + checks.append({ + 'rule_id': 'navamsa_lagna_occupied_or_supported_by_navamsa_lord', + 'source_pages': [21, 22], + 'navamsa_lagna': nav_lagna, + 'lord': nav_lord, + 'lord_house': nav_lord_house, + 'status': 'supportive' if nav_lord_house == 1 else 'not_confirmed' if nav_lord_house else 'blocked', + }) + if nav_lord_house is None: + blocked.append({'rule_id': 'navamsa_lagna_lord_house', 'missing_fields': [f'navamsa.planets.{nav_lord}.house_or_sign']}) + + supportive = sum(1 for row in checks if row['status'] == 'supportive') + return { + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'source_rule_ids': ['raman_muhurtha_lagna_navamsa_strength'], + 'checks': checks, + 'blocked': blocked, + 'supportive_count': supportive, + 'quality': 'strong' if supportive >= 2 and not blocked else 'partial' if supportive else 'unconfirmed', + 'aspect_exchange': aspect_exchange, + 'boundary': 'Aspect-exchange clauses use whole-sign graha drishti and remain a supporting clause, not final score.', + } + + +def evaluate_muhurta_election_chart_dosha( + election_chart: Dict, + *, + activity: str = 'business', + native_lagna: Optional[object] = None, + weekday: Optional[object] = None, +) -> Dict: + """Evaluate Raman p23-p25 election-chart dosha/cancellation hooks. + + This is a structural gate, not a final Muhurta verdict. + """ + doshas = [] + cancellations = [] + blocked = [] + normalized = normalize_muhurta_election_chart(election_chart) + planets = normalized.get('planets') or {} + if not isinstance(planets, dict) or not planets: + return { + 'status': 'blocked', + 'missing_fields': ['election_chart.planets'], + 'doshas': [], + 'cancellations': [], + 'normalized_chart': normalized, + 'source_rule_ids': [ + 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', + 'raman_muhurtha_neutralization_rules', + ], + } + + chart_view = {'lagna': normalized.get('lagna'), 'planets': planets} + navamsa_strength = evaluate_muhurta_lagna_navamsa_strength(election_chart) + shadvarga_contract = build_muhurta_shadvarga_strength_contract(election_chart) + shadvarga_malefic_strength = evaluate_muhurta_shadvarga_malefic_strength(election_chart) + strength_cancellations = evaluate_muhurta_strength_supported_cancellations(election_chart, weekday=weekday) + jupiter_protection = evaluate_muhurta_jupiter_navamsa_aspect_protection(election_chart) + venus_house = _planet_house_or_sign_house(chart_view, 'Venus') + mars_house = _planet_house_or_sign_house(chart_view, 'Mars') + if activity == 'marriage' and venus_house == 6: + doshas.append({'rule_id': 'venus_6th_marriage_bhrigu', 'severity': 9, 'planet': 'Venus', 'house': 6}) + if activity == 'marriage' and mars_house == 8: + doshas.append({'rule_id': 'mars_8th_marriage_kuja', 'severity': 9, 'planet': 'Mars', 'house': 8}) + if activity == 'marriage': + houses_by_malefic = { + planet: _planet_house_or_sign_house(chart_view, planet) + for planet in MALEFIC_PLANETS + } + karthari_malefics = [ + {'planet': planet, 'house': house} + for planet, house in houses_by_malefic.items() + if house in {2, 12} + ] + if any(row['house'] == 2 for row in karthari_malefics) and any(row['house'] == 12 for row in karthari_malefics): + doshas.append({ + 'rule_id': 'raman_p21_marriage_karthari_dosha', + 'severity': 10, + 'malefics': karthari_malefics, + 'source_pages': [21], + }) + moon_house = _planet_house_or_sign_house(chart_view, 'Moon') + if moon_house in {6, 8, 12}: + doshas.append({ + 'rule_id': 'raman_p21_moon_6_8_12_from_lagna', + 'severity': 9, + 'planet': 'Moon', + 'house': moon_house, + 'source_pages': [21], + }) + elif moon_house is None: + blocked.append({'rule_id': 'raman_p21_moon_6_8_12_from_lagna', 'missing_fields': ['planets.Moon.house_or_sign']}) + moon_house_occupants = [ + planet for planet in _planets_in_house(chart_view, moon_house or 0) + if planet != 'Moon' + ] + if moon_house is not None and moon_house_occupants: + doshas.append({ + 'rule_id': 'raman_p21_marriage_sagraha_chandra_dosha', + 'severity': 9, + 'planet': 'Moon', + 'house': moon_house, + 'co_occupants': moon_house_occupants, + 'source_pages': [21], + }) + + if native_lagna is not None: + election_lagna = normalized.get('lagna') + try: + native_idx = _coerce_1_based_index(native_lagna, SIGNS, 'native_lagna') + election_idx = _coerce_1_based_index(election_lagna, SIGNS, 'election_lagna') + if (election_idx - native_idx) % 12 + 1 == 8: + doshas.append({'rule_id': 'ashtama_lagna_dosha', 'severity': 8, 'native_lagna': native_lagna, 'election_lagna': election_lagna}) + except ValueError: + blocked.append({'rule_id': 'ashtama_lagna_dosha', 'missing_fields': ['valid_native_lagna', 'valid_election_lagna']}) + else: + blocked.append({'rule_id': 'ashtama_lagna_dosha', 'missing_fields': ['native_lagna']}) + + for row in shadvarga_malefic_strength.get('strong_malefics') or []: + doshas.append(dict(row)) + + for planet in BENEFIC_PLANETS: + if _planet_house_or_sign_house(chart_view, planet) == 1: + cancellations.append({'rule_id': 'benefic_in_lagna', 'priority': 8, 'planet': planet}) + for planet, exalt_sign in EXALTATION_SIGNS.items(): + if _planet_house_or_sign_house(chart_view, planet) == 1 and _planet_sign(chart_view, planet) == exalt_sign: + cancellations.append({'rule_id': 'exalted_planet_in_lagna', 'priority': 10, 'planet': planet}) + + jupiter_house = _planet_house_or_sign_house(chart_view, 'Jupiter') + if jupiter_house == 1: + cancellations.append({'rule_id': 'jupiter_in_lagna_dispels_lagna_evil', 'priority': 9, 'planet': 'Jupiter'}) + if jupiter_house in {1, 4, 7, 10}: + cancellations.append({'rule_id': 'jupiter_kendra_protection', 'priority': 8, 'planet': 'Jupiter', 'house': jupiter_house}) + for hit in jupiter_protection.get('hits') or []: + cancellations.append({'rule_id': hit['rule_id'], 'priority': hit.get('priority', 0), 'source': 'jupiter_navamsa_aspect_protection'}) + for hit in strength_cancellations.get('hits') or []: + cancellations.append({**hit, 'source': 'strength_supported_cancellations'}) + if any(row['status'] == 'supportive' for row in navamsa_strength.get('checks', []) if 'navamsa' in row['rule_id']): + cancellations.append({'rule_id': 'navamsa_lagna_fortification', 'priority': 7}) + + if _planet_house_or_sign_house(chart_view, 'Sun') == 11 or _planet_house_or_sign_house(chart_view, 'Moon') == 11: + cancellations.append({'rule_id': 'moon_or_sun_11th', 'priority': 6}) + if jupiter_house in {1, 4, 7, 10} or _planet_house_or_sign_house(chart_view, 'Venus') in {1, 4, 7, 10}: + malefics_placed = [ + planet + for planet in MALEFIC_PLANETS + if _planet_house_or_sign_house(chart_view, planet) in {3, 6, 11} + ] + if malefics_placed: + cancellations.append({'rule_id': 'jupiter_or_venus_kendra_malefics_3_6_11', 'priority': 8, 'malefics': malefics_placed}) + if navamsa_strength.get('quality') == 'strong': + cancellations.append({'rule_id': 'lagna_navamsa_fortification', 'priority': 7, 'supportive_count': navamsa_strength.get('supportive_count')}) + + for planet in planets: + if planet in EXALTATION_SIGNS and _planet_sign(chart_view, planet) is None and _planet_payload(chart_view, planet).get('dignity') is None: + blocked.append({'rule_id': 'planet_dignity', 'planet': planet, 'missing_fields': ['sign_or_dignity']}) + blocked.extend(navamsa_strength.get('blocked') or []) + if shadvarga_contract.get('status') == 'blocked': + blocked.append({'rule_id': 'avoid_strong_malefics_in_shadvarga', 'missing_fields': shadvarga_contract.get('missing_fields')}) + blocked.extend(shadvarga_malefic_strength.get('blocked') or []) + blocked.extend(jupiter_protection.get('blocked') or []) + + return { + 'status': 'evaluated_with_blocks' if blocked else 'evaluated', + 'activity': activity, + 'normalized_chart': normalized, + 'lagna_navamsa_strength': navamsa_strength, + 'shadvarga_strength_contract': shadvarga_contract, + 'shadvarga_malefic_strength': shadvarga_malefic_strength, + 'strength_supported_cancellations': strength_cancellations, + 'jupiter_navamsa_aspect_protection': jupiter_protection, + 'doshas': doshas, + 'cancellations': sorted(cancellations, key=lambda row: row.get('priority', 0), reverse=True), + 'blocked': blocked, + 'net_status': 'negative_needs_human_review' if doshas and not cancellations else 'mitigated_or_clear', + 'source_rule_ids': [ + 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', + 'raman_muhurtha_neutralization_rules', + 'raman_muhurtha_marriage_adverse_yogas_page_21', + ], + } + + +def evaluate_muhurta_cancellation_weight_packet( + election_chart: Dict, + *, + activity: str = 'business', + native_lagna: Optional[object] = None, + weekday: Optional[object] = None, +) -> Dict: + """Build an auditable dosha/cancellation weight packet without issuing a final score.""" + dosha_packet = evaluate_muhurta_election_chart_dosha( + election_chart, + activity=activity, + native_lagna=native_lagna, + weekday=weekday, + ) + severity_total = sum(float(row.get('severity', 0)) for row in dosha_packet.get('doshas') or []) + cancellation_total = sum(float(row.get('priority', 0)) for row in dosha_packet.get('cancellations') or []) + applied_cancellation_weight = min(severity_total, cancellation_total) + if severity_total == 0: + net_weight_status = 'no_structural_dosha_detected' + elif applied_cancellation_weight >= severity_total: + net_weight_status = 'fully_mitigated_candidate' + elif applied_cancellation_weight > 0: + net_weight_status = 'partially_mitigated_candidate' + else: + net_weight_status = 'unmitigated_candidate' + return { + 'schema': 'muhurta.cancellation_weight_packet.v1', + 'status': dosha_packet.get('status'), + 'activity': activity, + 'severity_total': severity_total, + 'cancellation_priority_total': cancellation_total, + 'applied_cancellation_weight': applied_cancellation_weight, + 'net_weight_status': net_weight_status, + 'final_activity_score_allowed': False, + 'doshas': dosha_packet.get('doshas') or [], + 'cancellations': dosha_packet.get('cancellations') or [], + 'blocked': dosha_packet.get('blocked') or [], + 'source_rule_ids': [ + 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', + 'raman_muhurtha_neutralization_rules', + ], + 'boundary': 'Weights expose replay priority only; final Muhurta activity scoring still needs broader Raman table closure.', + } + + +RAMAN_ACTIVITY_SCORE_WEIGHTS = { + 'panchanga_subha': 4, + 'panchanga_mixed': 1, + 'panchanga_asubha': -4, + 'panchaka_good': 8, + 'panchaka_bad': -6, + 'panchaka_activity_avoid': -18, + 'tarabala_good': 8, + 'tarabala_mild': -3, + 'tarabala_bad': -12, + 'chandrabala_good': 6, + 'chandrabala_bad': -12, + 'siddha': 8, + 'amrita_siddha': 12, + 'durmuhurtha': -18, + 'structural_dosha': -2, + 'structural_cancellation': 1, + 'money_lending_avoid_nakshatra': -14, + 'money_lending_avoid_weekday': -10, + 'money_lending_saturday_new_moon': -18, + 'money_receiving_counter_rule': 8, + 'money_lagna_lords_harmonious': 6, + 'money_lagna_lords_inharmonious': -8, + 'money_moon_scorpio_lender': -8, + 'journey_good_nakshatra': 8, + 'journey_janma_nakshatra_avoid': -12, + 'journey_ninth_tithi_avoid': -14, + 'journey_urgent_exception': 7, + 'journey_fixed_moon_lagna_gain': 6, + 'journey_jupiter_venus_support': 5, + 'land_purchase_good_nakshatra': 8, + 'land_purchase_avoid_riktha_tithi': -14, + 'land_purchase_good_weekday': 6, + 'land_purchase_reject_tuesday': -12, + 'land_purchase_fixed_lagna': 8, + 'land_purchase_common_lagna_ordinary': -3, + 'land_purchase_movable_lagna_taurus_navamsa_exception': 7, + 'land_purchase_eighth_house_vacant': 8, + 'land_purchase_eighth_house_occupied': -14, + 'land_purchase_malefics_in_upachayas': 6, + 'land_purchase_malefics_not_in_upachayas': -8, + 'land_purchase_benefics_in_kendras': 6, + 'land_purchase_benefics_not_in_kendras': -6, + 'land_purchase_moon_strong': 6, + 'land_purchase_moon_weak': -8, + 'land_purchase_weekday_lord_in_lagna': 4, +} + +FIXED_SIGNS = {'Taurus', 'Leo', 'Scorpio', 'Aquarius'} +MOVABLE_SIGNS = {'Aries', 'Cancer', 'Libra', 'Capricorn'} +COMMON_SIGNS = {'Gemini', 'Virgo', 'Sagittarius', 'Pisces'} +RAMAN_LAND_PURCHASE_ACTIVITIES = {'business_land_purchase', 'buying_land', 'land_purchase', 'house_building'} +RAMAN_LAND_PURCHASE_GOOD_NAKSHATRAS = { + 'Ashwini', 'Rohini', 'Mrigashira', 'Punarvasu', 'Pushya', + 'Uttara Phalguni', 'Hasta', 'Swati', 'Anuradha', + 'Uttara Ashadha', 'Shravana', 'Dhanishtha', 'Shatabhisha', + 'Uttara Bhadrapada', +} + + +def _score_quality(quality: Optional[str]) -> int: + if quality == 'subha': + return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_subha'] + if quality == 'mixed': + return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_mixed'] + if quality == 'asubha': + return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_asubha'] + return 0 + + +def _score_band(score: int, missing_count: int) -> str: + if missing_count >= 3: + return 'insufficient_inputs' + if score >= 75: + return 'strong_candidate' + if score >= 60: + return 'usable_candidate' + if score >= 45: + return 'mixed_candidate' + return 'avoid_candidate' + + +def _score_raman_activity_specific_modifier( + *, + activity: str, + lagna: Optional[object], + tithi_num: Optional[int], + weekday_value: Optional[object], + current_nakshatra: Optional[str], + birth_nakshatra: Optional[object], + transaction_type: Optional[str], + urgent_journey: bool, + lagna_lord_seventh_lord_relationship: Optional[str], + current_moon_sign: Optional[object], + jupiter_or_venus_support: Optional[bool], + navamsa_lagna: Optional[object], + eighth_house_vacant: Optional[bool], + malefics_in_upachayas: Optional[bool], + benefics_in_kendras: Optional[bool], + moon_strong: Optional[bool], + weekday_lord_in_lagna: Optional[bool], +) -> Dict: + """Evaluate Raman page-backed activity-specific modifiers.""" + delta = 0 + hits = [] + missing = [] + normalized_transaction = transaction_type or ( + 'lending' if activity in {'finance_lending', 'finance_lending_borrowing'} else + 'receiving' if activity == 'finance_receiving' else None + ) + try: + weekday_idx = None if weekday_value is None else int(weekday_value) % 7 + except (TypeError, ValueError): + weekday_idx = None + + if activity in {'finance_lending_borrowing', 'finance_lending', 'finance_receiving'}: + if normalized_transaction not in {'lending', 'receiving'}: + missing.append('transaction_type_lending_or_receiving') + if current_nakshatra is None: + missing.append('current_nakshatra') + if weekday_idx is None: + missing.append('weekday') + if tithi_num is None: + missing.append('tithi_num') + normalized_moon_sign = _as_sign(current_moon_sign) + normalized_relationship = ( + lagna_lord_seventh_lord_relationship.lower() + if isinstance(lagna_lord_seventh_lord_relationship, str) + else None + ) + if normalized_relationship is None: + missing.append('lagna_lord_seventh_lord_relationship') + elif normalized_relationship in {'harmonious', 'friendly', 'well_disposed', 'mutual_aspect_benefic'}: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lagna_lords_harmonious'] + delta += value + hits.append({'rule_id': 'raman_p70_lagna_lord_seventh_lord_harmonious', 'delta': value, 'source_pages': [70]}) + elif normalized_relationship in {'inharmonious', 'hostile', 'ill_disposed', 'afflicted'}: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lagna_lords_inharmonious'] + delta += value + hits.append({'rule_id': 'raman_p70_lagna_lord_seventh_lord_inharmonious', 'delta': value, 'source_pages': [70]}) + is_janma = False + if birth_nakshatra is not None and current_nakshatra is not None: + try: + is_janma = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') == _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') + except ValueError: + missing.append('valid_birth_or_current_nakshatra') + elif current_nakshatra is not None: + missing.append('birth_nakshatra_for_janma_lending_rule') + avoid_nakshatra = current_nakshatra in RAMAN_MONEY_LENDING_AVOID_NAKSHATRAS or is_janma + if normalized_transaction == 'lending': + if avoid_nakshatra: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_avoid_nakshatra'] + delta += value + hits.append({'rule_id': 'raman_p70_money_lending_avoid_nakshatra', 'delta': value, 'source_pages': [70]}) + if weekday_idx in {2, 5}: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_avoid_weekday'] + delta += value + hits.append({'rule_id': 'raman_p70_money_lending_avoid_tuesday_friday', 'delta': value, 'source_pages': [70]}) + if weekday_idx == 6 and tithi_num == 30: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_saturday_new_moon'] + delta += value + hits.append({'rule_id': 'raman_p70_money_lending_avoid_saturday_new_moon', 'delta': value, 'source_pages': [70]}) + if normalized_moon_sign == 'Scorpio': + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_moon_scorpio_lender'] + delta += value + hits.append({'rule_id': 'raman_p70_moon_in_scorpio_bad_for_lender', 'delta': value, 'source_pages': [70]}) + elif normalized_transaction == 'receiving' and avoid_nakshatra: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_receiving_counter_rule'] + delta += value + hits.append({'rule_id': 'raman_p70_receive_money_on_lending_avoid_days', 'delta': value, 'source_pages': [70]}) + + if activity in {'travel', 'journey'}: + if current_nakshatra is None: + missing.append('current_nakshatra') + if tithi_num is None: + missing.append('tithi_num') + normalized_moon_sign = _as_sign(current_moon_sign) + normalized_lagna = _as_sign(lagna) + if normalized_moon_sign is None: + missing.append('current_moon_sign_for_fixed_sign_rule') + if normalized_lagna is None: + missing.append('lagna_for_fixed_sign_rule') + if normalized_moon_sign in FIXED_SIGNS and normalized_lagna in FIXED_SIGNS: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_fixed_moon_lagna_gain'] + delta += value + hits.append({'rule_id': 'raman_p71_fixed_moon_and_lagna_for_pecuniary_gain', 'delta': value, 'source_pages': [71]}) + if jupiter_or_venus_support is None: + missing.append('jupiter_or_venus_support') + elif jupiter_or_venus_support: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_jupiter_venus_support'] + delta += value + hits.append({'rule_id': 'raman_p71_jupiter_or_venus_support', 'delta': value, 'source_pages': [71]}) + is_janma = False + if birth_nakshatra is not None and current_nakshatra is not None: + try: + is_janma = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') == _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') + except ValueError: + missing.append('valid_birth_or_current_nakshatra') + elif current_nakshatra is not None: + missing.append('birth_nakshatra_for_journey_janma_rule') + if current_nakshatra in RAMAN_JOURNEY_GOOD_NAKSHATRAS: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_good_nakshatra'] + delta += value + hits.append({'rule_id': 'raman_p71_journey_good_nakshatra', 'delta': value, 'source_pages': [71]}) + if is_janma: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_janma_nakshatra_avoid'] + delta += value + hits.append({'rule_id': 'raman_p71_journey_avoid_janma_nakshatra', 'delta': value, 'source_pages': [71]}) + if _tithi_in_paksha_number(tithi_num) == 9 if tithi_num is not None else False: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_ninth_tithi_avoid'] + delta += value + hits.append({'rule_id': 'raman_p71_journey_avoid_ninth_lunar_day', 'delta': value, 'source_pages': [71]}) + if urgent_journey and any(hit['delta'] < 0 for hit in hits): + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_urgent_exception'] + delta += value + hits.append({'rule_id': 'raman_p71_urgent_journey_exception', 'delta': value, 'source_pages': [71]}) + + if activity in RAMAN_LAND_PURCHASE_ACTIVITIES: + normalized_lagna = _as_sign(lagna) + normalized_navamsa_lagna = _as_sign(navamsa_lagna) + if current_nakshatra is None: + missing.append('current_nakshatra') + elif current_nakshatra in RAMAN_LAND_PURCHASE_GOOD_NAKSHATRAS: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_good_nakshatra'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_good_nakshatra', 'delta': value, 'source_pages': [82]}) + if tithi_num is None: + missing.append('tithi_num') + elif _tithi_in_paksha_number(tithi_num) in {4, 9, 14}: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_avoid_riktha_tithi'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_avoid_riktha_tithi', 'delta': value, 'source_pages': [82]}) + if weekday_idx is None: + missing.append('weekday') + elif weekday_idx in {1, 3, 4, 6}: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_good_weekday'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_good_weekday', 'delta': value, 'source_pages': [82]}) + elif weekday_idx == 2: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_reject_tuesday'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_reject_tuesday', 'delta': value, 'source_pages': [82]}) + if normalized_lagna is None: + missing.append('lagna_for_land_purchase') + elif normalized_lagna in FIXED_SIGNS: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_fixed_lagna'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_fixed_lagna', 'delta': value, 'source_pages': [82]}) + elif normalized_lagna in COMMON_SIGNS: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_common_lagna_ordinary'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_common_lagna_ordinary', 'delta': value, 'source_pages': [82]}) + elif normalized_lagna in MOVABLE_SIGNS: + if normalized_navamsa_lagna == 'Taurus': + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_movable_lagna_taurus_navamsa_exception'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_movable_lagna_taurus_navamsa_exception', 'delta': value, 'source_pages': [82]}) + else: + missing.append('navamsa_lagna_taurus_exception_or_fixed_lagna') + + boolean_checks = [ + (eighth_house_vacant, 'eighth_house_vacant', 'land_purchase_eighth_house_vacant', 'land_purchase_eighth_house_occupied', 'raman_p82_land_purchase_eighth_house_vacant', 'raman_p82_land_purchase_eighth_house_occupied'), + (malefics_in_upachayas, 'malefics_in_upachayas', 'land_purchase_malefics_in_upachayas', 'land_purchase_malefics_not_in_upachayas', 'raman_p82_land_purchase_malefics_in_upachayas', 'raman_p82_land_purchase_malefics_not_in_upachayas'), + (benefics_in_kendras, 'benefics_in_kendras', 'land_purchase_benefics_in_kendras', 'land_purchase_benefics_not_in_kendras', 'raman_p82_land_purchase_benefics_in_kendras', 'raman_p82_land_purchase_benefics_not_in_kendras'), + (moon_strong, 'moon_strong', 'land_purchase_moon_strong', 'land_purchase_moon_weak', 'raman_p82_land_purchase_moon_strong', 'raman_p82_land_purchase_moon_weak'), + ] + for value_input, missing_name, positive_weight, negative_weight, positive_rule, negative_rule in boolean_checks: + if value_input is None: + missing.append(missing_name) + elif value_input: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS[positive_weight] + delta += value + hits.append({'rule_id': positive_rule, 'delta': value, 'source_pages': [82]}) + else: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS[negative_weight] + delta += value + hits.append({'rule_id': negative_rule, 'delta': value, 'source_pages': [82]}) + if weekday_lord_in_lagna is None: + missing.append('weekday_lord_in_lagna') + elif weekday_lord_in_lagna: + value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_weekday_lord_in_lagna'] + delta += value + hits.append({'rule_id': 'raman_p82_land_purchase_weekday_lord_in_lagna', 'delta': value, 'source_pages': [82]}) + + return { + 'factor_id': 'raman_activity_specific_modifier', + 'activity': activity, + 'transaction_type': normalized_transaction, + 'urgent_journey': urgent_journey, + 'delta': delta, + 'hits': hits, + 'missing_inputs': missing, + 'source_rule_ids': [ + 'raman_muhurtha_money_lending_page_70', + 'raman_muhurtha_journey_page_71', + 'raman_muhurtha_land_purchase_page_82', + ], + 'status': 'evaluated_with_input_gaps' if missing else 'evaluated', + } + + +def score_raman_muhurta_activity_beta( + panchanga: Dict, + activity: str, + *, + lagna: Optional[object] = None, + birth_nakshatra: Optional[object] = None, + birth_moon_sign: Optional[object] = None, + current_moon_sign: Optional[object] = None, + in_durmuhurtha: bool = False, + election_chart: Optional[Dict] = None, + native_lagna: Optional[object] = None, + weekday: Optional[object] = None, + transaction_type: Optional[str] = None, + urgent_journey: bool = False, + lagna_lord_seventh_lord_relationship: Optional[str] = None, + jupiter_or_venus_support: Optional[bool] = None, + navamsa_lagna: Optional[object] = None, + eighth_house_vacant: Optional[bool] = None, + malefics_in_upachayas: Optional[bool] = None, + benefics_in_kendras: Optional[bool] = None, + moon_strong: Optional[bool] = None, + weekday_lord_in_lagna: Optional[bool] = None, +) -> Dict: + """Product beta score for Raman-backed Muhurta activity ranking. + + This is a calibrated product ranking score, not a classical truth verdict. + Missing optional native/chart inputs become explicit evidence gaps instead + of hard blockers. + """ + score = 50 + factors = [] + missing = [] + + for limb in ('tithi', 'nakshatra', 'yoga', 'karana', 'vara'): + quality = (panchanga.get(limb) or {}).get('quality') + delta = _score_quality(quality) + score += delta + factors.append({'factor_id': f'{limb}_quality', 'quality': quality, 'delta': delta}) + + tithi_num = (panchanga.get('tithi') or {}).get('tithi_num') + current_nakshatra = (panchanga.get('nakshatra') or {}).get('nakshatra') + weekday_value = weekday if weekday is not None else (panchanga.get('vara') or {}).get('weekday_idx') + + if lagna is None or tithi_num is None or current_nakshatra is None or weekday_value is None: + missing.append('panchaka_terms') + factors.append({'factor_id': 'panchaka_activity_gate', 'status': 'input_required', 'delta': 0}) + else: + panchaka = calc_panchaka(tithi_num, int(weekday_value), current_nakshatra, lagna, activity=activity) + if panchaka['quality'] == 'good': + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_good'] + elif panchaka['avoided_for_activity']: + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_activity_avoid'] + else: + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_bad'] + score += delta + factors.append({'factor_id': 'panchaka_activity_gate', 'actual': panchaka, 'delta': delta}) + + if birth_nakshatra is None or current_nakshatra is None: + missing.append('tarabala_native_birth_nakshatra') + factors.append({'factor_id': 'tarabala_gate', 'status': 'input_required', 'delta': 0}) + else: + tara = calc_tarabala(birth_nakshatra, current_nakshatra, activity=activity) + if tara['quality'] in {'good', 'activity_exception_usable'}: + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_good'] + elif tara['quality'] == 'mildly_unfavourable': + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_mild'] + else: + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_bad'] + score += delta + factors.append({'factor_id': 'tarabala_gate', 'actual': tara, 'delta': delta}) + + if birth_moon_sign is None or current_moon_sign is None: + missing.append('chandrabala_native_moon_sign') + factors.append({'factor_id': 'chandrabala_gate', 'status': 'input_required', 'delta': 0}) + else: + chandra = calc_chandrabala(birth_moon_sign, current_moon_sign) + delta = ( + RAMAN_ACTIVITY_SCORE_WEIGHTS['chandrabala_bad'] + if chandra['avoided'] + else RAMAN_ACTIVITY_SCORE_WEIGHTS['chandrabala_good'] + ) + score += delta + factors.append({'factor_id': 'chandrabala_gate', 'actual': chandra, 'delta': delta}) + + if weekday_value is not None and tithi_num is not None and current_nakshatra is not None: + siddha = calc_siddha_yoga(int(weekday_value), int(tithi_num), current_nakshatra) + delta = 0 + if siddha['quality'] == 'amrita_siddha': + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['amrita_siddha'] + elif siddha['quality'] == 'siddha': + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['siddha'] + score += delta + factors.append({'factor_id': 'siddha_yoga', 'actual': siddha, 'delta': delta}) + else: + missing.append('siddha_yoga_terms') + + if in_durmuhurtha: + delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['durmuhurtha'] + score += delta + factors.append({'factor_id': 'durmuhurtha_avoidance', 'status': 'inside_durmuhurtha', 'delta': delta}) + else: + factors.append({'factor_id': 'durmuhurtha_avoidance', 'status': 'not_flagged', 'delta': 0}) + + activity_modifier = _score_raman_activity_specific_modifier( + activity=activity, + lagna=lagna, + tithi_num=tithi_num, + weekday_value=weekday_value, + current_nakshatra=current_nakshatra, + birth_nakshatra=birth_nakshatra, + transaction_type=transaction_type, + urgent_journey=urgent_journey, + lagna_lord_seventh_lord_relationship=lagna_lord_seventh_lord_relationship, + current_moon_sign=current_moon_sign, + jupiter_or_venus_support=jupiter_or_venus_support, + navamsa_lagna=navamsa_lagna, + eighth_house_vacant=eighth_house_vacant, + malefics_in_upachayas=malefics_in_upachayas, + benefics_in_kendras=benefics_in_kendras, + moon_strong=moon_strong, + weekday_lord_in_lagna=weekday_lord_in_lagna, + ) + score += activity_modifier['delta'] + factors.append(activity_modifier) + missing.extend(activity_modifier['missing_inputs']) + + if election_chart is None: + missing.append('election_chart_for_structural_dosha') + factors.append({'factor_id': 'election_chart_dosha', 'status': 'input_required', 'delta': 0}) + else: + weight_packet = evaluate_muhurta_cancellation_weight_packet( + election_chart, + activity=activity, + native_lagna=native_lagna, + weekday=weekday_value, + ) + delta = int( + weight_packet['severity_total'] * RAMAN_ACTIVITY_SCORE_WEIGHTS['structural_dosha'] + + weight_packet['applied_cancellation_weight'] * RAMAN_ACTIVITY_SCORE_WEIGHTS['structural_cancellation'] + ) + delta = max(-20, min(20, delta)) + score += delta + factors.append({'factor_id': 'election_chart_dosha_cancellation', 'actual': weight_packet, 'delta': delta}) + if weight_packet.get('blocked'): + missing.append('partial_election_chart_structural_fields') + + bounded_score = max(0, min(100, int(round(score)))) + return { + 'schema': 'muhurta.raman_activity_beta_score.v1', + 'status': 'beta_score_ready' if not missing else 'beta_score_with_input_gaps', + 'activity': activity, + 'score': bounded_score, + 'band': _score_band(bounded_score, len(missing)), + 'weights_profile': 'raman_product_beta_v1', + 'weights': dict(RAMAN_ACTIVITY_SCORE_WEIGHTS), + 'factors': factors, + 'missing_inputs': missing, + 'product_gate': { + 'beta_activity_score_allowed': True, + 'final_truth_claim_allowed': False, + 'worked_example_calibration_status': 'internal_replay_calibrated_external_final_verdict_examples_pending', + }, + 'boundary': 'Raman-backed product beta score for ranking activity windows; not a final electional certainty claim.', + } + + def _jd_for_local_time(year: int, month: int, day: int, local_hours: float, tz: float) -> Optional[float]: try: import swisseph as swe @@ -667,7 +2192,7 @@ def calc_panchanga_end_times( day: int, tz: float, local_hours: float, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Optional[Dict]: """Calculate current Tithi/Nakshatra/Yoga end times from SwissEph positions.""" jd_start = _jd_for_local_time(year, month, day, local_hours, tz) @@ -1047,7 +2572,7 @@ def calc_daytime_inauspicious_periods( def calc_panchanga(sun_lon: float, moon_lon: float, weekday: int, hour_from_sunrise: float = 6.0) -> Dict: """ - 计算 Panchanga 五要素(所有输入均为恒星坐标 Lahiri)。 + 计算 Panchanga 五要素(所有输入均为恒星坐标,具体 ayanamsa 由上游决定)。 参数: sun_lon: 太阳恒星黄经 @@ -1167,7 +2692,14 @@ ACTIVITY_RULES = { } -def check_activity_muhurta(panchanga: Dict, activity: str) -> Dict: +def check_activity_muhurta( + panchanga: Dict, + activity: str, + *, + lagna: Optional[object] = None, + birth_nakshatra: Optional[object] = None, + birth_moon_sign: Optional[object] = None, +) -> Dict: """ 检查给定 Panchanga 是否适合特定活动。 @@ -1179,7 +2711,9 @@ def check_activity_muhurta(panchanga: Dict, activity: str) -> Dict: tithi_num = panchanga['tithi']['tithi_num'] nakshatra = panchanga['nakshatra']['nakshatra'] + nakshatra_idx = panchanga['nakshatra'].get('nakshatra_idx') vara = panchanga['vara']['vara'] + weekday_idx = panchanga['vara'].get('weekday_idx', 0) # Tithi 评估 if tithi_num in rules['good_tithis']: @@ -1205,7 +2739,44 @@ def check_activity_muhurta(panchanga: Dict, activity: str) -> Dict: else: vara_score = 'neutral' + panchaka = None + panchaka_score = 'blocked' + if lagna is not None: + panchaka = calc_panchaka(tithi_num, weekday_idx, nakshatra_idx, lagna, activity=activity) + if panchaka['avoided_for_activity']: + panchaka_score = 'bad' + elif panchaka['quality'] == 'good': + panchaka_score = 'good' + else: + panchaka_score = 'neutral' + else: + panchaka = { + 'status': 'input_required', + 'missing_fields': ['lagna'], + 'source_rule_ids': [ + 'raman_muhurtha_panchaka_formula', + 'raman_muhurtha_activity_panchaka_exceptions', + ], + } + + native_contract = build_tarabala_chandrabala_input_contract( + birth_nakshatra=birth_nakshatra, + current_nakshatra=nakshatra_idx, + birth_moon_sign=birth_moon_sign, + current_moon_sign=int((panchanga['nakshatra']['moon_lon'] % 360) // 30), + ) + tarabala = None + chandrabala = None + if birth_nakshatra is not None: + tarabala = calc_tarabala(birth_nakshatra, nakshatra_idx, activity=activity) + if birth_moon_sign is not None: + chandrabala = calc_chandrabala(birth_moon_sign, int((panchanga['nakshatra']['moon_lon'] % 360) // 30)) + scores = [tithi_score, nakshatra_score, vara_score] + if panchaka_score != 'blocked': + scores.append(panchaka_score) + if tarabala and tarabala.get('avoided_for_high_stakes_activity'): + scores.append('bad') good_count = scores.count('good') bad_count = scores.count('bad') @@ -1225,15 +2796,23 @@ def check_activity_muhurta(panchanga: Dict, activity: str) -> Dict: 'tithi_eval': tithi_score, 'nakshatra_eval': nakshatra_score, 'vara_eval': vara_score, + 'panchaka_eval': panchaka_score, + 'panchaka': panchaka, + 'native_input_contract': native_contract, + 'tarabala': tarabala, + 'chandrabala': chandrabala, 'verdict': verdict, 'good_count': good_count, 'bad_count': bad_count, - 'notes': _get_activity_notes(rules, tithi_num, nakshatra, vara) + 'notes': _get_activity_notes(rules, tithi_num, nakshatra, vara, panchaka, tarabala, native_contract) } def _get_activity_notes(rules: Dict, tithi_num: int, - nakshatra: str, vara: str) -> List[str]: + nakshatra: str, vara: str, + panchaka: Optional[Dict] = None, + tarabala: Optional[Dict] = None, + native_contract: Optional[Dict] = None) -> List[str]: notes = [] if tithi_num in rules['bad_tithis']: notes.append(f'Tithi {tithi_num} 不宜此类活动') @@ -1247,6 +2826,17 @@ def _get_activity_notes(rules: Dict, tithi_num: int, notes.append(f'{nakshatra} 星宿适合此类活动') if vara in rules['good_varas']: notes.append(f'{vara} 有利此类活动') + if panchaka: + if panchaka.get('status') == 'input_required': + notes.append('Panchaka 需要 Lagna 才能按 Raman 公式判定') + elif panchaka.get('avoided_for_activity'): + notes.append(f"Panchaka {panchaka.get('dosha')} 按 Raman 活动避忌不利") + elif panchaka.get('quality') == 'good': + notes.append('Panchaka 余数属于 Raman 可用组') + if tarabala and tarabala.get('avoided_for_high_stakes_activity'): + notes.append(f"Tarabala {tarabala.get('tara')} 不利重要活动") + if native_contract and native_contract.get('status') == 'input_required': + notes.append('Tarabala/Chandrabala 需要本命星宿与本命月亮星座后才能完整评分') return notes @@ -1264,8 +2854,8 @@ def muhurta_full_report( 生成完整 Muhurta 报告。 参数: - sun_lon: 太阳恒星黄经(Lahiri,0-360) - moon_lon: 月亮恒星黄经(Lahiri,0-360) + sun_lon: 太阳恒星黄经(0-360,具体 ayanamsa 由上游决定) + moon_lon: 月亮恒星黄经(0-360,具体 ayanamsa 由上游决定) weekday: 0=Sun, ..., 6=Sat hour_from_sunrise: 从日出起算的小时(默认 6h = 约正午) query_date_str: 查询日期字符串(用于展示) @@ -1313,7 +2903,7 @@ def panchanga_range_report( lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """Build a date-range Panchanga calendar with daytime inauspicious windows.""" start_dt = datetime.strptime(start_date[:10], '%Y-%m-%d') @@ -1368,6 +2958,7 @@ def panchanga_range_report( ) report['inauspicious_periods'] = calc_daytime_inauspicious_periods(weekday, day_sunrise, day_sunset) report['choghadiya'] = calc_choghadiya_windows(weekday, day_sunrise, day_sunset) + report['durmuhurtha'] = calc_durmuhurtha_windows(weekday, day_sunrise, day_sunset) report['hora_windows'] = calc_hora_windows(weekday, day_sunrise, day_sunset) panchanga = report['panchanga'] report['vrata_tags'] = classify_vrata_tags( @@ -1420,6 +3011,7 @@ def panchanga_range_report( 'sunrise_sunset': sunrise_sunset_policy, 'end_times': 'SwissEph boundary bisection when location/timezone are available', 'sub_day_windows': 'traditional Choghadiya and planetary Hora segmented by local sunrise/sunset', + 'durmuhurtha': 'Raman p22 day/night 15-segment table, marked as avoidance context until activity scoring replay is complete', 'festival_rules': 'conservative tithi/nakshatra/vara vrata rules; masa-dependent festivals marked as candidates', 'condition_tags': 'searchable row tags derived from vrata, activity verdicts, warnings and sub-day windows', 'next_precision_step': next_precision_step, @@ -1442,7 +3034,7 @@ def muhurta_range_search( lon: Optional[float] = None, tz: Optional[float] = None, avoid_inauspicious_periods: bool = True, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """Search a date range for ranked Muhurta candidates for a selected activity.""" limit = max(1, min(int(limit or 5), 20)) @@ -1514,7 +3106,7 @@ def build_muhurta_sidecar( lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """Compact Muhurta/Panchanga packet for unified workflow consumers.""" activity = activity if activity in ACTIVITY_RULES else 'business' @@ -1706,7 +3298,7 @@ def _approx_sun_moon_lon(year: int, month: int, day: int) -> Tuple[float, float] """ 近似计算太阳/月亮恒星黄经(无 swisseph,精度约 ±2°)。 仅用于测试和展示,不用于精确解盘。 - Lahiri Ayanamsa ≈ 23.85°(2026年) + 该近似回退公式内部仍使用固定 Lahiri 常数 ≈ 23.85°(2026年),仅用于无 swisseph 的低精度展示。 """ # J2000.0 起的天数 import math @@ -1723,7 +3315,7 @@ def _approx_sun_moon_lon(year: int, month: int, day: int) -> Tuple[float, float] M_moon = math.radians(134.9634 + 13.064993 * d) L_moon = (L_moon + 6.2886 * math.sin(M_moon)) % 360 - # 转为恒星(减去 Lahiri Ayanamsa ≈ 23.85°,2026年) + # 转为恒星(该近似回退路径减去固定 Lahiri 常数 ≈ 23.85°,2026年) ayanamsa = 23.85 sun_sid = (L_sun - ayanamsa) % 360 moon_sid = (L_moon - ayanamsa) % 360 diff --git a/scripts/narayana_dasha.py b/scripts/narayana_dasha.py index 9fb12d7c..69252a98 100644 --- a/scripts/narayana_dasha.py +++ b/scripts/narayana_dasha.py @@ -38,6 +38,104 @@ PLANET_SIGN_INDEX = { 'Rahu': 10, 'Ketu': 7, # traditional assignments } +_NARAYANA_ODD_FOOTED_SIGNS = {0, 1, 2, 6, 7, 8} +_NARAYANA_DURATION_PROFILES = { + "legacy_forward_v0", + "jaimini_odd_footed_v1", + "jaimini_odd_footed_dignity_v2", +} +_NARAYANA_GENERAL_SEQUENCE = { + 0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 1: [1, 8, 3, 10, 5, 0, 7, 2, 9, 4, 11, 6], + 2: [2, 10, 6, 5, 1, 9, 8, 4, 0, 11, 7, 3], + 3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4], + 4: [4, 9, 2, 7, 0, 5, 10, 3, 8, 1, 6, 11], + 5: [5, 9, 1, 2, 6, 10, 11, 3, 7, 8, 0, 4], + 6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5], + 7: [7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5, 0], + 8: [8, 4, 0, 11, 7, 3, 2, 10, 6, 5, 1, 9], + 9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10], + 10: [10, 3, 8, 1, 6, 11, 4, 9, 2, 7, 0, 5], + 11: [11, 3, 7, 0, 4, 8, 1, 5, 9, 2, 6, 10], +} +_NARAYANA_RATH_GENERAL_SEQUENCE = { + 0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 1: [1, 8, 3, 10, 5, 0, 7, 2, 9, 4, 11, 6], + 2: [2, 10, 6, 5, 1, 9, 8, 4, 0, 11, 7, 3], + 3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4], + 4: [4, 9, 2, 7, 0, 5, 10, 3, 8, 1, 6, 11], + 5: [5, 9, 1, 2, 6, 10, 11, 3, 7, 8, 0, 4], + 6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5], + 7: [7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5, 0], + 8: [8, 4, 0, 11, 7, 3, 2, 10, 6, 5, 1, 9], + 9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10], + 10: [10, 3, 8, 1, 6, 11, 4, 9, 2, 7, 0, 5], + 11: [11, 3, 7, 8, 0, 4, 5, 9, 1, 2, 6, 10], +} +_NARAYANA_SEQUENCE_PROFILES = { + "legacy_zodiacal_v0", + "jaimini_general_order_v1", + "rath_general_table10_v1", + "rath_saturn_table11_v1", +} +_NARAYANA_SEED_PROFILES = {"legacy_lagna_v0", "jaimini_lagna_seventh_strength_v1"} +_NARAYANA_DUAL_LORD_PROFILES = {"legacy_primary_lord_v0", "jaimini_dual_lord_source_v1"} +_NARAYANA_ANTARDASHA_PROFILES = { + "legacy_weighted_v0", + "parashara_equal_v1", + "pl9_observed_stronger_sign_v1", + "pl9_observed_direction_v1", + "pl9_observed_direction_tie_break_v1", +} +_NARAYANA_AD_ORDER_TABLE13 = { + 0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 1: [1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2], + 2: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1], + 3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4], + 4: [4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3], + 5: [5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6], + 6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5], + 7: [7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8], + 8: [8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7], + 9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10], + 10: [10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + 11: [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0], +} +_NARAYANA_AD_KETU_TABLE14 = { + 0: [0, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1], + 1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0], + 2: [2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 3], + 3: [3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2], + 4: [4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5], + 5: [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4], + 6: [6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7], + # Source/OCR line repeats Cancer/Gemini at the tail. Keep the normalized + # reversal rule here; the ledger records the printed anomaly separately. + 7: [7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6], + 8: [8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9], + 9: [9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8], + 10: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11], + 11: [11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], +} +_NARAYANA_AD_ORDER_PROFILES = { + "rath_table13_v1", + "rath_ketu_table14_v1", + "rath_saturn_table11_v1", +} +_MOVABLE_SIGNS = {0, 3, 6, 9} +_FIXED_SIGNS = {1, 4, 7, 10} +_NARAYANA_DIGNITY = { + "Sun": {"exalted": {0}, "debilitated": {6}}, + "Moon": {"exalted": {1}, "debilitated": {7}}, + "Mars": {"exalted": {9}, "debilitated": {3}}, + "Mercury": {"exalted": {5}, "debilitated": {11}}, + "Jupiter": {"exalted": {3}, "debilitated": {9}}, + "Venus": {"exalted": {11}, "debilitated": {5}}, + "Saturn": {"exalted": {6}, "debilitated": {0}}, +} +_NARAYANA_NODE_MOOLATRIKONA = {"Rahu": {5}, "Ketu": {7}} +_NARAYANA_NODE_OWN_SIGNS = {"Rahu": {10}, "Ketu": {7}} + # ========================================================================= # 核心计算 @@ -60,6 +158,277 @@ def _count_signs_forward(src_idx: int, dest_idx: int) -> int: return count # 已含起点 +def build_narayana_sign_sequence( + seed_sign_idx: int, + *, + profile: str = "legacy_zodiacal_v0", +) -> List[int]: + """Return a first-cycle sign order for an explicit Narayana sequence profile.""" + if profile not in _NARAYANA_SEQUENCE_PROFILES: + raise ValueError(f"unsupported Narayana sequence profile: {profile}") + if not 0 <= seed_sign_idx < 12: + raise ValueError("seed sign index must be in 0..11") + if profile == "legacy_zodiacal_v0": + return [(seed_sign_idx + offset) % 12 for offset in range(12)] + if profile == "rath_general_table10_v1": + return list(_NARAYANA_RATH_GENERAL_SEQUENCE[seed_sign_idx]) + if profile == "rath_saturn_table11_v1": + return [(seed_sign_idx + offset) % 12 for offset in range(12)] + return list(_NARAYANA_GENERAL_SEQUENCE[seed_sign_idx]) + + +def build_narayana_antardasha_order_sequence( + start_sign_idx: int, + *, + profile: str = "rath_table13_v1", +) -> List[int]: + """Return Sanjay Rath source-table Antardasha sign order.""" + if profile not in _NARAYANA_AD_ORDER_PROFILES: + raise ValueError(f"unsupported Narayana Antardasha order profile: {profile}") + if not 0 <= start_sign_idx < 12: + raise ValueError("start sign index must be in 0..11") + if profile == "rath_table13_v1": + return list(_NARAYANA_AD_ORDER_TABLE13[start_sign_idx]) + if profile == "rath_ketu_table14_v1": + return list(_NARAYANA_AD_KETU_TABLE14[start_sign_idx]) + return [(start_sign_idx + offset) % 12 for offset in range(12)] + + +def build_narayana_pratyantardasha_order_sequence( + start_sign_idx: int, + *, + profile: str = "rath_inherit_ad_table13_v1", +) -> List[int]: + """Return source-attested PD order by inheriting the Rath AD rolling rule.""" + if profile == "rath_inherit_ad_table13_v1": + return build_narayana_antardasha_order_sequence( + start_sign_idx, + profile="rath_table13_v1", + ) + if profile == "rath_inherit_ketu_ad_table14_v1": + return build_narayana_antardasha_order_sequence( + start_sign_idx, + profile="rath_ketu_table14_v1", + ) + raise ValueError(f"unsupported Narayana Pratyantardasha order profile: {profile}") + + +def subdivide_narayana_equal_order( + parent_period: Dict, + *, + start_sign_idx: int, + order_profile: str, + level: str, + parent_key: str, + parent_name: str, +) -> List[Dict]: + """Split a Narayana parent period into twelve equal source-profile rows.""" + if not 0 <= start_sign_idx < 12: + raise ValueError("start sign index must be in 0..11") + total_years = float(parent_period.get("years", 0)) + parent_start = float(parent_period.get("start_age", 0)) + parent_end = float(parent_period.get("end_age", parent_start + total_years)) + if level == "PD": + sequence = build_narayana_pratyantardasha_order_sequence( + start_sign_idx, + profile=order_profile, + ) + else: + sequence = build_narayana_antardasha_order_sequence( + start_sign_idx, + profile=order_profile, + ) + rows = [] + for index, sign_idx in enumerate(sequence): + start_age = parent_start + total_years * index / 12 + end_age = parent_end if index == 11 else parent_start + total_years * (index + 1) / 12 + rows.append({ + "level": level, + "sign": SIGNS[sign_idx], + "sign_idx": sign_idx, + "lord": SIGN_LORDS[SIGNS[sign_idx]], + "years": round(max(0.0, end_age - start_age), 6), + "start_age": round(start_age, 6), + "end_age": round(end_age, 6), + "sequence_index": index, + "order_profile": order_profile, + "start_sign_source": "explicit_source_profile_start_sign", + parent_key: parent_name, + }) + return rows + + +def _has_narayana_sequence_exception( + seed_sign_idx: int, + planet_lons: Dict[str, float], +) -> bool: + return any( + _get_planet_sign(planet, planet_lons) == seed_sign_idx + for planet in ("Saturn", "Ketu") + ) + + +def _rasi_aspects(source_sign_idx: int, target_sign_idx: int) -> bool: + """Return Jaimini Rasi Drishti between two signs.""" + if source_sign_idx in _MOVABLE_SIGNS: + return target_sign_idx in _FIXED_SIGNS and target_sign_idx != (source_sign_idx + 1) % 12 + if source_sign_idx in _FIXED_SIGNS: + return target_sign_idx in _MOVABLE_SIGNS and target_sign_idx != (source_sign_idx - 1) % 12 + return target_sign_idx in {2, 5, 8, 11} and target_sign_idx != source_sign_idx + + +def select_narayana_seed_sign( + lagna_sign_idx: int, + planet_lons: Dict[str, float], +) -> Dict: + """Select the stronger of Lagna and seventh using the first two rule levels. + + A tie is deliberately returned as blocked: later classical strength rules are + not yet implemented and must not be silently substituted with a preference. + """ + if not 0 <= lagna_sign_idx < 12: + raise ValueError("lagna sign index must be in 0..11") + candidate_indices = (lagna_sign_idx, (lagna_sign_idx + 6) % 12) + candidates = {} + for sign_idx in candidate_indices: + lord = SIGN_LORDS[SIGNS[sign_idx]] + occupants = sorted( + planet + for planet in planet_lons + if _get_planet_sign(planet, planet_lons) == sign_idx + ) + aspect_factors = [] + for planet in dict.fromkeys(("Jupiter", "Mercury", lord)): + planet_sign_idx = _get_planet_sign(planet, planet_lons) + if planet_sign_idx is not None and _rasi_aspects(planet_sign_idx, sign_idx): + aspect_factors.append(planet) + candidates[sign_idx] = { + "sign": SIGNS[sign_idx], + "lord": lord, + "occupants": occupants, + "occupant_count": len(occupants), + "aspect_factors": aspect_factors, + "aspect_factor_count": len(aspect_factors), + } + + lagna_candidate = candidates[lagna_sign_idx] + seventh_sign_idx = (lagna_sign_idx + 6) % 12 + seventh_candidate = candidates[seventh_sign_idx] + lagna_score = (lagna_candidate["occupant_count"], lagna_candidate["aspect_factor_count"]) + seventh_score = (seventh_candidate["occupant_count"], seventh_candidate["aspect_factor_count"]) + if lagna_score == seventh_score: + return { + "status": "blocked", + "reason": "later_narayana_sign_strength_rules_required", + "profile": "jaimini_lagna_seventh_strength_v1", + "lagna_sign_idx": lagna_sign_idx, + "seventh_sign_idx": seventh_sign_idx, + "candidates": candidates, + } + selected_sign_idx = lagna_sign_idx if lagna_score > seventh_score else seventh_sign_idx + return { + "status": "selected", + "profile": "jaimini_lagna_seventh_strength_v1", + "selected_sign_idx": selected_sign_idx, + "selected_sign": SIGNS[selected_sign_idx], + "selection_levels": ["occupant_count", "jupiter_mercury_or_lord_rasi_aspect"], + "candidates": candidates, + } + + +def calculate_narayana_duration( + sign_idx: int, + lord_sign_idx: int, + *, + profile: str = "legacy_forward_v0", +) -> int: + """Return a Narayana sign-period duration under an explicit rule profile.""" + if profile not in _NARAYANA_DURATION_PROFILES: + raise ValueError(f"unsupported Narayana duration profile: {profile}") + if not 0 <= sign_idx < 12 or not 0 <= lord_sign_idx < 12: + raise ValueError("sign indices must be in 0..11") + if sign_idx == lord_sign_idx: + return 12 + if profile == "legacy_forward_v0": + return _count_signs_forward(sign_idx, lord_sign_idx) + if sign_idx in _NARAYANA_ODD_FOOTED_SIGNS: + return (lord_sign_idx - sign_idx) % 12 + return (sign_idx - lord_sign_idx) % 12 + + +def calculate_narayana_period_years( + sign_idx: int, + lord: str, + lord_sign_idx: int, + *, + profile: str = "legacy_forward_v0", +) -> tuple[int, int]: + """Return a period length and dignity adjustment under an explicit profile.""" + base_years = calculate_narayana_duration(sign_idx, lord_sign_idx, profile=profile) + if profile != "jaimini_odd_footed_dignity_v2": + return base_years, 0 + dignity = _NARAYANA_DIGNITY.get(lord, {}) + adjustment = 1 if lord_sign_idx in dignity.get("exalted", set()) else 0 + if lord_sign_idx in dignity.get("debilitated", set()): + adjustment = -1 + return max(0, min(12, base_years + adjustment)), adjustment + + +def _narayana_dual_lord_strength( + lord: str, + lord_sign_idx: int, + planet_lons: Dict[str, float], +) -> tuple[int, int, int]: + occupants = sum( + 1 for planet in planet_lons if _get_planet_sign(planet, planet_lons) == lord_sign_idx + ) + sign_lord = SIGN_LORDS[SIGNS[lord_sign_idx]] + aspect_factors = sum( + 1 + for planet in dict.fromkeys(("Jupiter", "Mercury", sign_lord)) + if (planet_sign_idx := _get_planet_sign(planet, planet_lons)) is not None + and _rasi_aspects(planet_sign_idx, lord_sign_idx) + ) + dignity = 2 if lord_sign_idx in _NARAYANA_NODE_MOOLATRIKONA.get(lord, set()) else 0 + if lord_sign_idx in _NARAYANA_NODE_OWN_SIGNS.get(lord, set()): + dignity = max(dignity, 1) + return occupants, aspect_factors, dignity + + +def _resolve_narayana_lord( + sign_idx: int, + planet_lons: Dict[str, float], + *, + dual_lord_profile: str, +) -> str: + if dual_lord_profile not in _NARAYANA_DUAL_LORD_PROFILES: + raise ValueError(f"unsupported Narayana dual-lord profile: {dual_lord_profile}") + primary_lord = SIGN_LORDS[SIGNS[sign_idx]] + dual_lords = {7: ("Mars", "Ketu"), 10: ("Saturn", "Rahu")}.get(sign_idx) + if dual_lords is None or dual_lord_profile == "legacy_primary_lord_v0": + return primary_lord + first, second = dual_lords + first_sign = _get_planet_sign(first, planet_lons) + second_sign = _get_planet_sign(second, planet_lons) + if first_sign is None or second_sign is None: + return primary_lord + if first_sign == sign_idx and second_sign != sign_idx: + return second + if second_sign == sign_idx and first_sign != sign_idx: + return first + if first_sign == second_sign: + return first + first_strength = _narayana_dual_lord_strength(first, first_sign, planet_lons) + second_strength = _narayana_dual_lord_strength(second, second_sign, planet_lons) + if first_strength > second_strength: + return first + if second_strength > first_strength: + return second + first_years = calculate_narayana_duration(sign_idx, first_sign, profile="jaimini_odd_footed_v1") + second_years = calculate_narayana_duration(sign_idx, second_sign, profile="jaimini_odd_footed_v1") + return first if first_years >= second_years else second + + def calc_narayana_mahadasha( lagna_sign_idx: int, planet_lons: Dict[str, float], @@ -114,6 +483,11 @@ def calc_narayana_mahadasha( def calc_narayana_antardasha( mahadasha_periods: List[Dict], md_sign_idx: int, + *, + planet_lons: Optional[Dict[str, float]] = None, + profile: str = "legacy_weighted_v0", + dual_lord_profile: str = "legacy_primary_lord_v0", + md_period: Optional[Dict] = None, ) -> List[Dict]: """ 计算给定 Mahadasha 的 Antardasha 子周期。 @@ -134,6 +508,41 @@ def calc_narayana_antardasha( if md is None: return [] + if profile == "legacy_weighted_v0" and len({p['sign_idx'] for p in mahadasha_periods}) != len(mahadasha_periods): + raise ValueError("legacy_weighted_v0 does not support repeated MD signs across cycles") + + if profile in {"parashara_equal_v1", "pl9_observed_stronger_sign_v1", "pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}: + if not planet_lons: + raise ValueError(f"{profile} requires planet_lons") + selection = select_narayana_seed_sign(md_sign_idx, planet_lons) + if selection["status"] != "selected": + raise ValueError(selection["reason"]) + stronger_sign_idx = selection["selected_sign_idx"] + lord = _resolve_narayana_lord(stronger_sign_idx, planet_lons, dual_lord_profile=dual_lord_profile) + if profile in {"parashara_equal_v1", "pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}: + start_sign_idx = _get_planet_sign(lord, planet_lons) + if start_sign_idx is None: + raise ValueError(f"{profile} requires stronger-sign lord longitude") + start_sign_source = "lord_of_stronger_dasha_or_seventh_sign" + evidence_status = "sourced" if profile == "parashara_equal_v1" else "observed_pl9_control_case_only" + else: + start_sign_idx = stronger_sign_idx + start_sign_source = "pl9_observed_stronger_dasha_or_seventh_sign" + evidence_status = "parameter_sensitive" + zodiacal_direction = md_sign_idx % 2 == 0 + if profile in {"pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}: + zodiacal_direction = md_sign_idx in {3, 4, 9, 10} + return _subdivide_narayana_period_equal( + parent_period=md, + start_sign_idx=start_sign_idx, + zodiacal_direction=zodiacal_direction, + stronger_sign_idx=stronger_sign_idx, + stronger_sign_lord=lord, + profile=profile, + start_sign_source=start_sign_source, + evidence_status=evidence_status, + ) + return _subdivide_narayana_period( mahadasha_periods=mahadasha_periods, parent_period=md, @@ -143,6 +552,47 @@ def calc_narayana_antardasha( ) + +def _subdivide_narayana_period_equal( + *, + parent_period: Dict, + start_sign_idx: int, + zodiacal_direction: bool, + stronger_sign_idx: int, + stronger_sign_lord: str, + profile: str, + start_sign_source: str, + evidence_status: str, +) -> List[Dict]: + """Apply BPHS's twelve equal-sign Antardasha rule for a Rashi Dasha.""" + total_years = float(parent_period.get("years", 0)) + parent_start = float(parent_period.get("start_age", 0)) + parent_end = float(parent_period.get("end_age", parent_start + total_years)) + direction = 1 if zodiacal_direction else -1 + sub_periods = [] + for index in range(12): + sign_idx = (start_sign_idx + direction * index) % 12 + start_age = parent_start + total_years * index / 12 + end_age = parent_end if index == 11 else parent_start + total_years * (index + 1) / 12 + sub_periods.append({ + "sign": SIGNS[sign_idx], + "sign_idx": sign_idx, + "lord": SIGN_LORDS[SIGNS[sign_idx]], + "years": round(max(0.0, end_age - start_age), 4), + "start_age": round(start_age, 4), + "end_age": round(end_age, 4), + "parent_md": parent_period.get("sign", SIGNS[parent_period["sign_idx"]]), + "sequence_index": index, + "antardasha_profile": profile, + "start_sign_source": start_sign_source, + "evidence_status": evidence_status, + "stronger_sign_idx": stronger_sign_idx, + "stronger_sign_lord": stronger_sign_lord, + "direction": "zodiacal" if zodiacal_direction else "reverse", + }) + return sub_periods + + def calc_narayana_pratyantardasha( mahadasha_periods: List[Dict], antardasha_period: Dict, @@ -295,11 +745,110 @@ def get_current_narayana_dasha( return result +def build_narayana_dense_boundaries( + mahadasha_periods: List[Dict], + *, + planet_lons: Optional[Dict[str, float]] = None, + antardasha_profile: str = "legacy_weighted_v0", + dual_lord_profile: str = "legacy_primary_lord_v0", +) -> List[Dict]: + """Return schema-stable MD/AD Narayana boundaries on the absolute age axis.""" + rows: List[Dict] = [] + for md_index, md in enumerate(mahadasha_periods, start=1): + md_direction = md.get("direction") + rows.append({ + "level": "MD", + "sign": md.get("sign"), + "sign_idx": md.get("sign_idx"), + "lord": md.get("lord"), + "start_age": md.get("start_age"), + "end_age": md.get("end_age"), + "duration_years": md.get("years"), + "duration_days": round(float(md.get("years", 0)) * 365.25, 2), + "direction": md_direction or md.get("sequence_profile"), + "seed_sign": SIGNS[md.get("seed_sign_idx", md.get("sign_idx", 0))], + "profile": { + "duration_profile": md.get("duration_profile"), + "sequence_profile": md.get("sequence_profile"), + "seed_profile": md.get("seed_profile"), + "dual_lord_profile": md.get("dual_lord_profile"), + "antardasha_profile": antardasha_profile, + }, + "source_formula_status": "local_md_profiled_v1", + "period_key": md.get("period_key"), + "sequence_index": md_index - 1, + }) + try: + antardashas = calc_narayana_antardasha( + mahadasha_periods, + md["sign_idx"], + planet_lons=planet_lons, + profile=antardasha_profile, + dual_lord_profile=dual_lord_profile, + md_period=md, + ) + ad_status = "local_ad_profiled_v1" + except ValueError as exc: + rows.append({ + "level": "AD", + "sign": None, + "start_age": md.get("start_age"), + "end_age": md.get("end_age"), + "duration_years": None, + "duration_days": None, + "direction": None, + "seed_sign": md.get("sign"), + "profile": { + "duration_profile": md.get("duration_profile"), + "sequence_profile": md.get("sequence_profile"), + "seed_profile": md.get("seed_profile"), + "dual_lord_profile": dual_lord_profile, + "antardasha_profile": antardasha_profile, + }, + "source_formula_status": "blocked_antardasha_profile_error", + "blocked_reason": str(exc), + "parent_md": md.get("sign"), + "parent_period_key": md.get("period_key"), + }) + continue + for ad_index, ad in enumerate(antardashas, start=1): + rows.append({ + "level": "AD", + "sign": ad.get("sign"), + "sign_idx": ad.get("sign_idx"), + "lord": ad.get("lord"), + "start_age": ad.get("start_age"), + "end_age": ad.get("end_age"), + "duration_years": ad.get("years"), + "duration_days": round(float(ad.get("years", 0)) * 365.25, 2), + "direction": ad.get("direction") or md.get("sequence_profile"), + "seed_sign": ad.get("start_sign_source") or md.get("sign"), + "profile": { + "duration_profile": md.get("duration_profile"), + "sequence_profile": md.get("sequence_profile"), + "seed_profile": md.get("seed_profile"), + "dual_lord_profile": dual_lord_profile, + "antardasha_profile": antardasha_profile, + }, + "source_formula_status": ad.get("evidence_status", ad_status), + "parent_md": md.get("sign"), + "parent_period_key": md.get("period_key"), + "sequence_index": ad_index - 1, + }) + return rows + + def narayana_dasha_full_report( lagna_sign_idx: int, planet_lons: Dict[str, float], current_age: float = 0, birth_year: int = 0, + duration_profile: str = "legacy_forward_v0", + sequence_profile: str = "legacy_zodiacal_v0", + seed_profile: str = "legacy_lagna_v0", + dual_lord_profile: str = "legacy_primary_lord_v0", + antardasha_profile: str = "legacy_weighted_v0", + cycle_count: int = 1, ) -> Dict: """ Narayana Dasha 完整报告。 @@ -322,6 +871,29 @@ def narayana_dasha_full_report( result['total_cycle_years'] = total_cycle result['lagna_sign'] = SIGNS[lagna_sign_idx] result['lagna_sign_idx'] = lagna_sign_idx + result['duration_profile'] = duration_profile + result['sequence_profile'] = sequence_profile + result['seed_profile'] = seed_profile + result['dual_lord_profile'] = dual_lord_profile + result['antardasha_profile'] = antardasha_profile + result['cycle_count'] = cycle_count + result['seed_selection'] = ( + select_narayana_seed_sign(lagna_sign_idx, planet_lons) + if seed_profile == "jaimini_lagna_seventh_strength_v1" + else { + "status": "selected", + "profile": "legacy_lagna_v0", + "selected_sign_idx": lagna_sign_idx, + "selected_sign": SIGNS[lagna_sign_idx], + } + ) + result['dense_boundaries'] = build_narayana_dense_boundaries( + mahadasha, + planet_lons=planet_lons, + antardasha_profile=antardasha_profile, + dual_lord_profile=dual_lord_profile, + ) + result['dense_boundary_schema'] = "narayana_dense_boundaries.v1" # 2. 当前大运 if current_age > 0: diff --git a/scripts/report_orchestrator.py b/scripts/report_orchestrator.py index a0d24aff..9b11b341 100644 --- a/scripts/report_orchestrator.py +++ b/scripts/report_orchestrator.py @@ -28,6 +28,16 @@ try: except ImportError: # pragma: no cover - package import path from scripts.consultation_domain_registry import CANONICAL_DOMAINS, normalize_domain +try: + from report_language_contract import build_report_language_contract_audit +except Exception: # pragma: no cover - module is research-only and not vendored + try: + from scripts.report_language_contract import build_report_language_contract_audit + except Exception: + def build_report_language_contract_audit(rendered): + return {"status": "blocked", "reason": "report_language_contract_module_absent"} + + # ═══════════════════════════════════════════════════════════════ # 核心枚举与常量 @@ -1134,6 +1144,22 @@ def render_reader_report(reader_report: Dict[str, Any]) -> Dict[str, Any]: thematic_narrative = reader_report.get("thematic_narrative") supplied_appendix = reader_report.get("evidence_appendix") evidence_appendix = dict(supplied_appendix) if isinstance(supplied_appendix, dict) else {} + dasha_boundary_packets = reader_report.get("dasha_boundary_packets") + ashtottari_packet = reader_report.get("ashtottari_report_boundary_packet") + startrack_language_bridge = reader_report.get("startrack_language_bridge") + if (isinstance(dasha_boundary_packets, dict) and dasha_boundary_packets) or (isinstance(ashtottari_packet, dict) and ashtottari_packet): + reference_parity = evidence_appendix.get("reference_parity") + reference_parity = dict(reference_parity) if isinstance(reference_parity, dict) else {} + if isinstance(dasha_boundary_packets, dict) and dasha_boundary_packets: + reference_parity.setdefault("dasha_boundary_packets", dasha_boundary_packets) + if isinstance(ashtottari_packet, dict) and ashtottari_packet: + reference_parity.setdefault("ashtottari_pl9_boundary_packet", ashtottari_packet) + evidence_appendix["reference_parity"] = reference_parity + if isinstance(startrack_language_bridge, dict) and startrack_language_bridge: + language_bridge = evidence_appendix.get("language_bridge") + language_bridge = dict(language_bridge) if isinstance(language_bridge, dict) else {} + language_bridge.setdefault("startrack_language_bridge", startrack_language_bridge) + evidence_appendix["language_bridge"] = language_bridge presentation_mode = reader_report.get("presentation_mode") if not presentation_mode and isinstance(executive_summary, dict): presentation_mode = executive_summary.get("presentation_mode") @@ -1152,6 +1178,13 @@ def render_reader_report(reader_report: Dict[str, Any]) -> Dict[str, Any]: "evidence_appendix": evidence_appendix, } rendered.update(extensions) + language_bridge = rendered["evidence_appendix"].get("language_bridge") + language_bridge = dict(language_bridge) if isinstance(language_bridge, dict) else {} + language_bridge.setdefault( + "report_language_contract_audit", + build_report_language_contract_audit(rendered), + ) + rendered["evidence_appendix"]["language_bridge"] = language_bridge return rendered diff --git a/scripts/report_pack_contract.py b/scripts/report_pack_contract.py new file mode 100644 index 00000000..b5823b6a --- /dev/null +++ b/scripts/report_pack_contract.py @@ -0,0 +1,254 @@ +"""Shared report-pack contract normalizer. + +This module intentionally stays thin: it does not compute astrology results and +does not adjudicate pack truth. It converts existing pack-shaped dictionaries +into one stable envelope that the final PL9-style renderer can consume. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +try: + from canonical_jyotish_profile import ( + build_canonical_jyotish_profile, + build_disputed_method_policy, + build_reader_engine_boundary_notice, + ) +except Exception: # pragma: no cover - research helpers are not vendored here + try: + from scripts.canonical_jyotish_profile import ( + build_canonical_jyotish_profile, + build_disputed_method_policy, + build_reader_engine_boundary_notice, + ) + except Exception: + def build_canonical_jyotish_profile(): + return {"profile_id": "product_canonical", "status": "local_fallback"} + + def build_disputed_method_policy(): + return {} + + def build_reader_engine_boundary_notice(): + return {"status": "local_fallback"} +try: + from profile_aware_benchmark_boundary_dashboard import load_dashboard_payload +except Exception: # pragma: no cover - research helpers are not vendored here + try: + from scripts.profile_aware_benchmark_boundary_dashboard import load_dashboard_payload + except Exception: + def load_dashboard_payload(): + return {"status": "blocked", "reason": "profile_aware_dashboard_absent"} + + +SCHEMA = "pl9.unified_report_pack_contract.v1" + + +def normalize_report_pack_contract(pack: Mapping[str, Any], *, pack_id: str) -> dict[str, Any]: + """Return a uniform report-ready envelope for an existing pack.""" + + report_sections = _normalize_report_sections(pack.get("report_sections")) + audit = _normalize_audit(pack) + pl9_pages = _as_list(pack.get("pl9_pages") or audit.get("pl9_pages")) + blocked_reasons = _blocked_reasons(pack, report_sections) + + contract = { + "schema": SCHEMA, + "pack_id": pack_id, + "source_schema": pack.get("schema"), + "status": _status(pack, audit), + "profile": dict(pack.get("profile") or {}), + "pl9_pages": pl9_pages, + "summary": dict(pack.get("summary") or {}), + "canonical_jyotish_profile": dict(pack.get("canonical_jyotish_profile") or build_canonical_jyotish_profile()), + "reader_engine_boundary_notice": dict( + pack.get("reader_engine_boundary_notice") or build_reader_engine_boundary_notice() + ), + "multi_engine_difference_notice": _multi_engine_difference_notice(pack), + "profile_aware_dashboard": dict(pack.get("profile_aware_dashboard") or load_dashboard_payload()), + "raw_data": _raw_data(pack), + "normalized_data": _normalized_data(pack), + "report_sections": report_sections, + "exports": _normalize_exports(pack.get("exports")), + "audit": audit, + "blocked_reasons": blocked_reasons, + "contract_audit": { + "missing_fields": _missing_fields(report_sections), + "source_pack_keys": list(pack.keys()), + "normalization_boundary": "contract_only_no_astrological_recalculation", + }, + } + return contract + + +def _status(pack: Mapping[str, Any], audit: Mapping[str, Any]) -> str: + explicit = pack.get("status") or audit.get("status") + if explicit: + return str(explicit) + + statuses: list[str] = [] + report_sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {} + for key in ("executive_summary", "thematic_narrative", "evidence_appendix", "pdf_sections"): + section = report_sections.get(key) + if isinstance(section, Mapping) and section.get("status"): + statuses.append(str(section.get("status"))) + for key, value in pack.items(): + if key in {"schema", "profile", "pl9_pages", "summary", "report_sections", "exports", "audit"}: + continue + if isinstance(value, Mapping) and value.get("status"): + statuses.append(str(value.get("status"))) + + if not statuses: + return "blocked" + if any(status in {"conflict", "parameter_sensitive"} for status in statuses): + return "parameter_sensitive" + if any(status in {"partial_verified", "verified"} for status in statuses): + return "partial_verified" + if any(status == "not_applicable" for status in statuses): + return "not_applicable" + return "blocked" + + +def _normalize_report_sections(value: Any) -> dict[str, Any]: + sections = dict(value or {}) if isinstance(value, Mapping) else {} + executive = sections.get("executive_summary") + thematic = sections.get("thematic_narrative") + evidence = sections.get("evidence_appendix") + pdf_sections = sections.get("pdf_sections") + + if isinstance(executive, Mapping): + executive = [str(executive.get("headline") or executive.get("status") or "")] + elif isinstance(executive, str): + executive = [executive] + elif executive is None: + executive = [] + else: + executive = list(executive) + + if thematic is None: + thematic = sections.get("trigger_seed_narrative") or [] + if evidence is None: + evidence = [] + for key in ("visual_chart_audit", "audit_appendix"): + if key in sections: + evidence.append(sections[key]) + + return { + "executive_summary": executive, + "thematic_narrative": list(thematic) if isinstance(thematic, list) else _as_list(thematic), + "evidence_appendix": list(evidence) if isinstance(evidence, list) else _as_list(evidence), + "pdf_sections": list(pdf_sections) if isinstance(pdf_sections, list) else _as_list(pdf_sections), + } + + +def _normalize_exports(value: Any) -> dict[str, Any]: + exports = dict(value or {}) if isinstance(value, Mapping) else {} + return { + "json": exports.get("json"), + "markdown": exports.get("markdown"), + "pdf_sections": exports.get("pdf_sections"), + "ai_evidence_bundle": dict(exports.get("ai_evidence_bundle") or {}), + } + + +def _multi_engine_difference_notice(pack: Mapping[str, Any]) -> dict[str, Any]: + if isinstance(pack.get("multi_engine_difference_notice"), Mapping): + return dict(pack["multi_engine_difference_notice"]) + policy = build_disputed_method_policy() + lanes = [] + for lane_id, lane in policy.items(): + lanes.append( + { + "lane_id": lane_id, + "canonical_standard": lane["canonical_standard"], + "external_difference_display": "external_observation_conflict", + "report_assertion_ceiling": lane["report_assertion_ceiling"], + "reader_text_zh": ( + f"{lane_id} 主口径按 {lane['canonical_standard']};" + "其他引擎若不同,会显示为外部观察冲突,不直接改写主结论。" + ), + } + ) + return { + "status": "active", + "purpose": "reader_safe_multi_engine_difference_display", + "canonical_profile_id": build_canonical_jyotish_profile()["profile_id"], + "lanes": lanes, + "must_not_claim": [ + "external_engine_conflict_overrides_canonical_result", + "internal_consistency_is_global_truth_closure", + ], + } + + +def _normalize_audit(pack: Mapping[str, Any]) -> dict[str, Any]: + audit = dict(pack.get("audit") or {}) + must_not_claim = list(audit.get("must_not_claim") or []) + sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {} + for key in ("visual_chart_audit", "evidence_audit"): + section = sections.get(key) if isinstance(sections, Mapping) else None + if isinstance(section, Mapping): + must_not_claim.extend(item for item in section.get("must_not_claim") or [] if item not in must_not_claim) + if must_not_claim: + audit["must_not_claim"] = must_not_claim + return audit + + +def _blocked_reasons(pack: Mapping[str, Any], report_sections: Mapping[str, Any]) -> list[str]: + reasons: list[str] = [] + for key in ("blocked_reasons", "blocked_fields"): + reasons.extend(str(item) for item in pack.get(key) or []) + original_sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {} + executive = original_sections.get("executive_summary") if isinstance(original_sections, Mapping) else None + if isinstance(executive, Mapping): + reasons.extend(str(item) for item in executive.get("blocked_fields") or []) + if not reasons and _status(pack, pack.get("audit") or {}) == "blocked": + for item in report_sections.get("evidence_appendix") or []: + if isinstance(item, str): + reasons.append(item) + return list(dict.fromkeys(reasons)) + + +def _raw_data(pack: Mapping[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in pack.items() + if key + not in { + "schema", + "status", + "profile", + "pl9_pages", + "summary", + "report_sections", + "exports", + "audit", + "profile_aware_dashboard", + } + } + + +def _normalized_data(pack: Mapping[str, Any]) -> dict[str, Any]: + return { + "source_schema": pack.get("schema"), + "status": pack.get("status") or (pack.get("audit") or {}).get("status") if isinstance(pack.get("audit"), Mapping) else pack.get("status"), + } + + +def _missing_fields(report_sections: Mapping[str, Any]) -> list[str]: + missing = [] + for key in ("executive_summary", "thematic_narrative", "evidence_appendix"): + if not report_sections.get(key): + missing.append(key) + return missing + + +def _as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + return [value] diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 1c2fd138..bd4daaa5 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -145,6 +145,7 @@ RELEASE_CRITICAL_UNTRACKED_PATHS = [ QUALITY_GATE_PROFILES = { "quick": { + "test_timeout_seconds": 90, "skip_slow": True, "skip_yoga_logic": True, "skip_frontend_runtime": False, @@ -156,6 +157,7 @@ QUALITY_GATE_PROFILES = { "skip_vedastro_live": True, }, "browser": { + "test_timeout_seconds": 120, "skip_slow": True, "skip_yoga_logic": True, "skip_frontend_runtime": False, @@ -167,6 +169,7 @@ QUALITY_GATE_PROFILES = { "skip_vedastro_live": True, }, "release": { + "test_timeout_seconds": 600, "skip_slow": False, "skip_yoga_logic": False, "skip_frontend_runtime": False, @@ -178,6 +181,7 @@ QUALITY_GATE_PROFILES = { "skip_vedastro_live": True, }, "accuracy": { + "test_timeout_seconds": 300, "skip_slow": True, "skip_yoga_logic": False, "skip_frontend_runtime": True, @@ -189,6 +193,7 @@ QUALITY_GATE_PROFILES = { "skip_vedastro_live": True, }, "vedastro-live": { + "test_timeout_seconds": 120, "skip_slow": True, "skip_yoga_logic": True, "skip_frontend_runtime": True, @@ -200,6 +205,7 @@ QUALITY_GATE_PROFILES = { "skip_vedastro_live": False, }, "runtime-truth": { + "test_timeout_seconds": 180, "skip_slow": True, "skip_yoga_logic": True, "skip_frontend_runtime": True, @@ -515,6 +521,13 @@ def main() -> int: parser.add_argument("--skip-local-accuracy-report", action="store_true", help="Skip consolidated local accuracy report") parser.add_argument("--skip-vedastro-live", action="store_true", help="Skip optional VedAstro live endpoint smoke") parser.add_argument("--all-tests", action="store_true", help="Run every pytest file, including optional-dependency suites") + parser.add_argument( + "--test-timeout", + type=float, + default=None, + metavar="SECONDS", + help="Fail an individual pytest call after SECONDS; defaults to the selected profile boundary.", + ) parser.add_argument("--require-external-parity", action="store_true", help="Fail the release gate unless the three-engine raw parity manifest passes.") args = parser.parse_args() profile = run_profile(args) diff --git a/scripts/solar_return.py b/scripts/solar_return.py index b5e1a963..e2c1da70 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -70,6 +70,24 @@ def _estimate_sun_sign(birth_month: int, birth_day: int) -> int: return 0 # fallback +def _muntha_from_natal_ascendant(natal_asc_sign_idx: int, completed_years: int) -> Dict: + """Advance Muntha from the natal ascendant, one sign per completed year.""" + muntha_sign_idx = (int(natal_asc_sign_idx) + int(completed_years)) % 12 + return { + 'status': 'used', + 'muntha_sign_idx': muntha_sign_idx, + 'muntha_sign': SIGNS[muntha_sign_idx], + 'muntha_lord': SIGN_LORDS[SIGNS[muntha_sign_idx]], + 'natal_asc_sign_idx': int(natal_asc_sign_idx), + 'natal_asc_sign': SIGNS[int(natal_asc_sign_idx)], + 'age': int(completed_years), + 'formula': ( + f'(natal ascendant {SIGNS[int(natal_asc_sign_idx)]} + ' + f'completed years {int(completed_years)}) mod 12 = {SIGNS[muntha_sign_idx]}' + ), + } + + # ========================================================================= # 工具函数 # ========================================================================= @@ -115,7 +133,7 @@ def _datetime_to_jd_ut(dt: datetime) -> float: def _get_sun_lon_jd(jd_ut: float, ayanamsa_name: str = 'lahiri') -> Optional[float]: - """计算给定 JD (UT) 的太阳恒星黄经(Lahiri)""" + """计算给定 JD (UT) 的太阳恒星黄经(默认 Raman)""" if not HAS_SWE: return None try: @@ -138,7 +156,7 @@ def find_solar_return_ut( tz: float = 0.0, max_iter: int = 30, tol_deg: float = 0.0003, # ~1 arcsec - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """ 计算太阳返照(Solar Return)精确 UT 时刻。 @@ -176,7 +194,7 @@ def _find_solar_return_swe( target_year: int, max_iter: int, tol_deg: float, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """使用 swisseph 精确计算太阳返照时刻(Newton 迭代法)""" # 近似起始点:出生日期在目标年份的同一天 @@ -278,7 +296,7 @@ def calc_solar_return_chart( birth_hour: int, birth_minute: int, birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """ 计算太阳返照盘(Varshaphala)。 @@ -402,7 +420,7 @@ def solar_return_full_report( birth_hour: int, birth_minute: int, birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, - ayanamsa_name: str = 'lahiri', + ayanamsa_name: str = 'raman', ) -> Dict: """ 太阳返照盘完整报告(Varshaphala 年运分析)。 @@ -456,27 +474,31 @@ def solar_return_full_report( 'age': sr['age'], } planet_lons = sr['planet_lons'] + sr_planets = sr.get('chart', {}).get('planets', {}) + tajika_planets = { + pn: { + 'longitude': pd.get('degree_raw', pd.get('degree')), + 'speed': pd.get('speed'), + } + for pn, pd in sr_planets.items() + if pn in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn') + and isinstance(pd, dict) + and pd.get('degree_raw', pd.get('degree')) is not None + and pd.get('speed') is not None + } asc_sign_idx = sr['asc_sign_idx'] - # 2. Muntha(正确算法:从返照盘太阳星座开始数) + # 2. Muntha advances from the natal ascendant, not the solar sign. try: - # Solar return sun sign = birth sun sign (by definition of solar return) - sr_sun_sign = int(planet_lons.get('Sun', 0) / 30) % 12 - muntha_sign = (sr_sun_sign + age) % 12 - muntha_note = '' - if degraded: - muntha_note = '(基于查表法估算太阳星座,实际Muntha可能偏差±1星座)' - result['muntha'] = { - 'muntha_sign_idx': muntha_sign, - 'muntha_sign': SIGNS[muntha_sign], - 'muntha_lord': SIGN_LORDS[SIGNS[muntha_sign]], - 'sr_sun_sign_idx': sr_sun_sign, - 'sr_sun_sign': SIGNS[sr_sun_sign], - 'age': age, - 'formula': f'(返照盘太阳星座{sr_sun_sign} + 年龄{age}) mod 12 = {muntha_sign}', - 'interpretation': _interpret_muntha_sign(muntha_sign), - 'note': muntha_note, - } + natal_asc = sr.get('birth_asc_sign_idx') if not degraded else None + if natal_asc is None: + result['muntha'] = { + 'status': 'blocked', + 'reason': 'natal_ascendant_required_for_muntha', + } + else: + result['muntha'] = _muntha_from_natal_ascendant(natal_asc, age) + result['muntha']['interpretation'] = _interpret_muntha_sign(result['muntha']['muntha_sign_idx']) except Exception as e: result['muntha'] = {'error': str(e)} @@ -500,7 +522,7 @@ def solar_return_full_report( else: try: from tajika import calc_tajika_yogas - yogas = calc_tajika_yogas(planet_lons, chart_type='varsha') + yogas = calc_tajika_yogas(tajika_planets, chart_type='varsha') result['tajika_yogas'] = yogas except Exception as e: result['tajika_yogas'] = {'error': str(e)} @@ -512,8 +534,18 @@ def solar_return_full_report( try: from tajika import calc_all_sahams asc_lon = sr['ascendant'].get('lon', sr['ascendant'].get('degree', 0)) - sr_dt_ut = sr['solar_return']['dt_ut'] - sahams = calc_all_sahams(planet_lons, asc_lon, sr_dt_ut, chart_type='varsha') + sr_dt_local = sr['solar_return'].get('dt_local') + if sr_dt_local is None: + raise ValueError('solar_return_local_datetime_required_for_saham_daynight') + sahams = calc_all_sahams( + planet_lons, + asc_lon, + sr_dt_local, + chart_type='varsha', + lat=birth_lat, + lon=birth_lon, + tz=birth_tz, + ) result['sahams'] = sahams except Exception as e: result['sahams'] = {'error': str(e)} diff --git a/scripts/tajika.py b/scripts/tajika.py index ba243718..0f62c118 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -17,6 +17,9 @@ from datetime import datetime import math import json import os +import shutil +import subprocess +import sys SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] @@ -671,7 +674,7 @@ def calc_tajika_yogas(planet_lons: Dict[str, float], results['yogas'].append(yoga) # ── 2. Easarapha Yoga(分离瑜伽)── - # 定义:两行星同星座,快行星已离开慢行星(separate),度数差<=3度 + # 定义:两行星同星座,且 shared motion contract 判定为 separating for i, p1 in enumerate(Planet_LIST): if p1 not in planet_lons: continue @@ -686,28 +689,23 @@ def calc_tajika_yogas(planet_lons: Dict[str, float], fast = p1 if _is_faster(p1, p2) else p2 slow = p2 if fast == p1 else p1 - - fast_lon = planet_lons[fast] - slow_lon = planet_lons[slow] - # 分离:快行星已过慢行星(在慢行星"后面") - # 即慢行星度数 > 快行星度数(在同一圈内) - slow_in_sign = slow_lon % 30 - fast_in_sign = fast_lon % 30 - if slow_in_sign > fast_in_sign: - # 慢在前面,快在后面 → 分离状态 - diff = slow_in_sign - fast_in_sign - if diff <= 3.0: - yoga = { - 'type': 'Easarapha', - 'fast_planet': fast, - 'slow_planet': slow, - 'degree_diff': round(diff, 4), - 'sign': SIGNS[sign1], - 'strength': 'strong' if diff <= 1.0 else 'moderate', - 'interpretation': _easarapha_interp(fast, slow, diff), - } - results['easarapha'].append(yoga) - results['yogas'].append(yoga) + shared_pair = evaluate_tajika_pair(p1, p2, motion_payload) + if shared_pair is None or shared_pair.get('motion') != 'separating': + continue + diff = abs(float(shared_pair.get('residual', 0.0))) + yoga = { + 'type': 'Easarapha', + 'fast_planet': fast, + 'slow_planet': slow, + 'degree_diff': round(diff, 4), + 'sign': SIGNS[sign1], + 'strength': 'strong' if diff <= 1.0 else 'moderate', + 'interpretation': _easarapha_interp(fast, slow, diff), + 'shared_motion': shared_pair.get('motion'), + 'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'), + } + results['easarapha'].append(yoga) + results['yogas'].append(yoga) # ── 3. Nakta Yoga(夜间瑜伽)── # 定义:太阳和月亮同星座(任何度数) @@ -909,6 +907,206 @@ def _is_daytime(birth_dt: datetime, sun_lon: float, asc_lon: float) -> bool: return sun_house in (1, 2, 3, 4, 5, 6) +def _year_lord_is_daytime( + *, + annual_dt: Optional[datetime], + lat: Optional[float], + lon: Optional[float], + tz: Optional[float], + sun_lon: float, + asc_lon: float, +) -> bool: + if annual_dt is not None and lat is not None and lon is not None and tz is not None: + try: + from saham_daynight import determine_daytime + return bool(determine_daytime(annual_dt, lat=float(lat), lon=float(lon), tz=float(tz))['is_daytime']) + except Exception: + pass + if annual_dt is None: + annual_dt = datetime.utcnow() + return _is_daytime(annual_dt, sun_lon, asc_lon) + + +def _benefic_aspect_targets(source_sign_idx: int) -> List[int]: + source_sign_idx = int(source_sign_idx) % 12 + return [ + (source_sign_idx + 4) % 12, + (source_sign_idx + 8) % 12, + (source_sign_idx + 2) % 12, + (source_sign_idx + 10) % 12, + ] + + +def _malefic_aspect_targets(source_sign_idx: int) -> List[int]: + source_sign_idx = int(source_sign_idx) % 12 + return [ + source_sign_idx, + (source_sign_idx + 3) % 12, + (source_sign_idx + 9) % 12, + (source_sign_idx + 6) % 12, + ] + + +def _resolve_year_lord_panchavargiya_score( + planet: str, + lon_value: float, + *, + annual_jd: Optional[float], + lat: Optional[float], + lon: Optional[float], + tz: Optional[float], +) -> Dict: + external = _external_pyjhora_panchavargiya_score( + planet, + annual_jd=annual_jd, + lat=lat, + lon=lon, + tz=tz, + ) + if external is not None: + return external + proxy = _calc_panchavargiya_bala_for_planet(planet, lon_value) + return { + 'score': proxy['score'], + 'engine': 'native_proxy', + 'components': proxy['components'], + } + + +def _external_pyjhora_panchavargiya_score( + planet: str, + *, + annual_jd: Optional[float], + lat: Optional[float], + lon: Optional[float], + tz: Optional[float], +) -> Optional[Dict]: + if annual_jd is None or lat is None or lon is None or tz is None: + return None + try: + from jhora.panchanga import drik as j_drik + from jhora.horoscope.chart import strength as j_strength + place = j_drik.Place('annual_year_lord_probe', float(lat), float(lon), float(tz)) + scores = j_strength.pancha_vargeeya_bala(float(annual_jd), place) + planet_idx = PLANET_TO_INDEX.get(planet) + if planet_idx is None: + return None + score = scores[planet_idx] + return { + 'score': float(score), + 'engine': 'pyjhora', + 'components': {'pyjhora_pancha_vargeeya_bala': float(score)}, + } + except Exception: + return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz) + + +def _external_pyjhora_panchavargiya_score_sidecar( + planet: str, + annual_jd: float, + lat: float, + lon: float, + tz: float, +) -> Optional[Dict]: + """Read optional PyJHora Panchavargiya Bala scores via an external Python. + + PyJHora stays an optional black-box oracle. We do not vendor it, import it + as a hard dependency, or fail native execution when it is absent. + """ + scores = _external_pyjhora_panchavargiya_scores_sidecar(annual_jd, lat, lon, tz) + if not scores or planet not in scores: + return None + score = scores[planet] + return { + 'score': score, + 'engine': 'pyjhora_sidecar', + 'components': { + 'pyjhora_pancha_vargeeya_bala_sidecar': score, + 'scores_by_planet': scores, + 'license_boundary': 'external_reference_only_do_not_vendor', + }, + } + + +def _external_pyjhora_panchavargiya_scores_sidecar( + annual_jd: float, + lat: float, + lon: float, + tz: float, +) -> Optional[Dict[str, float]]: + key = (round(float(annual_jd), 8), round(float(lat), 6), round(float(lon), 6), round(float(tz), 4)) + if key in _PYJHORA_PVB_SIDECAR_CACHE: + return _PYJHORA_PVB_SIDECAR_CACHE[key] + executable = _resolve_external_pyjhora_python() + if not executable: + _PYJHORA_PVB_SIDECAR_CACHE[key] = None + return None + code = """ +import contextlib +import io +import json +import sys +with contextlib.redirect_stdout(io.StringIO()): + from jhora.panchanga import drik as j_drik + from jhora.horoscope.chart import strength as j_strength + place = j_drik.Place('annual_year_lord_probe', float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4])) + scores = j_strength.pancha_vargeeya_bala(float(sys.argv[1]), place) +print(json.dumps({str(k): float(v) for k, v in dict(scores).items()})) +""" + try: + completed = subprocess.run( + [executable, '-c', code, str(float(annual_jd)), str(float(lat)), str(float(lon)), str(float(tz))], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + parsed = json.loads(completed.stdout) + scores = { + INDEX_TO_PLANET[int(idx)]: float(score) + for idx, score in parsed.items() + if int(idx) in INDEX_TO_PLANET + } + _PYJHORA_PVB_SIDECAR_CACHE[key] = scores or None + return _PYJHORA_PVB_SIDECAR_CACHE[key] + except Exception: + _PYJHORA_PVB_SIDECAR_CACHE[key] = None + return None + + +def _resolve_external_pyjhora_python() -> Optional[str]: + candidates = [ + os.environ.get('JYOTISH_PYJHORA_PYTHON'), + shutil.which('python3.14'), + shutil.which('python3'), + ] + current = os.path.abspath(sys.executable) + for candidate in candidates: + if not candidate: + continue + candidate = os.path.abspath(candidate) + if not os.path.exists(candidate): + continue + # Do not compare realpaths here: a venv executable can resolve to the + # managed Python binary while still having a different site-packages + # view. The direct managed interpreter may have optional PyJHora even + # when the current venv does not. + if candidate == current: + continue + try: + completed = subprocess.run( + [candidate, '-c', "import importlib.util; raise SystemExit(0 if importlib.util.find_spec('jhora') else 1)"], + capture_output=True, + text=True, + timeout=8, + ) + except Exception: + continue + if completed.returncode == 0: + return candidate + return None + + def calc_all_sahams(planet_lons: Dict[str, float], asc_lon: float, birth_dt: datetime, @@ -1239,10 +1437,30 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic """计算两星间的有效容许度(取较小者)""" return min(TAJIKA_ORBS.get(p1, 7), TAJIKA_ORBS.get(p2, 7)) + def _build_shared_motion_payload(): + try: + from scripts.tajika_kernel import evaluate_tajika_pair + except ModuleNotFoundError: # pragma: no cover + from tajika_kernel import evaluate_tajika_pair + + speeds = { + 'Moon': 13.176, 'Mercury': 4.092, 'Venus': 1.602, + 'Sun': 0.986, 'Mars': 0.524, 'Jupiter': 0.083, 'Saturn': 0.034, + } + payload = { + planet: { + 'longitude': _get_longitude(planet), + 'speed': speeds.get(planet, 0.0), + } + for planet in SEVEN + } + return evaluate_tajika_pair, payload + # ── 1. Ithasala Yoga(连接瑜伽)完整版 ── # 条件:快星追赶慢星(applying),orb ≤ 有效容许度 ithasala_pairs = [] checked = set() + evaluate_tajika_pair, motion_payload = _build_shared_motion_payload() for p1 in SEVEN: for p2 in SEVEN: if p1 >= p2: @@ -1261,17 +1479,14 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic fast = p1 if _is_faster(p1, p2) else p2 slow = p2 if fast == p1 else p1 - fast_lon = _get_longitude(fast) - slow_lon = _get_longitude(slow) - - # 判断是否applying(快追慢) - # 快星度数 < 慢星度数(同一方向)= applying - applying = (fast_lon % 30) < (slow_lon % 30) - - if applying or orb <= 3.0: # 3°内视为紧密连接 + shared_pair = evaluate_tajika_pair(p1, p2, motion_payload) + if shared_pair is not None and shared_pair.get('motion') == 'applying': ithasala_pairs.append({ 'fast': fast, 'slow': slow, 'orb': orb, - 'fast_lon': fast_lon, 'slow_lon': slow_lon, + 'fast_lon': _get_longitude(fast), 'slow_lon': _get_longitude(slow), + 'shared_motion': shared_pair.get('motion'), + 'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'), + 'shared_residual': shared_pair.get('residual'), }) for pair in ithasala_pairs: diff --git a/scripts/tajika_kernel.py b/scripts/tajika_kernel.py index 14c01431..f1a97129 100644 --- a/scripts/tajika_kernel.py +++ b/scripts/tajika_kernel.py @@ -27,6 +27,41 @@ def _nearest_aspect(delta: float) -> tuple[float, float]: return min(candidates, key=lambda item: abs(item[1])) +def evaluate_tajika_pair( + left: str, + right: str, + planets: dict[str, dict[str, Any]], +) -> dict[str, Any] | None: + """Return the shared motion/orb contract for one seven-planet Tajika pair.""" + if left not in planets or right not in planets: + return None + left_payload = planets[left] + right_payload = planets[right] + if not isinstance(left_payload, dict) or not isinstance(right_payload, dict): + return None + if "longitude" not in left_payload or "longitude" not in right_payload: + return None + if "speed" not in left_payload or "speed" not in right_payload: + return None + + left_lon, right_lon = float(left_payload["longitude"]) % 360, float(right_payload["longitude"]) % 360 + aspect, residual = _nearest_aspect(right_lon - left_lon) + orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0 + if abs(residual) > orb: + return None + relative_speed = float(right_payload["speed"]) - float(left_payload["speed"]) + future_residual = _signed_angle(residual + relative_speed) + applying = abs(future_residual) < abs(residual) + return { + "planets": [left, right], + "aspect": abs(aspect), + "residual": round(residual, 6), + "average_deeptamsa": orb, + "motion": "applying" if applying else "separating", + "within_deeptamsa": True, + } + + def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: missing = [planet for planet in SEVEN_PLANETS if planet not in planets or "longitude" not in planets[planet] or "speed" not in planets[planet]] if missing: @@ -40,22 +75,9 @@ def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[st interactions = [] for index, left in enumerate(SEVEN_PLANETS): for right in SEVEN_PLANETS[index + 1:]: - left_lon, right_lon = float(planets[left]["longitude"]) % 360, float(planets[right]["longitude"]) % 360 - aspect, residual = _nearest_aspect(right_lon - left_lon) - orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0 - if abs(residual) > orb: - continue - relative_speed = float(planets[right]["speed"]) - float(planets[left]["speed"]) - future_residual = _signed_angle(residual + relative_speed) - applying = abs(future_residual) < abs(residual) - interactions.append({ - "planets": [left, right], - "aspect": abs(aspect), - "residual": round(residual, 6), - "average_deeptamsa": orb, - "motion": "applying" if applying else "separating", - "within_deeptamsa": True, - }) + pair = evaluate_tajika_pair(left, right, planets) + if pair is not None: + interactions.append(pair) return { "scope": "tajika_seven_planet_kernel", "status": "partial", @@ -74,6 +96,12 @@ def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[st } for row in interactions ], - "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola", "Ithasala/Easarapha adjudication"], - "boundary": "Candidate labels are derived only from seven-planet aspect, Deeptamsa and applying/separating evidence. Full named-yoga chains and event verdicts remain blocked pending classic golden cases.", + "supported_named_yogas": ["Ithasala", "Easarapha"], + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola"], + "authority_lane": "scripts/tajika_named_yoga_authority.py", + "boundary": ( + "Seven-planet aspect, Deeptamsa, and applying/separating evidence are " + "admissible for governed Ithasala/Easarapha authority. Broader named-yoga " + "chains and event verdicts remain blocked pending classic golden cases." + ), } diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index cff599a1..45b2282c 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -5,7 +5,8 @@ from __future__ import annotations import json import re -from dataclasses import dataclass +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -54,6 +55,24 @@ try: except Exception: # pragma: no cover - import path varies in tests/CLI from scripts.cross_system_arbitrator import build_cross_system_arbitration +try: + from specialized_indian_closure_review import build_specialized_indian_closure_review +except Exception: # pragma: no cover - research helper is not vendored here + try: + from scripts.specialized_indian_closure_review import build_specialized_indian_closure_review + except Exception: + def build_specialized_indian_closure_review(**kwargs): + return {"status": "blocked", "reason": "specialized_indian_closure_review_absent"} + +try: + from finance_astrology_support_review import build_finance_astrology_support_review +except Exception: # pragma: no cover - research helper is not vendored here + try: + from scripts.finance_astrology_support_review import build_finance_astrology_support_review + except Exception: + def build_finance_astrology_support_review(**kwargs): + return {"status": "blocked", "reason": "finance_astrology_support_review_absent"} + try: from functional_benefics import derive_functional_benefic_malefic except Exception: # pragma: no cover - import path varies in tests/CLI @@ -63,6 +82,22 @@ try: from real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest except Exception: # pragma: no cover - import path varies in tests/CLI from scripts.real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest +_EMPTY_METHODOLOGY_ROLES = { + "core": [], + "enhancement": [], + "adjudication": [], + "annual_trigger": [], + "blocked": [], +} +_DEFAULT_AUTHORITY_ORDER = ["core", "enhancement", "adjudication", "annual_trigger", "blocked"] +_DOCUMENT_ROUTE_BY_CANONICAL = { + "career": "career-timing-strict", + "marriage": "relationship-timing-strict", + "wealth": "finance-timing-strict", + "health": "health-timing-strict", + "timing": "event-timing-strict", + "general": "full-reading-strict", +} @dataclass(frozen=True) @@ -71,6 +106,8 @@ class RouteDefinition: primary_theme: str focus_techniques: list[str] display_label: str + methodology_roles: dict[str, list[str]] = field(default_factory=lambda: dict(_EMPTY_METHODOLOGY_ROLES)) + authority_order: list[str] = field(default_factory=lambda: list(_DEFAULT_AUTHORITY_ORDER)) class UnifiedConsultationOrchestrator: @@ -95,12 +132,19 @@ class UnifiedConsultationOrchestrator: "D10", "D2", "D4", + "D6", + "D8", + "D30", "planet_degrees", "house_degrees", "dasha_boundaries", + "narayana_dasha", "shadbala", "ashtakavarga", "yogas", + "functional_benefic_malefic", + "transit", + "non_medical_boundary", "UL", "A7", "A10", @@ -118,6 +162,14 @@ class UnifiedConsultationOrchestrator: primary_theme="career", focus_techniques=["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"], display_label="career", + methodology_roles={ + "core": ["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"], + "enhancement": ["A10"], + "adjudication": [], + "annual_trigger": [], + "blocked": [], + }, + authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"], ), "marriage": RouteDefinition( question_type="marriage", @@ -134,7 +186,17 @@ class UnifiedConsultationOrchestrator: "health": RouteDefinition( question_type="health", primary_theme="health", - focus_techniques=["D1", "D6", "D8", "Dasha", "Shadbala", "non-medical boundary"], + focus_techniques=[ + "D6", + "D8", + "D30", + "Dasha", + "Narayana Dasha", + "Shadbala", + "Transit", + "Functional Benefic/Malefic", + "non-medical boundary", + ], display_label="health", ), "education": RouteDefinition( @@ -142,6 +204,14 @@ class UnifiedConsultationOrchestrator: primary_theme="education", focus_techniques=["D5", "D24", "5th house", "9th house", "Dasha"], display_label="education", + methodology_roles={ + "core": ["D24", "D10", "Dasha", "Narayana Dasha", "Shadbala", "Transit"], + "enhancement": ["A10"], + "adjudication": [], + "annual_trigger": [], + "blocked": [], + }, + authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"], ), "migration": RouteDefinition( question_type="migration", @@ -166,12 +236,28 @@ class UnifiedConsultationOrchestrator: primary_theme="timing", focus_techniques=["Dasha", "Transit", "Double Transit", "Gochara"], display_label="timing", + methodology_roles={ + "core": ["Dasha", "Narayana Dasha", "Transit", "Double Transit", "Gochara"], + "enhancement": [], + "adjudication": [], + "annual_trigger": [], + "blocked": [], + }, + authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"], ), "general": RouteDefinition( question_type="general", primary_theme="general", focus_techniques=["D1", "D9", "Dasha", "Yoga", "Shadbala", "Ashtakavarga"], display_label="general", + methodology_roles={ + "core": ["D1", "D9", "Dasha", "Shadbala", "Ashtakavarga"], + "enhancement": ["Yoga"], + "adjudication": [], + "annual_trigger": [], + "blocked": [], + }, + authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"], ), } _SYNC_STEPS_BY_ROUTE = { @@ -228,6 +314,42 @@ class UnifiedConsultationOrchestrator: "general": {"D1", "D9"}, } + @staticmethod + def route_surface_contract(question_type: str) -> dict[str, Any]: + raw = str(question_type or "").strip().lower() + try: + runtime_route = normalize_domain(raw or "general") + except ValueError: + runtime_route = raw or "general" + document_route = _DOCUMENT_ROUTE_BY_CANONICAL.get( + runtime_route, + runtime_route.replace("_", "-"), + ) + registry_route = document_route.replace("-", "_") + alias_set = {runtime_route, registry_route, document_route, raw} - {""} + if runtime_route == "wealth": + alias_set.update({ + "finance", + "money", + "wealth-timing-strict", + "wealth_timing_strict", + "finance-timing-strict", + "finance_timing_strict", + }) + elif runtime_route == "marriage": + alias_set.update({"relationship", "relationship-timing-strict", "relationship_timing_strict"}) + elif runtime_route == "timing": + alias_set.update({"event-timing-strict", "event_timing_strict"}) + elif runtime_route == "general": + alias_set.update({"full-reading-strict", "full_reading_strict", "comprehensive"}) + return { + "runtime_route": runtime_route, + "registry_route": registry_route, + "document_route": document_route, + "audit_route": runtime_route, + "route_aliases": sorted(alias_set), + } + @classmethod def route_profile_contract(cls, route_name: str) -> dict[str, Any]: """Expose reader sections without replacing runtime technique audit.""" @@ -246,6 +368,19 @@ class UnifiedConsultationOrchestrator: "execution_boundary": "Profile labels do not replace Technique Audit Table execution status.", } + @classmethod + def evidence_packet_required_sections(cls, route_name: str | None = None) -> list[str]: + required = list(cls.EVIDENCE_PACKET_REQUIRED_SECTIONS) + try: + route = normalize_domain(route_name) if route_name else "general" + except ValueError: + route = route_name or "general" + if route == "health": + for section in ("D6", "D8", "D30", "narayana_dasha", "functional_benefic_malefic", "transit", "non_medical_boundary"): + if section not in required: + required.append(section) + return required + def normalize_themes(self, raw: Any) -> list[str]: return normalize_consultation_themes(raw) @@ -323,12 +458,14 @@ class UnifiedConsultationOrchestrator: @staticmethod def _route_packet(route: RouteDefinition, *, source: str) -> dict[str, Any]: + surface = UnifiedConsultationOrchestrator.route_surface_contract(route.question_type) return { "question_type": route.question_type, "primary_theme": route.primary_theme, "focus_techniques": list(route.focus_techniques), "display_label": route.display_label, "route_source": source, + **surface, } def route_profile(self, question: str, themes: list[str] | None = None) -> dict[str, Any]: @@ -467,6 +604,73 @@ class UnifiedConsultationOrchestrator: }, } + def _build_key_time_nodes( + self, + route_profile: dict[str, Any], + narrative: dict[str, Any], + ) -> list[dict[str, Any]]: + themes = list(route_profile.get("themes") or narrative.keys()) + nodes: list[dict[str, Any]] = [] + for theme in themes: + item = narrative.get(theme) + if not isinstance(item, dict): + continue + for section in ("timing_windows", "candidate_windows", "opportunity_windows"): + raw_nodes = item.get(section) + if not isinstance(raw_nodes, list): + continue + for raw_node in raw_nodes: + normalized = self._normalize_key_time_node(theme, section, raw_node) + if normalized: + nodes.append(normalized) + return nodes[:7] + + @staticmethod + def _normalize_key_time_node(theme: str, section: str, raw_node: Any) -> dict[str, Any] | None: + if isinstance(raw_node, str): + text = raw_node.strip() + if not text: + return None + return { + "theme": theme, + "section": section, + "label": text, + "window": text, + "status": "parameter_sensitive", + } + if not isinstance(raw_node, dict): + return None + label = str( + raw_node.get("label") + or raw_node.get("title") + or raw_node.get("name") + or raw_node.get("window") + or raw_node.get("date_range") + or raw_node.get("date") + or "" + ).strip() + window = str( + raw_node.get("window") + or raw_node.get("date_range") + or raw_node.get("date") + or raw_node.get("timeframe") + or label + ).strip() + if not label and not window: + return None + node = { + "theme": theme, + "section": section, + "label": label or window, + "window": window or label, + "status": str(raw_node.get("status") or "parameter_sensitive"), + } + for key in ("strength", "basis", "trigger_condition", "verification_hint", "source", "priority"): + value = raw_node.get(key) + if value not in (None, "", [], {}): + node[key] = value + return node + def shared_contract( self, *, @@ -590,6 +794,49 @@ class UnifiedConsultationOrchestrator: "source_path": source_path, } + @staticmethod + def _build_professional_support_cross_reference(narrative: dict[str, Any]) -> dict[str, Any]: + topics: dict[str, Any] = {} + support_statuses: list[str] = [] + for theme, item in narrative.items(): + if not isinstance(item, dict): + continue + system_views = item.get("system_views") if isinstance(item.get("system_views"), dict) else {} + support_topics = sorted(system_views) + if system_views: + support_statuses.append(str(item.get("status") or "used")) + topics[theme] = { + "status": str(item.get("status") or "used"), + "system_views": system_views, + "support_topics": support_topics, + "summary": item.get("summary") or item.get("consensus_summary") or "", + } + return { + "status": "used" if support_statuses else "blocked", + "topics": topics, + "supported_theme_count": len(support_statuses), + } + + @staticmethod + def _build_restricted_materials_reference(audit_rows: list[dict[str, Any]]) -> dict[str, Any]: + blocked = [ + str(row.get("technique") or row.get("name") or "unknown") + for row in audit_rows + if str(row.get("report_adoption") or row.get("status") or "").lower() == "blocked" + ] + conditional = [ + str(row.get("technique") or row.get("name") or "unknown") + for row in audit_rows + if str(row.get("report_adoption") or "").lower() == "conditional_evidence" + ] + return { + "status": "used" if blocked or conditional else "blocked", + "blocked_techniques": blocked, + "conditional_evidence": conditional, + "blocked_count": len(blocked), + "conditional_count": len(conditional), + } + @staticmethod def _external_engine_cross_validation(vedastro_state: str) -> dict[str, Any]: repo_root = Path(__file__).resolve().parents[1] @@ -678,6 +925,21 @@ class UnifiedConsultationOrchestrator: jaimini_arudha = modules["jaimini"].get("arudha_padas") arudha_padas = jaimini_arudha if isinstance(jaimini_arudha, dict) else {} pada_map = arudha_padas.get("padas") if isinstance(arudha_padas.get("padas"), dict) else arudha_padas + jaimini_packet = ( + chart_data.get("jaimini") + if isinstance(chart_data.get("jaimini"), dict) + else modules.get("jaimini") if isinstance(modules.get("jaimini"), dict) else {} + ) + sudarshana_packet = ( + chart_data.get("sudarshana") + if isinstance(chart_data.get("sudarshana"), dict) + else modules.get("sudarshana") if isinstance(modules.get("sudarshana"), dict) else {} + ) + sahams_packet = ( + chart_data.get("sahams") + if isinstance(chart_data.get("sahams"), dict) + else modules.get("sahams") if isinstance(modules.get("sahams"), dict) else {} + ) ascendant = base_chart.get("ascendant") if isinstance(base_chart.get("ascendant"), dict) else {} ascendant_sign = ascendant.get("sign") if isinstance(ascendant, dict) else None functional_layer = derive_functional_benefic_malefic(ascendant_sign) @@ -747,6 +1009,9 @@ class UnifiedConsultationOrchestrator: archive_manifest if archive_manifest.get("archive_count") else None, "vedastro_gateway.archives", ), + "jaimini": self._section(jaimini_packet, "chart.modules.jaimini"), + "sudarshana": self._section(sudarshana_packet, "chart.modules.sudarshana"), + "sahams": self._section(sahams_packet, "chart.modules.sahams"), } for division in FORMAL_DIVISIONS: if division == 1: @@ -769,7 +1034,9 @@ class UnifiedConsultationOrchestrator: return { "status": "complete" if not missing else "partial", "route": dict(route_packet), - "required_sections": list(self.EVIDENCE_PACKET_REQUIRED_SECTIONS), + "required_sections": self.evidence_packet_required_sections( + str((route_packet or {}).get("question_type") or (route_packet or {}).get("primary_theme") or "general") + ), "sections": sections, "functional_benefic_malefic": functional_layer, "signals": [item for item in signals if isinstance(item, dict)], @@ -1007,6 +1274,7 @@ class UnifiedConsultationOrchestrator: blind: bool = False, ) -> dict[str, Any]: official = vedastro_official if isinstance(vedastro_official, dict) else {} + route_name = str(route_packet.get("question_type") or route_packet.get("primary_theme") or "general") runtime_truth = official.get("runtime_truth") if isinstance(official.get("runtime_truth"), dict) else {} vedastro_state = self._vedastro_cloud_state(official) external_cross_validation = self._external_engine_cross_validation(vedastro_state) @@ -1049,6 +1317,19 @@ class UnifiedConsultationOrchestrator: jyotish_evidence=packet, western_evidence=western_evidence_packet, ) + specialized_indian_closure_review = build_specialized_indian_closure_review( + jaimini_packet=packet_sections.get("jaimini"), + sudarshana_packet=packet.get("sudarshana") if isinstance(packet.get("sudarshana"), dict) else packet_sections.get("sudarshana"), + sahams_packet=packet.get("sahams") if isinstance(packet.get("sahams"), dict) else packet_sections.get("sahams"), + ) + finance_astrology_support_review = build_finance_astrology_support_review( + route_packet=route_packet, + machine_evidence_packet=packet, + runtime_evidence_log={ + "quality_gate": {"technique_audit_table": []}, + "cross_system_arbitration": cross_system_arbitration, + }, + ) if cross_system_arbitration["status"] != "used": blocked_items.append("cross_system_arbitration_not_complete") technique_audit_table = [ @@ -1140,6 +1421,13 @@ class UnifiedConsultationOrchestrator: "vedastro_runtime_truth": dict(runtime_truth), "external_engine_cross_validation": external_cross_validation, "cross_system_arbitration": cross_system_arbitration, + "kp_western_support": { + "convergence": cross_system_arbitration.get("kp_western_convergence") or {}, + "negative_evidence": cross_system_arbitration.get("negative_evidence") or {}, + "real_case_support": cross_system_arbitration.get("western_real_case_support") or {}, + }, + "specialized_indian_closure_review": specialized_indian_closure_review, + "finance_astrology_support_review": finance_astrology_support_review, "source_priority": { "mode": self.SOURCE_PRIORITY["mode"], "priority": list(self.SOURCE_PRIORITY["priority"]), @@ -1153,7 +1441,7 @@ class UnifiedConsultationOrchestrator: }, "evidence_packet_contract": { "status": packet_status, - "required_sections": list(self.EVIDENCE_PACKET_REQUIRED_SECTIONS), + "required_sections": self.evidence_packet_required_sections(route_name), "missing_sections": packet.get("missing_sections", []), }, "blind_technical_mode": { @@ -1182,14 +1470,275 @@ class UnifiedConsultationOrchestrator: "External Engine Cross-Validation", "Western Cross-Validation", "Cross-System Arbitration", + "KP-Western Convergence", + "Western Negative Evidence", + "Western Real-Case Calibration", "Evidence Packet", "Blind Technical Mode", "MEVG / Global Web Evidence", "Real Case Calibration", "Timing Precision Gate", - "Functional Benefic/Malefic", - ], + "Functional Benefic/Malefic", + "Specialized Indian Closure Review", + "Finance Astrology Support Review", + ], "status": "blocked" if blocked_items else "pass", "blocked_items": blocked_items, }, } + + def build_expert_judgment_shadow_input( + self, + *, + question: str, + route_packet: dict[str, Any], + route_profile: dict[str, Any] | None = None, + machine_evidence_packet: dict[str, Any] | None = None, + runtime_evidence_log: dict[str, Any] | None = None, + legacy_prediction_payload: dict[str, Any] | None = None, + legacy_strict_workflows: dict[str, Any] | None = None, + request_id: str | None = None, + ) -> dict[str, Any]: + """Build Phase 1 shadow input without introducing judgment behavior.""" + try: + from scripts.expert_judgment import ExpertJudgmentRequest, assemble_expert_judgment_input + from scripts.expert_judgment.adapters import ( + build_activation_layer, + build_legacy_prediction_hint, + build_legacy_strict_evidence_items, + ) + except Exception: # pragma: no cover - research helper is not vendored here + try: + from expert_judgment import ExpertJudgmentRequest, assemble_expert_judgment_input + from expert_judgment.adapters import ( + build_activation_layer, + build_legacy_prediction_hint, + build_legacy_strict_evidence_items, + ) + except Exception: + return {"status": "blocked", "reason": "expert_judgment_module_absent"} + + packet = machine_evidence_packet if isinstance(machine_evidence_packet, dict) else {} + sections = packet.get("sections") if isinstance(packet.get("sections"), dict) else {} + route = dict(route_packet or {}) + profile = dict(route_profile or {}) + runtime_log = runtime_evidence_log if isinstance(runtime_evidence_log, dict) else {} + quality_gate = runtime_log.get("quality_gate") if isinstance(runtime_log.get("quality_gate"), dict) else {} + question_id = str(request_id or route.get("request_id") or route.get("question_id") or "shadow-request") + domain = str(route.get("primary_theme") or route.get("question_type") or "general") + question_type = str(route.get("question_type") or domain) + mode = str(profile.get("presentation_mode") or "default") + request = ExpertJudgmentRequest( + schema_version="expert_judgment_request.v1", + request_context={ + "question_id": question_id, + "question_text": question or "", + "domain": domain, + "question_type": question_type, + "precision_target": "quarter_window", + "mode": mode if mode in {"default", "high_rigor", "research"} else "default", + }, + ) + audit_payload = { + "audit_id": f"{question_id}-audit", + "status": self._normalize_shadow_status(quality_gate.get("status") or "blocked"), + "required_rows": self._shadow_required_audit_rows(quality_gate), + "blocked_items": list(quality_gate.get("blocked_items") or []), + "claim_boundaries": [ + "shadow input only", + "no final judgment", + "legacy hints stay isolated", + ], + } + activation_layer = build_activation_layer( + vimshottari=self._shadow_activation_payload(sections.get("dasha_boundaries"), "Vimshottari"), + narayana=self._shadow_activation_payload(sections.get("narayana_dasha"), "Narayana"), + blocked_sources=self._shadow_activation_blocked_sources(sections), + ) + legacy_strict_items = build_legacy_strict_evidence_items(legacy_strict_workflows) + assembled = assemble_expert_judgment_input( + request, + domain_context={ + "domain": domain, + "question_type": question_type, + "themes": list(profile.get("themes") or []), + "route_label": route.get("display_label"), + }, + audit_context={ + "status": audit_payload["status"], + "timing_precision_gate": dict( + (runtime_log.get("real_case_calibration") or {}).get("timing_precision_gate") or {} + ), + }, + evidence_graph_ref={"graph_id": f"{question_id}-graph"}, + activation_layer=activation_layer, + legacy_prediction=build_legacy_prediction_hint(legacy_prediction_payload), + evidence_items=self._build_shadow_evidence_items(sections) + legacy_strict_items, + audit_payload=audit_payload, + runtime_log_path="runtime_evidence_log", + technique_audit_table_path="runtime_evidence_log.quality_gate.technique_audit_table", + required_rows=audit_payload["required_rows"], + ) + created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + shadow_run_id = f"shadow-input://{question_id}" + return { + "shadow_run_id": shadow_run_id, + "request_id": question_id, + "expert_input_ref": f"expert_judgment_input://{question_id}", + "audit_snapshot_ref": f"audit_snapshot://{question_id}", + "created_at": created_at, + "status": "generated", + "shadow_snapshot": { + "shadow_run_id": shadow_run_id, + "request_id": question_id, + "expert_input_ref": f"expert_judgment_input://{question_id}", + "audit_snapshot_ref": f"audit_snapshot://{question_id}", + "created_at": created_at, + "status": "generated", + }, + "expert_judgment_input": asdict(assembled), + "audit_snapshot": dict(assembled.audit_context), + "boundary": "Shadow input only; no verdict, confidence, or final judgment.", + } + + def build_shadow_judgment_runtime( + self, + *, + expert_judgment_shadow: dict[str, Any] | None, + ) -> dict[str, Any]: + """Build Phase 2C shadow adjudication artifacts without product promotion.""" + try: + from scripts.expert_judgment import ( + build_judgment_shadow_output, + build_shadow_judgment_artifact, + build_shadow_review_packet, + build_shadow_stage_archive, + ) + from scripts.expert_judgment.schemas import ExpertJudgmentInput + except ModuleNotFoundError: # pragma: no cover - script execution path + from expert_judgment import ( + build_judgment_shadow_output, + build_shadow_judgment_artifact, + build_shadow_review_packet, + build_shadow_stage_archive, + ) + from expert_judgment.schemas import ExpertJudgmentInput + + shadow = expert_judgment_shadow if isinstance(expert_judgment_shadow, dict) else {} + input_payload = shadow.get("expert_judgment_input") if isinstance(shadow.get("expert_judgment_input"), dict) else None + if not isinstance(input_payload, dict): + raise ValueError("expert_judgment_shadow must contain expert_judgment_input") + + expert_input = ExpertJudgmentInput(**input_payload) + shadow_output = build_judgment_shadow_output(expert_input) + stage_archive = build_shadow_stage_archive( + shadow_run_id=str(shadow.get("shadow_run_id") or "shadow-input://unknown"), + request_id=str(shadow.get("request_id") or "shadow-request"), + shadow_output=shadow_output, + audit_snapshot=dict(shadow.get("audit_snapshot") or {}), + created_at=str(shadow.get("created_at") or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")), + ) + shadow_artifact = build_shadow_judgment_artifact( + shadow_run_id=stage_archive["shadow_run_id"], + request_id=stage_archive["request_id"], + shadow_stage_archive=stage_archive, + ) + shadow_review = build_shadow_review_packet( + shadow_run_id=stage_archive["shadow_run_id"], + request_id=stage_archive["request_id"], + expert_judgment_input=input_payload, + shadow_stage_archive=stage_archive, + shadow_judgment_artifact=shadow_artifact, + ) + return { + "shadow_run_id": stage_archive["shadow_run_id"], + "request_id": stage_archive["request_id"], + "shadow_stage_archive_ref": f"{stage_archive['shadow_run_id']}#stage_archive", + "shadow_judgment_artifact_ref": f"{stage_archive['shadow_run_id']}#judgment_artifact", + "shadow_review_ref": f"{stage_archive['shadow_run_id']}#review", + "status": "generated", + "shadow_stage_archive": stage_archive, + "shadow_judgment_artifact": shadow_artifact, + "shadow_review": shadow_review, + "boundary": "Shadow adjudication runtime only; no final adjudication, confidence, or product promotion.", + } + + @staticmethod + def _normalize_shadow_status(status: Any) -> str: + status_text = str(status or "").strip().lower() + if status_text in {"used", "complete", "executed", "official_verified", "pass"}: + return "executed" + if status_text in {"partial", "partial_scored", "local_fallback", "available_not_requested"}: + return "partial" + if status_text == "parameter_sensitive": + return "parameter_sensitive" + if status_text == "not_applicable": + return "not_applicable" + return "blocked" + + def _build_shadow_evidence_items(self, sections: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for section_name, section_payload in sections.items(): + if not isinstance(section_payload, dict): + continue + items.append( + { + "ref_id": f"shadow-{section_name.lower()}", + "source_module": "scripts.unified_consultation_orchestrator", + "source_path": str(section_payload.get("source_path") or f"machine_evidence_packet.sections.{section_name}"), + "payload_key": f"machine_evidence_packet.sections.{section_name}", + "status": self._normalize_shadow_status(section_payload.get("status")), + "summary": {"section": section_name}, + "claim_boundary": "reference only; no final judgment", + "evidence_graph_node": f"evidence://shadow/{section_name.lower()}", + } + ) + return items + + @staticmethod + def _shadow_required_audit_rows(quality_gate: dict[str, Any]) -> list[str]: + rows = quality_gate.get("technique_audit_table") if isinstance(quality_gate.get("technique_audit_table"), list) else [] + required_rows: list[str] = [] + for row in rows: + technique = row.get("technique") if isinstance(row, dict) else None + if isinstance(technique, str) and technique: + required_rows.append(technique) + return required_rows or ["Evidence Packet", "Real Case Calibration", "Timing Precision Gate"] + + def _shadow_activation_payload( + self, + section_payload: dict[str, Any] | None, + system_name: str, + ) -> dict[str, Any] | None: + if not isinstance(section_payload, dict): + return None + status = self._normalize_shadow_status(section_payload.get("status")) + if status == "blocked": + return None + return { + "status": status, + "source_path": section_payload.get("source_path"), + "system": system_name, + } + + def _shadow_activation_blocked_sources(self, sections: dict[str, Any]) -> list[dict[str, Any]]: + blocked_sources: list[dict[str, Any]] = [] + for section_name, system_name in ( + ("dasha_boundaries", "Vimshottari"), + ("narayana_dasha", "Narayana"), + ): + section_payload = sections.get(section_name) + if not isinstance(section_payload, dict): + blocked_sources.append( + {"name": system_name, "status": "blocked", "reason": "missing_section"} + ) + continue + if self._normalize_shadow_status(section_payload.get("status")) == "blocked": + blocked_sources.append( + { + "name": system_name, + "status": "blocked", + "reason": str(section_payload.get("source_path") or "blocked_source"), + } + ) + return blocked_sources diff --git a/scripts/varga.py b/scripts/varga.py index 3a26990a..1f2b47e3 100644 --- a/scripts/varga.py +++ b/scripts/varga.py @@ -34,6 +34,7 @@ VARGA_META = { 81:{'name':'Navamsa-Navamsa','cn':'D9之D9','area':'配偶灵性精微层'}, 108:{'name':'Dwadasamsa-Navamsa','cn':'D12之D9','area':'祖先父母精微层'}, 144:{'name':'Dwadasamsa-Dwadasamsa','cn':'D12之D12','area':'父母祖先精微层'}} +VEDASTRO_RAW_VERIFIED_DIVISIONS = frozenset({2, 3, 4, 7, 9, 10, 12, 16, 20, 24, 27, 40, 45, 60}) def _si(lon): return int(lon/30)%12 def _sn(i): return SIGNS[i%12] @@ -182,6 +183,47 @@ def calc_varga(lon, div, mode='classical_local'): return r +def normalize_varga_ascendant(value): + """Return the public ascendant object used by all varga charts.""" + source_format = None + if isinstance(value, list): + candidates = [ + row for row in value + if isinstance(row, dict) + and any( + str(row.get(field, '')).strip().lower() == 'ascendant' + for field in ('name', 'planet', 'key') + ) + ] + if len(candidates) != 1: + raise ValueError('ambiguous legacy ascendant list') + value = candidates[0] + source_format = 'legacy_list_normalized' + + if not isinstance(value, dict): + raise ValueError('ascendant must be a mapping or identified legacy list') + + sign = value.get('sign') + if sign not in SIGNS: + raise ValueError('ascendant sign is required') + sign_idx = value.get('sign_idx', SIGNS.index(sign)) + if not isinstance(sign_idx, int) or not 0 <= sign_idx < len(SIGNS): + raise ValueError('ascendant sign_idx is invalid') + degree_in_sign = value.get('degree_in_sign') + if not isinstance(degree_in_sign, (int, float)) or not 0 <= degree_in_sign < 30: + raise ValueError('ascendant degree_in_sign is invalid') + + normalized = { + 'sign': sign, + 'sign_idx': sign_idx, + 'degree_in_sign': round(float(degree_in_sign), 4), + 'longitude': round(float(value.get('longitude', sign_idx * 30 + degree_in_sign)) % 360, 4), + } + if source_format: + normalized['source_format'] = source_format + return normalized + + def calc_64th_navamsa(moon_lon: float) -> Dict: """Compute the 64th Navamsa from the Moon's Navamsa position. @@ -272,9 +314,17 @@ def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local' for div in divisions: m=VARGA_META.get(div,{}) key=f"D{div}_{m.get('name',f'D{div}')}" + mapping_status = ( + 'official_raw_sign_verified' + if mode == 'vedastro' and div in VEDASTRO_RAW_VERIFIED_DIVISIONS + else 'legacy_candidate_unverified' + if mode == 'vedastro' + else 'classical_local' + ) vd={'_meta':{'div':div,'name':m.get('name',''),'cn':m.get('cn',''), - 'area':m.get('area',''),'part_size':30.0/div}} - vd['Ascendant']=calc_varga(asc_lon,div,mode=mode) + 'area':m.get('area',''),'part_size':30.0/div, + 'mapping_status': mapping_status}} + vd['Ascendant']=normalize_varga_ascendant(calc_varga(asc_lon,div,mode=mode)) for pn,lon in planet_lons.items(): vd[pn]=calc_varga(lon,div,mode=mode) # 尊贵状态 diff --git a/scripts/western_timing_engine.py b/scripts/western_timing_engine.py index 0bdcdac5..23071e4d 100644 --- a/scripts/western_timing_engine.py +++ b/scripts/western_timing_engine.py @@ -10,9 +10,20 @@ from zoneinfo import ZoneInfo import swisseph as swe try: - from western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart + from western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart except ImportError: # pragma: no cover - package import path - from scripts.western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart + from scripts.western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart + + +_PLANETARY_YEARS = { + "sun": 19, + "moon": 25, + "mercury": 20, + "venus": 8, + "mars": 15, + "jupiter": 12, + "saturn": 30, +} def _target_jd(target_date: str, timezone: str | float | int) -> tuple[float, datetime]: @@ -183,6 +194,34 @@ def calculate_secondary_progressions(*, target_date: str, **birth: Any) -> dict[ } +def calculate_tertiary_progressed_moon(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate the tertiary progressed Moon using one day per sidereal lunar month.""" + natal_chart = build_tropical_natal_chart(**birth) + target_jd, local = _target_jd(target_date, birth["timezone"]) + birth_jd = _birth_jd(**birth) + elapsed_months = (target_jd - birth_jd) / 27.321661 + progressed_jd = birth_jd + elapsed_months + planets = _progressed_planets(progressed_jd) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "tertiary_progressed_moon", + "status": "partial", + "method": "one_ephemeris_day_per_sidereal_lunar_month", + "target_date": target_date, + "target_local_time": local.isoformat(), + "elapsed_sidereal_lunar_months": round(elapsed_months, 8), + "progressed_julian_day_ut": round(progressed_jd, 8), + "natal_moon_longitude": natal_chart["natal"]["planets"]["moon"]["longitude"], + "progressed_moon": planets["moon"], + "aspects": _cross_aspects({"moon": planets["moon"]}, natal_points), + "boundary": "Tertiary progressed Moon only; progressed house framework, duration, and interpretation remain separate audited layers.", + } + + def calculate_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: """Direct natal points by the true arc of the secondary progressed Sun.""" natal_chart = build_tropical_natal_chart(**birth) @@ -355,6 +394,24 @@ def calculate_lunar_return(*, start_date: str, **birth: Any) -> dict[str, Any]: } +def calculate_lunar_return_series(*, target_date: str, months: int = 1, **birth: Any) -> dict[str, Any]: + """Return the next exact lunar return from each of the prior calendar-month anchors.""" + if not 1 <= int(months) <= 12: + raise ValueError("months must be between 1 and 12") + target = datetime.fromisoformat(target_date[:10]) + returns = [] + for offset in range(int(months) - 1, -1, -1): + anchor = target - timedelta(days=31 * offset) + returns.append(calculate_lunar_return(start_date=anchor.date().isoformat(), **birth)) + return { + "technique": "lunar_return_series", + "target_date": target_date, + "months": int(months), + "returns": returns, + "boundary": "Calendar-month anchor series; each item is a native exact lunar return calculation.", + } + + def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: int = 370, **birth: Any) -> dict[str, Any]: """Scan daily transit-to-natal aspect activity and group consecutive windows.""" start = datetime.fromisoformat(start_date) @@ -400,6 +457,20 @@ def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: "end_date": final_date, "min_orb": round(row["min_orb"], 6), }) + windows = sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])) + exact_hit_timeline = [ + { + "layer": "transits", + "target": row["natal_point"], + "aspect": row["aspect"], + "window_start": row["start_date"], + "exact_date": row["start_date"] if row["start_date"] == row["end_date"] else row["end_date"], + "window_end": row["end_date"], + "transit_planet": row["transit_planet"], + "min_orb": row["min_orb"], + } + for row in windows + ] return { "technique": "transit_duration_scan", "status": "used", @@ -408,7 +479,8 @@ def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: "end_date": end_date, "days_scanned": days, "daily_hits": daily_hits, - "windows": sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])), + "windows": windows, + "exact_hit_timeline": exact_hit_timeline, "boundary": "Daily scan only; exact ingress/egress times require sub-daily root finding.", } @@ -467,19 +539,152 @@ def calculate_parans_status(*, target_date: str | None = None, **birth: Any) -> } +def calculate_annual_profection(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate annual profection house, sign, and yearly ruler from the tropical ascendant.""" + natal_chart = build_tropical_natal_chart(**birth) + target_local = datetime.fromisoformat(target_date[:10]) + birth_month = int(birth["month"]) + birth_day = int(birth["day"]) + years_elapsed = target_local.year - int(birth["year"]) + if (target_local.month, target_local.day) < (birth_month, birth_day): + years_elapsed -= 1 + asc_sign = natal_chart["natal"]["ascendant"]["sign"] + asc_index = _SIGNS.index(asc_sign) + profected_house = years_elapsed % 12 + 1 + profected_sign = _SIGNS[(asc_index + years_elapsed) % 12] + year_lord = _RULERS[profected_sign] + year_lord_natal = natal_chart["natal"]["planets"][year_lord] + return { + "technique": "annual_profection", + "status": "used", + "target_date": target_date, + "years_elapsed": years_elapsed, + "natal_ascendant_sign": asc_sign, + "profected_house": profected_house, + "profected_sign": profected_sign, + "year_lord": year_lord, + "year_lord_natal_house": year_lord_natal.get("house"), + "year_lord_natal_sign": year_lord_natal.get("sign"), + "boundary": "Annual profection house/sign activation only; interpretation and event adjudication remain separate audited layers.", + } + + +def _lot_longitude(*, ascendant: float, sun: float, moon: float, is_day_chart: bool, lot: str) -> float: + lot_key = lot.lower() + if lot_key == "fortune": + raw = ascendant + (moon - sun) if is_day_chart else ascendant + (sun - moon) + elif lot_key == "spirit": + raw = ascendant + (sun - moon) if is_day_chart else ascendant + (moon - sun) + else: + raise ValueError("lot must be spirit or fortune") + return _longitude(raw) + + +def _period_years_for_sign(sign: str) -> int: + return _PLANETARY_YEARS[_RULERS[sign]] + + +def _build_release_periods(start_sign_index: int, start_date: datetime, levels: int) -> list[dict[str, Any]]: + periods: list[dict[str, Any]] = [] + current_start = start_date + for offset in range(12): + sign = _SIGNS[(start_sign_index + offset) % 12] + years = _period_years_for_sign(sign) + current_end = current_start + timedelta(days=round(years * 365.242189)) + period = { + "level": 1, + "sign": sign, + "years": years, + "start_date": current_start.date().isoformat(), + "end_date": (current_end - timedelta(days=1)).date().isoformat(), + } + if levels >= 2: + subperiods = [] + sub_start = current_start + for sub_offset in range(12): + sub_sign = _SIGNS[(start_sign_index + offset + sub_offset) % 12] + sub_years = years * _period_years_for_sign(sub_sign) / 12.0 + sub_end = sub_start + timedelta(days=round(sub_years * 365.242189)) + subperiods.append({ + "level": 2, + "sign": sub_sign, + "years": round(sub_years, 6), + "start_date": sub_start.date().isoformat(), + "end_date": (sub_end - timedelta(days=1)).date().isoformat(), + }) + sub_start = sub_end + period["subperiods"] = subperiods + periods.append(period) + current_start = current_end + return periods + + +def calculate_zodiacal_release(*, target_date: str, lot: str = "spirit", levels: int = 2, **birth: Any) -> dict[str, Any]: + """Calculate a bounded L1/L2 zodiacal-release timeline from Fortune or Spirit.""" + if levels not in {1, 2}: + raise ValueError("levels must be 1 or 2") + natal_chart = build_tropical_natal_chart(**birth) + natal = natal_chart["natal"] + ascendant = natal["ascendant"]["longitude"] + sun = natal["planets"]["sun"]["longitude"] + moon = natal["planets"]["moon"]["longitude"] + is_day_chart = natal["planets"]["sun"]["house"] in {7, 8, 9, 10, 11, 12} + lot_longitude = _lot_longitude( + ascendant=ascendant, + sun=sun, + moon=moon, + is_day_chart=is_day_chart, + lot=lot, + ) + lot_sign = _SIGNS[int(lot_longitude // 30)] + start_date = datetime( + int(birth["year"]), + int(birth["month"]), + int(birth["day"]), + ) + periods = _build_release_periods(_SIGNS.index(lot_sign), start_date, levels) + active_period = next( + ( + period + for period in periods + if period["start_date"] <= target_date <= period["end_date"] + ), + None, + ) + return { + "technique": "zodiacal_release", + "status": "partial", + "target_date": target_date, + "lot": lot.lower(), + "lot_longitude": round(lot_longitude, 6), + "lot_sign": lot_sign, + "day_night_basis": "day_chart" if is_day_chart else "night_chart", + "levels": levels, + "periods": periods, + "active_period": active_period, + "boundary": "L1/L2 sign-period release scaffold from the Lot of Spirit/Fortune only; loosing-of-the-bond, peak periods, angularity weighting, and interpretation remain separate audited layers.", + } + + def build_timing_techniques( *, transit_date: str | None = None, solar_return_year: int | None = None, secondary_progression_date: str | None = None, + tertiary_progressed_moon_date: str | None = None, solar_arc_date: str | None = None, converse_secondary_progression_date: str | None = None, converse_solar_arc_date: str | None = None, midpoint_date: str | None = None, lunar_return_start_date: str | None = None, + lunar_return_months: int = 1, duration_scan_start_date: str | None = None, duration_scan_end_date: str | None = None, parans_date: str | None = None, + profection_date: str | None = None, + zodiacal_release_date: str | None = None, + zodiacal_release_lot: str = "spirit", + zodiacal_release_levels: int = 2, **birth: Any, ) -> dict[str, Any]: """Materialize only the requested, independently auditable timing layers.""" @@ -492,6 +697,10 @@ def build_timing_techniques( techniques["secondary_progressions"] = calculate_secondary_progressions( target_date=secondary_progression_date, **birth ) + if tertiary_progressed_moon_date: + techniques["tertiary_progressed_moon"] = calculate_tertiary_progressed_moon( + target_date=tertiary_progressed_moon_date, **birth + ) if solar_arc_date: techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth) if converse_secondary_progression_date: @@ -505,7 +714,11 @@ def build_timing_techniques( if midpoint_date: techniques["midpoints"] = calculate_midpoints(target_date=midpoint_date, **birth) if lunar_return_start_date: - techniques["lunar_return"] = calculate_lunar_return(start_date=lunar_return_start_date, **birth) + techniques["lunar_return"] = calculate_lunar_return_series( + target_date=lunar_return_start_date, + months=int(lunar_return_months), + **birth, + ) if duration_scan_start_date and duration_scan_end_date: techniques["transit_duration_scan"] = calculate_transit_duration_scan( start_date=duration_scan_start_date, @@ -514,4 +727,13 @@ def build_timing_techniques( ) if parans_date: techniques["parans"] = calculate_parans_status(target_date=parans_date, **birth) + if profection_date: + techniques["annual_profection"] = calculate_annual_profection(target_date=profection_date, **birth) + if zodiacal_release_date: + techniques["zodiacal_release"] = calculate_zodiacal_release( + target_date=zodiacal_release_date, + lot=zodiacal_release_lot, + levels=int(zodiacal_release_levels), + **birth, + ) return techniques diff --git a/tests/golden/consultation_contract_keypaths_v1.json b/tests/golden/consultation_contract_keypaths_v1.json index 279cece1..b3cd4165 100644 --- a/tests/golden/consultation_contract_keypaths_v1.json +++ b/tests/golden/consultation_contract_keypaths_v1.json @@ -315,11 +315,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -328,12 +333,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -607,11 +615,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -620,12 +633,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -899,11 +915,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -912,12 +933,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -1191,11 +1215,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -1204,12 +1233,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -1483,11 +1515,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -1496,12 +1533,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -1775,11 +1815,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -1788,12 +1833,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -2077,11 +2125,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -2090,12 +2143,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -2379,11 +2435,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -2392,12 +2453,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -2472,10 +2536,19 @@ "unified_orchestrator.surface": "string", "unified_orchestrator.themes": "array", "vedastro_gateway": "object", + "vedastro_gateway.cache_and_queue": "object", + "vedastro_gateway.gateway_status": "object", + "vedastro_gateway.honesty_boundary": "object", + "vedastro_gateway.input": "object", + "vedastro_gateway.official_capability_catalog": "object", "vedastro_gateway.official_closure_reason": "string", "vedastro_gateway.official_closure_state": "string", + "vedastro_gateway.runtime_mode": "object", + "vedastro_gateway.schema_version": "number", "vedastro_gateway.scope": "string", "vedastro_gateway.status": "string", + "vedastro_gateway.strict_workflow": "object", + "vedastro_gateway.user_visibility": "object", "vedastro_official": "object", "vedastro_official.adjudication_stages": "object", "vedastro_official.blocked_items": "array", @@ -2671,11 +2744,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -2684,12 +2762,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", @@ -2764,10 +2845,19 @@ "unified_orchestrator.surface": "string", "unified_orchestrator.themes": "array", "vedastro_gateway": "object", + "vedastro_gateway.cache_and_queue": "object", + "vedastro_gateway.gateway_status": "object", + "vedastro_gateway.honesty_boundary": "object", + "vedastro_gateway.input": "object", + "vedastro_gateway.official_capability_catalog": "object", "vedastro_gateway.official_closure_reason": "string", "vedastro_gateway.official_closure_state": "string", + "vedastro_gateway.runtime_mode": "object", + "vedastro_gateway.schema_version": "number", "vedastro_gateway.scope": "string", "vedastro_gateway.status": "string", + "vedastro_gateway.strict_workflow": "object", + "vedastro_gateway.user_visibility": "object", "vedastro_official": "object", "vedastro_official.adjudication_stages": "object", "vedastro_official.blocked_items": "array", @@ -2963,11 +3053,16 @@ "routes": "array", "routes[0]": "string", "routing": "object", + "routing.audit_route": "string", "routing.display_label": "string", + "routing.document_route": "string", "routing.focus_techniques": "array", "routing.primary_theme": "string", "routing.question_type": "string", + "routing.registry_route": "string", + "routing.route_aliases": "array", "routing.route_source": "string", + "routing.runtime_route": "string", "runtime_evidence_log": "object", "runtime_evidence_log.blind_technical_mode": "object", "runtime_evidence_log.cross_system_arbitration": "object", @@ -2976,12 +3071,15 @@ "runtime_evidence_log.evidence_sources": "object", "runtime_evidence_log.executed_steps": "array", "runtime_evidence_log.external_engine_cross_validation": "object", + "runtime_evidence_log.finance_astrology_support_review": "object", + "runtime_evidence_log.kp_western_support": "object", "runtime_evidence_log.name": "string", "runtime_evidence_log.quality_gate": "object", "runtime_evidence_log.real_case_calibration": "object", "runtime_evidence_log.route": "object", "runtime_evidence_log.skipped_steps": "array", "runtime_evidence_log.source_priority": "object", + "runtime_evidence_log.specialized_indian_closure_review": "object", "runtime_evidence_log.surface": "string", "runtime_evidence_log.vedastro_cloud_state": "string", "runtime_evidence_log.vedastro_runtime_truth": "object", diff --git a/tests/test_gulika.py b/tests/test_gulika.py index 1a76ff8d..60ae728f 100644 --- a/tests/test_gulika.py +++ b/tests/test_gulika.py @@ -27,7 +27,13 @@ def test_gulika_matches_public_pyjhora_smoke_oracle() -> None: Path("references/oracle/prashna_sphuta_pyjhora_public_smoke.json").read_text(encoding="utf-8") ) expected = packet["outputs"]["gulika"] - result = calculate_gulika(datetime(1990, 1, 1, 12, 0), lat=39.9042, lon=116.4074, tz=8) + result = calculate_gulika( + datetime(1990, 1, 1, 12, 0), + lat=39.9042, + lon=116.4074, + tz=8, + ayanamsa="lahiri", + ) assert result["method"] == "saturn_part_start" assert result["sign_idx"] == expected["sign_index"] diff --git a/tests/test_report_orchestrator_reader_contract.py b/tests/test_report_orchestrator_reader_contract.py index 80863d1a..c0e2fd30 100644 --- a/tests/test_report_orchestrator_reader_contract.py +++ b/tests/test_report_orchestrator_reader_contract.py @@ -45,7 +45,7 @@ def test_upstream_skill_snapshot_matches_manifest_and_commercial_root_remains_ro snapshot_hash = hashlib.sha256(snapshot.read_bytes()).hexdigest() assert snapshot_hash == manifest.get("source_skill_sha256", manifest["skill_sha256"]) - assert manifest["source_mode"] == "archive" + assert manifest["source_mode"] == "git" assert re.fullmatch(r"[0-9a-f]{40}", manifest["source_commit"]) assert re.fullmatch(r"[0-9a-f]{64}", manifest["source_tree_sha256"]) assert linked_skill.resolve() == (ROOT / "SKILL.md").resolve()