From d2cd5a369f060e279a77a556388e3d1ce9483d95 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Mon, 8 Jun 2026 13:16:42 +0800 Subject: [PATCH] v6.1.9: Add public benchmarks and research roadmap - Add sanitized Jyotish benchmark suite with fictional/public smoke samples - Include benchmark scripts and markdown reports while excluding raw JSON/CSV outputs - Add open-source Jyotish project comparison research - Add complete technique coverage roadmap - Add privacy-safe PDF chart validation methodology - Update SKILL.md and CHANGELOG with v6.1.9 scope and privacy boundaries Validation: - py_compile benchmarks/jyotish/scripts/*.py passed - quality gate passed with 35 pytest tests and golden case --- CHANGELOG.md | 23 + SKILL.md | 4 +- benchmarks/jyotish/README.md | 44 ++ .../jyotish/data/benchmark_samples.json | 82 +++ ...enchmark_round10_explanation_regression.md | 115 ++++ ...jyotish_benchmark_round1_local_baseline.md | 46 ++ ...h_benchmark_round1_swiss_direct_compare.md | 32 + ...benchmark_round2_swiss_extended_compare.md | 45 ++ ...yotish_benchmark_round3_pyjhora_compare.md | 124 ++++ ...tish_benchmark_round4_node_mode_compare.md | 98 +++ ...ish_benchmark_round5_arudha_a10_compare.md | 37 ++ ...h_benchmark_round6_ashtakavarga_compare.md | 37 ++ ..._round6b_ashtakavarga_table_arbitration.md | 49 ++ ...mark_round6c_ashtakavarga_book_examples.md | 31 + ...sh_benchmark_round7_chara_dasha_compare.md | 39 ++ ...h_benchmark_round8_transit_true_compare.md | 34 + ...sh_benchmark_round9_shadbala_invariants.md | 52 ++ .../scripts/pyjhora_compat/timezonefinder.py | 9 + .../jyotish/scripts/run_arudha_compare.py | 190 ++++++ .../scripts/run_ashtakavarga_book_examples.py | 217 +++++++ .../scripts/run_ashtakavarga_compare.py | 168 +++++ .../run_ashtakavarga_table_arbitration.py | 190 ++++++ .../scripts/run_chara_dasha_compare.py | 199 ++++++ .../jyotish/scripts/run_node_mode_compare.py | 251 ++++++++ .../jyotish/scripts/run_pyjhora_compare.py | 345 ++++++++++ .../scripts/run_shadbala_invariants.py | 229 +++++++ .../jyotish/scripts/run_skill_baseline.py | 226 +++++++ .../scripts/run_swiss_direct_compare.py | 211 ++++++ .../scripts/run_swiss_extended_compare.py | 303 +++++++++ .../scripts/run_transit_true_compare.py | 267 ++++++++ docs/research/jyotish_projects_comparison.md | 256 ++++++++ .../roadmap/jyotish_technique_coverage_map.md | 609 ++++++++++++++++++ ...df-chart-reading-validation-methodology.md | 109 ++++ 33 files changed, 4669 insertions(+), 2 deletions(-) create mode 100644 benchmarks/jyotish/README.md create mode 100644 benchmarks/jyotish/data/benchmark_samples.json create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round10_explanation_regression.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round1_local_baseline.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round1_swiss_direct_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round2_swiss_extended_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round3_pyjhora_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round4_node_mode_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round5_arudha_a10_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round6_ashtakavarga_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round6b_ashtakavarga_table_arbitration.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round6c_ashtakavarga_book_examples.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round7_chara_dasha_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round8_transit_true_compare.md create mode 100644 benchmarks/jyotish/reports/jyotish_benchmark_round9_shadbala_invariants.md create mode 100644 benchmarks/jyotish/scripts/pyjhora_compat/timezonefinder.py create mode 100644 benchmarks/jyotish/scripts/run_arudha_compare.py create mode 100644 benchmarks/jyotish/scripts/run_ashtakavarga_book_examples.py create mode 100644 benchmarks/jyotish/scripts/run_ashtakavarga_compare.py create mode 100644 benchmarks/jyotish/scripts/run_ashtakavarga_table_arbitration.py create mode 100644 benchmarks/jyotish/scripts/run_chara_dasha_compare.py create mode 100644 benchmarks/jyotish/scripts/run_node_mode_compare.py create mode 100644 benchmarks/jyotish/scripts/run_pyjhora_compare.py create mode 100644 benchmarks/jyotish/scripts/run_shadbala_invariants.py create mode 100644 benchmarks/jyotish/scripts/run_skill_baseline.py create mode 100644 benchmarks/jyotish/scripts/run_swiss_direct_compare.py create mode 100644 benchmarks/jyotish/scripts/run_swiss_extended_compare.py create mode 100644 benchmarks/jyotish/scripts/run_transit_true_compare.py create mode 100644 docs/research/jyotish_projects_comparison.md create mode 100644 docs/roadmap/jyotish_technique_coverage_map.md create mode 100644 references/validation/pdf-chart-reading-validation-methodology.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fbf576..b55d3452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # 印度占星 Skill 更新日志 +## v6.1.9(2026-06-08)—— 本地散落资料安全入仓:Benchmark、竞品研究、覆盖度地图与PDF验证方法论 + +> **目标**:把历史 WorkBuddy 会话中散落但有长期价值的资料,按公开仓库隐私边界整理入 `yinduzhanxing`,避免遗漏,同时不上传真实用户个案、原始 PDF 文本或 raw full-reading 输出。 + +### 新增资料 +- `benchmarks/jyotish/`:公开/虚构 smoke case benchmark 套件,包含 `data/benchmark_samples.json`、12 个可复跑 benchmark 脚本、13 份历史 markdown benchmark 报告与 README。 +- `docs/research/jyotish_projects_comparison.md`:开源 Jyotish 项目竞品分析,覆盖 PyJHora、jyotisha、VedAstro、drik-panchanga、VedicAstro、jyotishganit、Kerykeion 等项目。 +- `docs/roadmap/jyotish_technique_coverage_map.md`:完整技法覆盖度地图,按 Natal / Dasha / Transit / Muhurta / Prashna / Synastry 与 L1-L4 层级梳理已实现、partial、missing 和待优化项。 +- `references/validation/pdf-chart-reading-validation-methodology.md`:从私有 PDF 星盘验证经验中脱敏抽象出的通用方法论,覆盖 PDF/OCR 提取、Quality Gate、字段核对、置信度分级和隐私边界。 + +### 隐私与入仓边界 +- 已排除:benchmark raw JSON/CSV、`__pycache__`、个人解盘/个人运势报告、真实 PDF 提取文本、私有 full-reading JSON、包含个人出生资料或人生事件的原始个案文件。 +- 5月4日 10案例报告暂未直接入仓:虽然多为公开人物案例,但含完整出生资料与叙事判断,后续如需使用,应先改写为公开名人验证摘要或测试夹具。 +- 所有入仓 benchmark 样本均标记为 `fictional_or_public_test`。 + +### 验证 +```bash +python3 -m py_compile benchmarks/jyotish/scripts/*.py +python3 scripts/run_quality_gate.py --skip-yoga-logic +``` + +--- + ## v6.1.8(2026-06-08)—— Nishkapata 友好星座条件恢复 + 主题化报告真实模块接线,Yoga F1 提升至 95.22% > **目标**:继续冲击 Yoga FN 瓶颈,但坚持准确率优先,只接受来源语义明确且预验证不增加 FP 的规则优化。 diff --git a/SKILL.md b/SKILL.md index b259d8fc..0491187f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -313,9 +313,9 @@ $PYTHON $SCRIPT <子命令> [参数] --- -**版本**:v6.1.8-yoga-f1-95-theme-real-modules +**版本**:v6.1.9-public-benchmarks-research-roadmap **创建日期**:2026-04-20 -**最后更新**:2026-06-08(v6.1.8 在 v6.1.7 D9/D60 YogaContext 基础上,恢复 Nishkapata Yoga 的 BVR-205 友好星座条件;Yoga F1 提升至 95.22%,FP=36、FN=63。`orchestrator_bridge.py` 已可直接消费 `full-reading.modules` 真实结果生成婚姻/事业/财富/健康/灵性五大主题证据与叙事。Yoga 当前以 `references/validation_logic_report.json` 为准。) +**最后更新**:2026-06-08(v6.1.9 将历史散落资料安全整理入仓:新增 `benchmarks/jyotish/` 公开/虚构 smoke benchmark 套件、`docs/research/jyotish_projects_comparison.md`、`docs/roadmap/jyotish_technique_coverage_map.md` 与 `references/validation/pdf-chart-reading-validation-methodology.md`。v6.1.8 Yoga 指标保持有效:F1=95.22%,FP=36、FN=63;Yoga 当前以 `references/validation_logic_report.json` 为准。) --- diff --git a/benchmarks/jyotish/README.md b/benchmarks/jyotish/README.md new file mode 100644 index 00000000..5853b0b5 --- /dev/null +++ b/benchmarks/jyotish/README.md @@ -0,0 +1,44 @@ +# Jyotish Benchmark Suite + +This directory contains the public benchmark material recovered and sanitized in v6.1.9. + +## Scope + +- Samples: 10 fictional/public smoke cases in `data/benchmark_samples.json`. +- Scripts: reproducible comparison scripts under `scripts/`. +- Reports: markdown summary reports under `reports/`. + +Raw JSON/CSV outputs are intentionally not committed. Re-run the scripts locally to regenerate them under `benchmarks/jyotish/outputs/`. + +## Privacy rule + +All committed samples are marked `fictional_or_public_test`. Do not add real user birth data, private chart output, personal life events, PDF extraction text, or private full-reading JSON to this directory. + +## Running + +From the repository root: + +```bash +python3 benchmarks/jyotish/scripts/run_skill_baseline.py +python3 benchmarks/jyotish/scripts/run_swiss_direct_compare.py +python3 benchmarks/jyotish/scripts/run_transit_true_compare.py +python3 benchmarks/jyotish/scripts/run_shadbala_invariants.py +``` + +Some scripts require optional local dependencies such as PyJHora or pyswisseph. If PyJHora is installed outside the default environment, set `PYJHORA_SITE` or `PYJHORA_PATH` as needed. + +## Historical benchmark rounds + +The recovered reports document the benchmark sequence used to harden the engine: + +1. Local full-reading baseline +2. Swiss direct planetary comparison +3. Swiss extended comparison +4. PyJHora comparison +5. Mean/True node arbitration +6. Arudha/A10 comparison +7. Ashtakavarga comparison and book-example arbitration +8. Chara Dasha comparison +9. True transit comparison +10. Shadbala internal invariants +11. Explanation regression notes diff --git a/benchmarks/jyotish/data/benchmark_samples.json b/benchmarks/jyotish/data/benchmark_samples.json new file mode 100644 index 00000000..f1555897 --- /dev/null +++ b/benchmarks/jyotish/data/benchmark_samples.json @@ -0,0 +1,82 @@ +[ + { + "id": "smoke_beijing_1990_noon", + "label": "Smoke Beijing 1990 noon", + "category": "smoke", + "birth": {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 39.9042, "lon": 116.4074, "tz": 8}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_newyork_1985_morning", + "label": "Smoke New York 1985 morning", + "category": "smoke", + "birth": {"year": 1985, "month": 7, "day": 13, "hour": 8, "minute": 30, "lat": 40.7128, "lon": -74.0060, "tz": -4}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_london_1970_evening", + "label": "Smoke London 1970 evening", + "category": "smoke", + "birth": {"year": 1970, "month": 3, "day": 21, "hour": 18, "minute": 15, "lat": 51.5074, "lon": -0.1278, "tz": 0}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_delhi_2000_midnight", + "label": "Smoke Delhi 2000 midnight", + "category": "smoke", + "birth": {"year": 2000, "month": 12, "day": 25, "hour": 0, "minute": 5, "lat": 28.6139, "lon": 77.2090, "tz": 5.5}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_sydney_1999_afternoon", + "label": "Smoke Sydney 1999 afternoon", + "category": "smoke", + "birth": {"year": 1999, "month": 9, "day": 9, "hour": 15, "minute": 45, "lat": -33.8688, "lon": 151.2093, "tz": 10}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_tokyo_1964_noon", + "label": "Smoke Tokyo 1964 noon", + "category": "smoke", + "birth": {"year": 1964, "month": 10, "day": 10, "hour": 12, "minute": 0, "lat": 35.6762, "lon": 139.6503, "tz": 9}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_cairo_1952_dawn", + "label": "Smoke Cairo 1952 dawn", + "category": "smoke", + "birth": {"year": 1952, "month": 7, "day": 23, "hour": 5, "minute": 20, "lat": 30.0444, "lon": 31.2357, "tz": 2}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_paris_1989_noon", + "label": "Smoke Paris 1989 noon", + "category": "smoke", + "birth": {"year": 1989, "month": 11, "day": 9, "hour": 12, "minute": 0, "lat": 48.8566, "lon": 2.3522, "tz": 1}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_losangeles_1995_night", + "label": "Smoke Los Angeles 1995 night", + "category": "smoke", + "birth": {"year": 1995, "month": 5, "day": 5, "hour": 23, "minute": 40, "lat": 34.0522, "lon": -118.2437, "tz": -7}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + }, + { + "id": "smoke_sao_paulo_2004_morning", + "label": "Smoke Sao Paulo 2004 morning", + "category": "smoke", + "birth": {"year": 2004, "month": 2, "day": 29, "hour": 9, "minute": 10, "lat": -23.5558, "lon": -46.6396, "tz": -3}, + "today": "2026-06-03", + "privacy": "fictional_or_public_test" + } +] diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round10_explanation_regression.md b/benchmarks/jyotish/reports/jyotish_benchmark_round10_explanation_regression.md new file mode 100644 index 00000000..7ad40e19 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round10_explanation_regression.md @@ -0,0 +1,115 @@ +# Jyotish Benchmark Round 10 — 解释层回归与置信度声明审计 + +日期:2026-06-04 + +## 目标 + +确认前几轮计算层修复和能力降级已经进入解释层与输出模板,避免出现以下问题: + +1. Chara Dasha 已经降级为 `partial`,但文档或输出仍把它当作高置信度应期模块。 +2. Shadbala 已经降级为 `partial`,但仍声称完成传统绝对值校准。 +3. full-reading Transit 已修复为真实过境,但解释层仍可能不声明 `true_transit_positions`。 +4. Ashtakavarga v2.1 已经完成 BPHS/PVR 书例校准,但输出或文档未体现。 + +## 本轮修正 + +### 1. Jaimini / Chara Dasha 表述降权 + +修正文件: + +- `README.md` +- `assets/timing-prediction-template.md` +- `references/strict-workflow-router.md` +- `scripts/jaimini.py` +- `scripts/jyotish_engine.py` + +关键变化: + +- Jaimini 静态层保留:Chara Karaka、AK/AmK、Karakamsha。 +- Chara Dasha timing 明确为 `partial`。 +- 在 `full-reading.modules.jaimini` 中新增: + +```json +"chara_dasha_capability": { + "status": "partial", + "reason": "Round 7 vs PyJHora KN Rao matched 58/240 fields; current implementation is simplified, not full KN Rao/PVN Rao/Iranganti.", + "usage_rule": "Use Chara Karaka/Karakamsha normally; use Chara Dasha timing only as low-weight corroboration." +} +``` + +### 2. Shadbala 表述降权 + +修正文件: + +- `scripts/shadbala.py` +- `scripts/report_builder.py` + +关键变化: + +- `scripts/shadbala.py` 不再称为“完整 Shadbala”。 +- 输出 method 改为: + +```text +Shadbala六重力量(内部一致相对强弱;外部绝对值校准前partial) +``` + +- 报告封面中的量化指标改为: + +```text +Shadbala (relative/partial), Ashtakavarga v2.1 (SAV/BAV), D9 Navamsha +``` + +### 3. Technique Audit Table 模板修正 + +`references/strict-workflow-router.md` 中 Technique Audit Table 已明确: + +- `Jaimini / Chara Dasha` 状态可为 `Used / partial / not used`。 +- `Shadbala` 状态可为 `Used / partial / not used`。 +- 两者都必须说明对置信度的影响。 + +## 回归抽查结果 + +代表性命令: + +```bash +python3 scripts/jyotish_engine.py full-reading \ + --year 1990 --month 1 --day 1 --hour 12 --minute 0 \ + --lat 39.9042 --lon 116.4074 --tz 8 \ + --today 2026-06-04 --transit-date 2026-06-04 +``` + +关键输出: + +| 检查项 | 结果 | +|---|---| +| full-reading errors | `[]` | +| registry problem_count | `0` | +| registry warning_count | `0` | +| status_counts.covered | `12` | +| status_counts.partial | `4` | +| `modules.transit_positions.data_layer` | `true_transit_positions` | +| `modules.transit_multi_reference.data_layer` | `true_transit_positions` | +| `modules.transit_positions.target_date` | `2026-06-04` | +| `modules.ashtakavarga.method` | `Ashtakavarga八分法(BPHS/PVR书例校准v2.1)` | +| `modules.shadbala.method` | `Shadbala六重力量(内部一致相对强弱;外部绝对值校准前partial)` | +| `modules.jaimini.chara_dasha_capability.status` | `partial` | + +## career_timing_strict audit table 抽查 + +| Technique | Status | Limitation present | Output paths | +|---|---|---:|---| +| `jaimini_chara_dasha` | `partial` | yes | `modules.jaimini` | +| `a10_karma_pada` | `covered` | no | `modules.special_lagnas.A10_Karma_Pada` | +| `shadbala` | `partial` | yes | `modules.shadbala` | +| `ashtakavarga` | `covered` | no | `modules.ashtakavarga` | + +## 结论 + +第十轮解释层回归通过。当前 skill 已经把计算层可信度变化同步到解释层: + +- Chara Dasha 不再被包装成高置信度完整应期模块。 +- Shadbala 不再声称完成外部绝对值校准。 +- full-reading Transit 明确输出真实过境数据层。 +- Ashtakavarga 输出保留 v2.1 BPHS/PVR 书例校准口径。 + +下一步可以进入本地提交与 GitHub 同步阶段;若要继续提高可信度,则应优先实装并对标 KN Rao/PVN Rao Chara Dasha,或接入 JHora/公开书例完成 Shadbala 外部绝对值校准。 diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round1_local_baseline.md b/benchmarks/jyotish/reports/jyotish_benchmark_round1_local_baseline.md new file mode 100644 index 00000000..7040e86e --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round1_local_baseline.md @@ -0,0 +1,46 @@ +# Jyotish benchmark 第一轮本地基线报告 + +生成时间:2026-06-03 + +## 1. 本轮范围 + +- 本轮只建立当前 skill 的 canonical baseline。 +- 样本全部为公开/虚构 smoke test,不包含用户个人出生资料。 +- 还没有接入 PyJHora / VedAstro / jyotishyamitra 等外部引擎,因此本轮不能给最终可信度评分。 + +## 2. 执行结果 + +- 样本数:10 +- 成功:10 +- 失败:0 +- 输出目录:`jyotish_benchmark/outputs/` + +## 3. 样本摘要 + +| Sample | Ascendant | MD/AD | A10 | Modules | Empty modules | +|---|---|---|---|---:|---| +| smoke_beijing_1990_noon | {'sign': 'Pisces', 'sign_cn': '双鱼座', 'degree': 348.1199, 'degree_in_sign': 18.1199, 'lord': 'Jupiter'} | Saturn / Saturn | Virgo | 27 | - | +| smoke_newyork_1985_morning | {'sign': 'Leo', 'sign_cn': '狮子座', 'degree': 120.7023, 'degree_in_sign': 0.7023, 'lord': 'Sun'} | Jupiter / Saturn | Aquarius | 27 | - | +| smoke_london_1970_evening | {'sign': 'Virgo', 'sign_cn': '处女座', 'degree': 158.3003, 'degree_in_sign': 8.3003, 'lord': 'Mercury'} | Jupiter / Venus | Virgo | 27 | - | +| smoke_delhi_2000_midnight | {'sign': 'Virgo', 'sign_cn': '处女座', 'degree': 155.7304, 'degree_in_sign': 5.7304, 'lord': 'Mercury'} | Venus / Mercury | Pisces | 27 | - | +| smoke_sydney_1999_afternoon | {'sign': 'Capricorn', 'sign_cn': '摩羯座', 'degree': 298.7176, 'degree_in_sign': 28.7176, 'lord': 'Saturn'} | Moon / Rahu | Capricorn | 27 | - | +| smoke_tokyo_1964_noon | {'sign': 'Sagittarius', 'sign_cn': '射手座', 'degree': 251.5357, 'degree_in_sign': 11.5357, 'lord': 'Jupiter'} | Moon / Venus | Gemini | 27 | - | +| smoke_cairo_1952_dawn | {'sign': 'Cancer', 'sign_cn': '巨蟹座', 'degree': 98.5969, 'degree_in_sign': 8.5969, 'lord': 'Moon'} | Rahu / Ketu | Capricorn | 27 | - | +| smoke_paris_1989_noon | {'sign': 'Sagittarius', 'sign_cn': '射手座', 'degree': 252.1586, 'degree_in_sign': 12.1586, 'lord': 'Jupiter'} | Mercury / Jupiter | Scorpio | 27 | - | +| smoke_losangeles_1995_night | {'sign': 'Sagittarius', 'sign_cn': '射手座', 'degree': 254.083, 'degree_in_sign': 14.083, 'lord': 'Jupiter'} | Mercury / Rahu | Capricorn | 27 | - | +| smoke_sao_paulo_2004_morning | {'sign': 'Pisces', 'sign_cn': '双鱼座', 'degree': 358.4495, 'degree_in_sign': 28.4495, 'lord': 'Jupiter'} | Jupiter / Jupiter | Aries | 27 | - | + +## 4. 发现 + +- 当前 skill 对 10 个 smoke 样本都能生成 full-reading canonical JSON。 +- 这证明内部输出契约具备批量 benchmark 的基础。 +- 但这只是 baseline,不是外部可信度证明。 +- 下一步必须接入至少 PyJHora 和 jyotishyamitra,形成 cross-engine matrix。 + +## 5. 下一步 + +1. 安装/隔离运行 PyJHora,抽取 D1/D9/D10/Dasha。 +2. 安装/隔离运行 jyotishyamitra,抽取 JSON 输出。 +3. 若 VedAstro API 可用,加入 API 对比;否则列为人工/半自动。 +4. 生成 `cross_engine_matrix.csv`,按字段计算一致/不一致/不可比。 +5. 对边界样本单独标注,避免误判。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round1_swiss_direct_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round1_swiss_direct_compare.md new file mode 100644 index 00000000..bb58c4f2 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round1_swiss_direct_compare.md @@ -0,0 +1,32 @@ +# Jyotish benchmark 第一轮 Swiss direct 对比报告 + +生成时间:2026-06-03 + +## 1. 范围 + +- 对比对象:当前 skill canonical baseline vs 直接调用 Swiss Ephemeris。 +- 配置:Sidereal Lahiri,Mean Node,行星黄经与 Nakshatra 字段。 +- 本轮不比较上升、宫位、D9/D10、大运;这些留给下一轮多引擎/参数冻结测试。 + +## 2. 总体结果 + +- 字段总数:450 +- 匹配:450 +- 不匹配:0 +- 匹配率:100.00% + +## 3. 分字段结果 + +| Field | Total | Match | Mismatch | +|---|---:|---:|---:| +| degree_in_sign | 90 | 90 | 0 | +| nakshatra | 90 | 90 | 0 | +| nakshatra_pada | 90 | 90 | 0 | +| retrograde | 90 | 90 | 0 | +| sign | 90 | 90 | 0 | + +## 5. 解释 + +- 若 sign/nakshatra 大量一致,说明当前 skill 的核心 Lahiri 行星计算大方向可信。 +- 若 degree_in_sign 出现系统性差异,优先检查 ayanamsa、True/Mean Node、UTC换算、Swiss flags。 +- 本轮发现的问题只约束计算层,不直接评价解释和预测能力。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round2_swiss_extended_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round2_swiss_extended_compare.md new file mode 100644 index 00000000..98b786e4 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round2_swiss_extended_compare.md @@ -0,0 +1,45 @@ +# Jyotish benchmark 第二轮 Swiss extended 对比报告 + +生成时间:2026-06-03 + +## 1. 范围 + +- 对比对象:当前 skill canonical baseline vs 直接调用 Swiss Ephemeris + 独立复写的 D9/D10/Vimshottari 公式。 +- 样本:10 个公开/虚构 smoke case,不含用户个人资料。 +- 本轮新增字段:Ascendant、D9、D10、当前 Vimshottari MD/AD。 +- 注意:D9/D10/Vimshottari 的公式仍参考当前 skill 的公开公式重写,属于“独立脚本复算”,不是 PyJHora/JHora 级别的完全外部流派验证。 + +## 2. 总体结果 + +- 字段总数:490 +- 匹配:486 +- 不匹配:0 +- 边界敏感:4 +- 不可比:0 +- 严格匹配率:99.18% +- 容差/边界归因后可接受率:100.00% + +## 3. 分模块结果 + +| Section | Total | Match | Mismatch | Boundary sensitive | Not comparable | +|---|---:|---:|---:|---:|---:| +| D10 | 200 | 196 | 0 | 4 | 0 | +| D9 | 200 | 200 | 0 | 0 | 0 | +| ascendant | 30 | 30 | 0 | 0 | 0 | +| dasha | 60 | 60 | 0 | 0 | 0 | + +## 4b. 边界敏感字段 + +- 这些字段不是普通错配,而是度数处于分盘切分边界附近;四舍五入、Mean/True Node、JHora流派参数都可能导致落入相邻分盘。后续必须用 PyJHora/JHora 再仲裁。 + +| Sample | Section | Body | Field | Local skill | Swiss extended | Delta | +|---|---|---|---|---|---|---:| +| smoke_tokyo_1964_noon | D10 | Rahu | sign | Gemini | Cancer | | +| smoke_tokyo_1964_noon | D10 | Rahu | degree_in_sign | 29.9773 | 0.0278 | 29.9495 | +| smoke_tokyo_1964_noon | D10 | Ketu | sign | Sagittarius | Capricorn | | +| smoke_tokyo_1964_noon | D10 | Ketu | degree_in_sign | 29.9773 | 0.0278 | 29.9495 | + +## 5. 判断 + +- 第二轮未发现不匹配,说明当前 skill 的 Ascendant、D9、D10、Vimshottari 当前 MD/AD 在本地独立复算下稳定。 +- 这仍然不能替代 PyJHora / JHora / VedAstro 的外部多引擎验证;它只是把内部公式错误和 UTC/边界错误的风险进一步压低。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round3_pyjhora_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round3_pyjhora_compare.md new file mode 100644 index 00000000..fafa5ccb --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round3_pyjhora_compare.md @@ -0,0 +1,124 @@ +# Jyotish benchmark 第三轮:PyJHora 对比报告 + +生成时间:2026-06-03 + +## 1. 本轮范围 + +- 外部引擎:PyJHora 4.8.6。 +- 用途:第二个独立 Jyotish 开源项目对标,重点验证 D1、D9、D10,并初探 Vimshottari。 +- 样本:10个公开/虚构 smoke case,不包含用户个人资料。 +- 口径:强制 Lahiri;PyJHora 默认 TRUE_PUSHYA,因此本轮显式切换到 LAHIRI。 +- 兼容处理:PyJHora 4.8.6 与本机 pyswisseph API 存在关键字参数/常量兼容问题,本脚本只在 benchmark 进程内 monkeypatch,不改 PyJHora 源码,不把 AGPL 代码并入 skill。 + +## 2. 总体结果 + +- 字段总数:840 +- 匹配:764 +- 不匹配:68 +- 边界敏感:8 +- 总严格匹配率:90.95% +- 非 Dasha 字段严格匹配率:90.26% +- 非 Dasha 字段边界归因后可接受率:91.28% + +## 3. 分区统计 + +| Section | Total | Match | Mismatch | Boundary sensitive | Not comparable | +|---|---:|---:|---:|---:|---:| +| D10 | 200 | 174 | 22 | 4 | 0 | +| D9 | 200 | 176 | 20 | 4 | 0 | +| ascendant | 20 | 20 | 0 | 0 | 0 | +| dasha | 60 | 60 | 0 | 0 | 0 | +| planet | 360 | 334 | 26 | 0 | 0 | + +## 4. 不匹配字段 + +| Sample | Section | Body | Field | Local skill | PyJHora | Delta | +|---|---|---|---|---|---|---:| +| smoke_beijing_1990_noon | planet | Rahu | nakshatra | Dhanishta | Shravana | | +| smoke_beijing_1990_noon | planet | Rahu | nakshatra_pada | 1 | 4 | | +| smoke_beijing_1990_noon | planet | Rahu | degree_in_sign | 24.7353 | 23.141 | 1.5943 | +| smoke_beijing_1990_noon | planet | Ketu | nakshatra_pada | 3 | 2 | | +| smoke_beijing_1990_noon | planet | Ketu | degree_in_sign | 24.7353 | 23.141 | 1.5943 | +| smoke_beijing_1990_noon | D9 | Rahu | sign | Leo | Cancer | | +| smoke_beijing_1990_noon | D9 | Rahu | degree_in_sign | 12.6174 | 28.2689 | 15.6515 | +| smoke_beijing_1990_noon | D9 | Ketu | sign | Aquarius | Capricorn | | +| smoke_beijing_1990_noon | D9 | Ketu | degree_in_sign | 12.6174 | 28.2689 | 15.6515 | +| smoke_beijing_1990_noon | D10 | Rahu | sign | Taurus | Aries | | +| smoke_beijing_1990_noon | D10 | Rahu | degree_in_sign | 7.3526 | 21.4099 | 14.0573 | +| smoke_beijing_1990_noon | D10 | Ketu | sign | Scorpio | Libra | | +| smoke_beijing_1990_noon | D10 | Ketu | degree_in_sign | 7.3526 | 21.4099 | 14.0573 | +| smoke_newyork_1985_morning | planet | Rahu | degree_in_sign | 21.246 | 22.5483 | 1.3023 | +| smoke_newyork_1985_morning | planet | Ketu | degree_in_sign | 21.246 | 22.5483 | 1.3023 | +| smoke_newyork_1985_morning | D9 | Rahu | degree_in_sign | 11.2143 | 22.9344 | 11.7201 | +| smoke_newyork_1985_morning | D9 | Ketu | degree_in_sign | 11.2143 | 22.9344 | 11.7201 | +| smoke_newyork_1985_morning | D10 | Rahu | degree_in_sign | 2.4603 | 15.4827 | 13.0224 | +| smoke_newyork_1985_morning | D10 | Ketu | degree_in_sign | 2.4603 | 15.4827 | 13.0224 | +| smoke_london_1970_evening | planet | Rahu | degree_in_sign | 17.6225 | 18.4125 | 0.79 | +| smoke_london_1970_evening | planet | Ketu | degree_in_sign | 17.6225 | 18.4125 | 0.79 | +| smoke_london_1970_evening | D9 | Rahu | degree_in_sign | 8.6025 | 15.7124 | 7.1099 | +| smoke_london_1970_evening | D9 | Ketu | degree_in_sign | 8.6025 | 15.7124 | 7.1099 | +| smoke_delhi_2000_midnight | planet | Rahu | degree_in_sign | 22.1972 | 21.616 | 0.5812 | +| smoke_delhi_2000_midnight | planet | Ketu | degree_in_sign | 22.1972 | 21.616 | 0.5812 | +| smoke_delhi_2000_midnight | D9 | Rahu | degree_in_sign | 19.7747 | 14.5444 | 5.2303 | +| smoke_delhi_2000_midnight | D9 | Ketu | degree_in_sign | 19.7747 | 14.5444 | 5.2303 | +| smoke_delhi_2000_midnight | D10 | Rahu | degree_in_sign | 11.9719 | 6.1604 | 5.8115 | +| smoke_delhi_2000_midnight | D10 | Ketu | degree_in_sign | 11.9719 | 6.1604 | 5.8115 | +| smoke_sydney_1999_afternoon | planet | Rahu | degree_in_sign | 17.2389 | 18.8586 | 1.6197 | +| smoke_sydney_1999_afternoon | planet | Ketu | degree_in_sign | 17.2389 | 18.8586 | 1.6197 | +| smoke_sydney_1999_afternoon | D9 | Rahu | degree_in_sign | 5.1499 | 19.7276 | 14.5777 | +| smoke_sydney_1999_afternoon | D9 | Ketu | degree_in_sign | 5.1499 | 19.7276 | 14.5777 | +| smoke_sydney_1999_afternoon | D10 | Rahu | sign | Leo | Virgo | | +| smoke_sydney_1999_afternoon | D10 | Rahu | degree_in_sign | 22.3888 | 8.5862 | 13.8026 | +| smoke_sydney_1999_afternoon | D10 | Ketu | sign | Aquarius | Pisces | | +| smoke_sydney_1999_afternoon | D10 | Ketu | degree_in_sign | 22.3888 | 8.5862 | 13.8026 | +| smoke_tokyo_1964_noon | planet | Rahu | degree_in_sign | 2.9977 | 1.9596 | 1.0381 | +| smoke_tokyo_1964_noon | planet | Ketu | degree_in_sign | 2.9977 | 1.9596 | 1.0381 | +| smoke_tokyo_1964_noon | D9 | Rahu | degree_in_sign | 26.9795 | 17.6361 | 9.3434 | +| smoke_tokyo_1964_noon | D9 | Ketu | degree_in_sign | 26.9795 | 17.6361 | 9.3434 | +| smoke_tokyo_1964_noon | D10 | Rahu | degree_in_sign | 29.9773 | 19.5957 | 10.3816 | +| smoke_tokyo_1964_noon | D10 | Ketu | degree_in_sign | 29.9773 | 19.5957 | 10.3816 | +| smoke_cairo_1952_dawn | planet | Rahu | degree_in_sign | 29.4558 | 28.3255 | 1.1303 | +| smoke_cairo_1952_dawn | planet | Ketu | degree_in_sign | 29.4558 | 28.3255 | 1.1303 | +| smoke_cairo_1952_dawn | D9 | Rahu | degree_in_sign | 25.1021 | 14.9291 | 10.173 | +| smoke_cairo_1952_dawn | D9 | Ketu | degree_in_sign | 25.1021 | 14.9291 | 10.173 | +| smoke_cairo_1952_dawn | D10 | Rahu | degree_in_sign | 24.5579 | 13.2545 | 11.3034 | +| smoke_cairo_1952_dawn | D10 | Ketu | degree_in_sign | 24.5579 | 13.2545 | 11.3034 | +| smoke_paris_1989_noon | planet | Rahu | degree_in_sign | 27.5276 | 28.0232 | 0.4956 | +| smoke_paris_1989_noon | planet | Ketu | degree_in_sign | 27.5276 | 28.0232 | 0.4956 | +| smoke_paris_1989_noon | D9 | Rahu | degree_in_sign | 7.7486 | 12.209 | 4.4604 | +| smoke_paris_1989_noon | D9 | Ketu | degree_in_sign | 7.7486 | 12.209 | 4.4604 | +| smoke_paris_1989_noon | D10 | Rahu | degree_in_sign | 5.2762 | 10.2322 | 4.956 | +| smoke_paris_1989_noon | D10 | Ketu | degree_in_sign | 5.2762 | 10.2322 | 4.956 | +| smoke_losangeles_1995_night | planet | Rahu | degree_in_sign | 11.3412 | 11.7412 | 0.4 | +| smoke_losangeles_1995_night | planet | Ketu | degree_in_sign | 11.3412 | 11.7412 | 0.4 | +| smoke_losangeles_1995_night | D9 | Rahu | degree_in_sign | 12.0704 | 15.671 | 3.6006 | +| smoke_losangeles_1995_night | D9 | Ketu | degree_in_sign | 12.0704 | 15.671 | 3.6006 | +| smoke_losangeles_1995_night | D10 | Rahu | degree_in_sign | 23.4116 | 27.4122 | 4.0006 | +| smoke_losangeles_1995_night | D10 | Ketu | degree_in_sign | 23.4116 | 27.4122 | 4.0006 | +| smoke_sao_paulo_2004_morning | planet | Rahu | nakshatra_pada | 3 | 2 | | +| smoke_sao_paulo_2004_morning | planet | Rahu | degree_in_sign | 20.6362 | 19.622 | 1.0142 | +| smoke_sao_paulo_2004_morning | planet | Ketu | nakshatra | Vishakha | Swati | | +| smoke_sao_paulo_2004_morning | planet | Ketu | nakshatra_pada | 1 | 4 | | +| smoke_sao_paulo_2004_morning | planet | Ketu | degree_in_sign | 20.6362 | 19.622 | 1.0142 | +| smoke_sao_paulo_2004_morning | D10 | Rahu | degree_in_sign | 26.3619 | 16.2197 | 10.1422 | +| smoke_sao_paulo_2004_morning | D10 | Ketu | degree_in_sign | 26.3619 | 16.2197 | 10.1422 | + +## 4b. 边界敏感字段 + +| Sample | Section | Body | Field | Local skill | PyJHora | Delta | +|---|---|---|---|---|---|---:| +| smoke_london_1970_evening | D10 | Rahu | sign | Cancer | Leo | | +| smoke_london_1970_evening | D10 | Rahu | degree_in_sign | 26.225 | 4.1249 | 22.1001 | +| smoke_london_1970_evening | D10 | Ketu | sign | Capricorn | Aquarius | | +| smoke_london_1970_evening | D10 | Ketu | degree_in_sign | 26.225 | 4.1249 | 22.1001 | +| smoke_sao_paulo_2004_morning | D9 | Rahu | sign | Libra | Virgo | | +| smoke_sao_paulo_2004_morning | D9 | Rahu | degree_in_sign | 5.7257 | 26.5977 | 20.872 | +| smoke_sao_paulo_2004_morning | D9 | Ketu | sign | Aries | Pisces | | +| smoke_sao_paulo_2004_morning | D9 | Ketu | degree_in_sign | 5.7257 | 26.5977 | 20.872 | + +## 5. 判断 + +- PyJHora 作为第二开源引擎已经接入成功。 +- D1/D9/D10若高匹配,说明当前 skill 的分盘算法不仅与 Swiss direct 自算一致,也能通过独立 Jyotish 项目的实测。 +- Dasha 部分若存在系统性差异,优先视为 PyJHora seed_star / dasha year / 起运规则口径差异,不能马上判定本 skill 错;需要 JHora 或 Drik Panchang 再仲裁。 +- PyJHora 是 AGPL-3.0,适合做外部 benchmark,不适合把其源码或派生实现并入当前 skill。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round4_node_mode_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round4_node_mode_compare.md new file mode 100644 index 00000000..cdf6aa1d --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round4_node_mode_compare.md @@ -0,0 +1,98 @@ +# Jyotish benchmark 第四轮:Rahu/Ketu 节点口径仲裁 + +生成时间:2026-06-03 + +## 1. 本轮目的 + +- 解释第三轮 PyJHora 对比中 Rahu/Ketu 大量差异的根因。 +- 对比当前 skill canonical baseline 与 Swiss Ephemeris Mean Node、Swiss Ephemeris True Node、PyJHora rasi_chart 默认输出。 +- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。 + +## 2. 总体结果 + +| Target | Total | Match | Mismatch | Match rate | +|---|---:|---:|---:|---:| +| swiss_mean_node | 80 | 80 | 0 | 100.00% | +| swiss_true_node | 80 | 54 | 26 | 67.50% | +| pyjhora_default_rasi | 80 | 54 | 26 | 67.50% | + +## 3. 分字段统计 + +| Target | Field | Total | Match | Mismatch | +|---|---|---:|---:|---:| +| swiss_mean_node | sign | 20 | 20 | 0 | +| swiss_mean_node | degree_in_sign | 20 | 20 | 0 | +| swiss_mean_node | nakshatra | 20 | 20 | 0 | +| swiss_mean_node | nakshatra_pada | 20 | 20 | 0 | +| swiss_true_node | sign | 20 | 20 | 0 | +| swiss_true_node | degree_in_sign | 20 | 0 | 20 | +| swiss_true_node | nakshatra | 20 | 18 | 2 | +| swiss_true_node | nakshatra_pada | 20 | 16 | 4 | +| pyjhora_default_rasi | sign | 20 | 20 | 0 | +| pyjhora_default_rasi | degree_in_sign | 20 | 0 | 20 | +| pyjhora_default_rasi | nakshatra | 20 | 18 | 2 | +| pyjhora_default_rasi | nakshatra_pada | 20 | 16 | 4 | + +## 4. 关键不匹配样例 + +| Sample | Target | Body | Field | Local skill | Target value | Delta | +|---|---|---|---|---|---|---:| +| smoke_beijing_1990_noon | swiss_true_node | Rahu | nakshatra | Dhanishta | Shravana | | +| smoke_beijing_1990_noon | swiss_true_node | Rahu | nakshatra_pada | 1 | 4 | | +| smoke_beijing_1990_noon | swiss_true_node | Rahu | degree_in_sign | 24.7353 | 23.140989 | 1.594311 | +| smoke_beijing_1990_noon | swiss_true_node | Ketu | nakshatra_pada | 3 | 2 | | +| smoke_beijing_1990_noon | swiss_true_node | Ketu | degree_in_sign | 24.7353 | 23.140989 | 1.594311 | +| smoke_beijing_1990_noon | pyjhora_default_rasi | Rahu | nakshatra | Dhanishta | Shravana | | +| smoke_beijing_1990_noon | pyjhora_default_rasi | Rahu | nakshatra_pada | 1 | 4 | | +| smoke_beijing_1990_noon | pyjhora_default_rasi | Rahu | degree_in_sign | 24.7353 | 23.140989 | 1.594311 | +| smoke_beijing_1990_noon | pyjhora_default_rasi | Ketu | nakshatra_pada | 3 | 2 | | +| smoke_beijing_1990_noon | pyjhora_default_rasi | Ketu | degree_in_sign | 24.7353 | 23.140989 | 1.594311 | +| smoke_newyork_1985_morning | swiss_true_node | Rahu | degree_in_sign | 21.246 | 22.548271 | 1.302271 | +| smoke_newyork_1985_morning | swiss_true_node | Ketu | degree_in_sign | 21.246 | 22.548271 | 1.302271 | +| smoke_newyork_1985_morning | pyjhora_default_rasi | Rahu | degree_in_sign | 21.246 | 22.548271 | 1.302271 | +| smoke_newyork_1985_morning | pyjhora_default_rasi | Ketu | degree_in_sign | 21.246 | 22.548271 | 1.302271 | +| smoke_london_1970_evening | swiss_true_node | Rahu | degree_in_sign | 17.6225 | 18.41249 | 0.78999 | +| smoke_london_1970_evening | swiss_true_node | Ketu | degree_in_sign | 17.6225 | 18.41249 | 0.78999 | +| smoke_london_1970_evening | pyjhora_default_rasi | Rahu | degree_in_sign | 17.6225 | 18.41249 | 0.78999 | +| smoke_london_1970_evening | pyjhora_default_rasi | Ketu | degree_in_sign | 17.6225 | 18.41249 | 0.78999 | +| smoke_delhi_2000_midnight | swiss_true_node | Rahu | degree_in_sign | 22.1972 | 21.616044 | 0.581156 | +| smoke_delhi_2000_midnight | swiss_true_node | Ketu | degree_in_sign | 22.1972 | 21.616044 | 0.581156 | +| smoke_delhi_2000_midnight | pyjhora_default_rasi | Rahu | degree_in_sign | 22.1972 | 21.616044 | 0.581156 | +| smoke_delhi_2000_midnight | pyjhora_default_rasi | Ketu | degree_in_sign | 22.1972 | 21.616044 | 0.581156 | +| smoke_sydney_1999_afternoon | swiss_true_node | Rahu | degree_in_sign | 17.2389 | 18.858624 | 1.619724 | +| smoke_sydney_1999_afternoon | swiss_true_node | Ketu | degree_in_sign | 17.2389 | 18.858624 | 1.619724 | +| smoke_sydney_1999_afternoon | pyjhora_default_rasi | Rahu | degree_in_sign | 17.2389 | 18.858624 | 1.619724 | +| smoke_sydney_1999_afternoon | pyjhora_default_rasi | Ketu | degree_in_sign | 17.2389 | 18.858624 | 1.619724 | +| smoke_tokyo_1964_noon | swiss_true_node | Rahu | degree_in_sign | 2.9977 | 1.959571 | 1.038129 | +| smoke_tokyo_1964_noon | swiss_true_node | Ketu | degree_in_sign | 2.9977 | 1.959571 | 1.038129 | +| smoke_tokyo_1964_noon | pyjhora_default_rasi | Rahu | degree_in_sign | 2.9977 | 1.959571 | 1.038129 | +| smoke_tokyo_1964_noon | pyjhora_default_rasi | Ketu | degree_in_sign | 2.9977 | 1.959571 | 1.038129 | +| smoke_cairo_1952_dawn | swiss_true_node | Rahu | degree_in_sign | 29.4558 | 28.325454 | 1.130346 | +| smoke_cairo_1952_dawn | swiss_true_node | Ketu | degree_in_sign | 29.4558 | 28.325454 | 1.130346 | +| smoke_cairo_1952_dawn | pyjhora_default_rasi | Rahu | degree_in_sign | 29.4558 | 28.325453 | 1.130347 | +| smoke_cairo_1952_dawn | pyjhora_default_rasi | Ketu | degree_in_sign | 29.4558 | 28.325453 | 1.130347 | +| smoke_paris_1989_noon | swiss_true_node | Rahu | degree_in_sign | 27.5276 | 28.023225 | 0.495625 | +| smoke_paris_1989_noon | swiss_true_node | Ketu | degree_in_sign | 27.5276 | 28.023225 | 0.495625 | +| smoke_paris_1989_noon | pyjhora_default_rasi | Rahu | degree_in_sign | 27.5276 | 28.023225 | 0.495625 | +| smoke_paris_1989_noon | pyjhora_default_rasi | Ketu | degree_in_sign | 27.5276 | 28.023225 | 0.495625 | +| smoke_losangeles_1995_night | swiss_true_node | Rahu | degree_in_sign | 11.3412 | 11.741225 | 0.400025 | +| smoke_losangeles_1995_night | swiss_true_node | Ketu | degree_in_sign | 11.3412 | 11.741225 | 0.400025 | +| smoke_losangeles_1995_night | pyjhora_default_rasi | Rahu | degree_in_sign | 11.3412 | 11.741225 | 0.400025 | +| smoke_losangeles_1995_night | pyjhora_default_rasi | Ketu | degree_in_sign | 11.3412 | 11.741225 | 0.400025 | +| smoke_sao_paulo_2004_morning | swiss_true_node | Rahu | nakshatra_pada | 3 | 2 | | +| smoke_sao_paulo_2004_morning | swiss_true_node | Rahu | degree_in_sign | 20.6362 | 19.621972 | 1.014228 | +| smoke_sao_paulo_2004_morning | swiss_true_node | Ketu | nakshatra | Vishakha | Swati | | +| smoke_sao_paulo_2004_morning | swiss_true_node | Ketu | nakshatra_pada | 1 | 4 | | +| smoke_sao_paulo_2004_morning | swiss_true_node | Ketu | degree_in_sign | 20.6362 | 19.621972 | 1.014228 | +| smoke_sao_paulo_2004_morning | pyjhora_default_rasi | Rahu | nakshatra_pada | 3 | 2 | | +| smoke_sao_paulo_2004_morning | pyjhora_default_rasi | Rahu | degree_in_sign | 20.6362 | 19.621972 | 1.014228 | +| smoke_sao_paulo_2004_morning | pyjhora_default_rasi | Ketu | nakshatra | Vishakha | Swati | | +| smoke_sao_paulo_2004_morning | pyjhora_default_rasi | Ketu | nakshatra_pada | 1 | 4 | | +| smoke_sao_paulo_2004_morning | pyjhora_default_rasi | Ketu | degree_in_sign | 20.6362 | 19.621972 | 1.014228 | + +## 5. 仲裁结论 + +- 当前 skill 的 Rahu/Ketu 与 Swiss Ephemeris **Mean Node** 口径完全一致;这解释了第一轮 Swiss direct 450/450 匹配。 +- PyJHora 4.8.6 的 `rasi_chart()` 默认走 `drik.dhasavarga(... set_rahu_ketu_as_true_nodes=True)`,即默认使用 **True Node**。 +- 因此第三轮 PyJHora 中 Rahu/Ketu 的 degree/nakshatra/D9/D10 差异,主要不是当前 skill 的计算 bug,而是 **Mean Node vs True Node 口径差异**。 +- 工程建议:当前 skill 应显式声明默认 `node_mode=mean`,后续可新增 `--node-mode mean|true` 参数;benchmark 报告中也应把节点口径列为冻结参数。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round5_arudha_a10_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round5_arudha_a10_compare.md new file mode 100644 index 00000000..4e47833e --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round5_arudha_a10_compare.md @@ -0,0 +1,37 @@ +# Jyotish benchmark 第五轮:A10 / Arudha Pada 交叉验证 + +生成时间:2026-06-03 + +## 1. 本轮目的 + +- 验证当前 skill 的 A10 / Karma Pada / Rajya Pada 符号输出是否与独立公式和 PyJHora Arudha 实现一致。 +- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。 +- 本轮先验证 sign/source_sign/source_lord/exception_applied 等结构字段;A10 精确度数属于不同传统口径,暂不作为硬性匹配字段。 + +## 2. 总体结果 + +| Target | Total | Match | Mismatch | Match rate | +|---|---:|---:|---:|---:| +| independent_formula | 60 | 60 | 0 | 100.00% | +| pyjhora_bhava_arudha | 10 | 10 | 0 | 100.00% | + +## 3. 逐样本 A10 Sign + +| Sample | Local skill A10 | Independent formula | PyJHora A10 | Status | +|---|---|---|---|---| +| smoke_beijing_1990_noon | Virgo | Virgo | Virgo | match | +| smoke_cairo_1952_dawn | Capricorn | Capricorn | Capricorn | match | +| smoke_delhi_2000_midnight | Pisces | Pisces | Pisces | match | +| smoke_london_1970_evening | Virgo | Virgo | Virgo | match | +| smoke_losangeles_1995_night | Capricorn | Capricorn | Capricorn | match | +| smoke_newyork_1985_morning | Aquarius | Aquarius | Aquarius | match | +| smoke_paris_1989_noon | Scorpio | Scorpio | Scorpio | match | +| smoke_sao_paulo_2004_morning | Aries | Aries | Aries | match | +| smoke_sydney_1999_afternoon | Capricorn | Capricorn | Capricorn | match | +| smoke_tokyo_1964_noon | Gemini | Gemini | Gemini | match | + +## 4. 仲裁结论 + +- 当前 skill 的 A10 sign 与独立 Jaimini Arudha formula 对齐。 +- 当前 skill 的 A10 sign 与 PyJHora `bhava_arudhas_from_planet_positions()` 对齐。 +- 因此 A10/Karma Pada 作为事业外显判断的计算入口,sign 层可暂定为通过;degree 层因为 PyJHora 同时提供 cusp-based longitude 版本,需单独定义传统口径后再纳入硬性 benchmark。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round6_ashtakavarga_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round6_ashtakavarga_compare.md new file mode 100644 index 00000000..780f02d8 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round6_ashtakavarga_compare.md @@ -0,0 +1,37 @@ +# Jyotish benchmark 第六轮:Ashtakavarga BAV/SAV 交叉验证 + +生成时间:2026-06-03 + +## 1. 本轮目的 + +- 验证当前 skill 的 Ashtakavarga BAV/SAV 是否与 PyJHora `get_ashtaka_varga()` 对齐。 +- 同时检查内部不变量:7行星 SAV 总分=337;含 Lagna full SAV 总分=386;各行星 BAV 固定总分正确。 +- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。 + +## 2. 总体结果 + +| Target | Total | Match | Mismatch | Match rate | +|---|---:|---:|---:|---:| +| pyjhora_sav | 120 | 120 | 0 | 100.00% | +| pyjhora_bav | 960 | 960 | 0 | 100.00% | +| invariants | 30 | 30 | 0 | 100.00% | + +## 3. 逐样本摘要 + +| Sample | SAV match | BAV match | SAV total | Full SAV | Strongest signs | Weakest signs | +|---|---:|---:|---:|---:|---|---| +| smoke_beijing_1990_noon | 12/12 | 96/96 | 337 | 386 | Sagittarius, Libra, Taurus | Capricorn, Cancer, Aquarius | +| smoke_newyork_1985_morning | 12/12 | 96/96 | 337 | 386 | Pisces, Gemini, Aries | Cancer, Virgo, Aquarius | +| smoke_london_1970_evening | 12/12 | 96/96 | 337 | 386 | Capricorn, Cancer, Aquarius | Virgo, Aries, Pisces | +| smoke_delhi_2000_midnight | 12/12 | 96/96 | 337 | 386 | Virgo, Libra, Cancer | Taurus, Gemini, Capricorn | +| smoke_sydney_1999_afternoon | 12/12 | 96/96 | 337 | 386 | Gemini, Capricorn, Aquarius | Sagittarius, Pisces, Cancer | +| smoke_tokyo_1964_noon | 12/12 | 96/96 | 337 | 386 | Taurus, Virgo, Aries | Pisces, Leo, Scorpio | +| smoke_cairo_1952_dawn | 12/12 | 96/96 | 337 | 386 | Taurus, Sagittarius, Aries | Aquarius, Pisces, Gemini | +| smoke_paris_1989_noon | 12/12 | 96/96 | 337 | 386 | Leo, Libra, Taurus | Virgo, Gemini, Scorpio | +| smoke_losangeles_1995_night | 12/12 | 96/96 | 337 | 386 | Virgo, Taurus, Libra | Leo, Scorpio, Gemini | +| smoke_sao_paulo_2004_morning | 12/12 | 96/96 | 337 | 386 | Sagittarius, Aries, Gemini | Taurus, Virgo, Libra | + +## 4. 仲裁结论 + +- 若 `pyjhora_sav` 与 `pyjhora_bav` 均为 100%,则 Ashtakavarga BAV/SAV 计算层可暂定通过。 +- Shodhya Pinda 不纳入本轮硬性通过;PyJHora 源码示例本身说明个别书例存在不一致,适合单独做弱口径验证。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round6b_ashtakavarga_table_arbitration.md b/benchmarks/jyotish/reports/jyotish_benchmark_round6b_ashtakavarga_table_arbitration.md new file mode 100644 index 00000000..3e37588f --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round6b_ashtakavarga_table_arbitration.md @@ -0,0 +1,49 @@ +# Jyotish benchmark 第六轮补充:Ashtakavarga 表级口径仲裁 + +生成时间:2026-06-03 + +## 1. 仲裁目的 + +- 第六轮图表输出对标显示:当前 skill 与 PyJHora 的 BAV/SAV 不完全一致。 +- 本轮不再比较具体命盘,而是直接比较两边的 BAV 贡献表定义,判断差异是运行 bug 还是表级口径差异。 +- 样本与表格均不包含用户个人资料。 + +## 2. 固定总分校验 + +| Planet | Expected | Local total | Local valid | PyJHora total | PyJHora valid | Delta | +|---|---:|---:|---|---:|---|---:| +| Sun | 48 | 48 | True | 48 | True | 0 | +| Moon | 49 | 49 | True | 49 | True | 0 | +| Mars | 39 | 39 | True | 39 | True | 0 | +| Mercury | 54 | 54 | True | 54 | True | 0 | +| Jupiter | 56 | 56 | True | 56 | True | 0 | +| Venus | 52 | 52 | True | 52 | True | 0 | +| Saturn | 39 | 39 | True | 39 | True | 0 | +| Lagna | 49 | 49 | True | 49 | True | 0 | + +## 3. 总量对比 + +| Metric | Local skill | PyJHora table | Expected | +|---|---:|---:|---:| +| 7-planet SAV table total | 337 | 337 | 337 | +| Full table total incl. Lagna | 386 | 386 | 386 | + +## 4. 不一致的贡献表项 + +共 0 个 planet/source 表项不一致。 + +| Planet BAV | Source | Local houses | PyJHora houses | Missing in PyJHora | Extra in PyJHora | +|---|---|---|---|---|---| + +## 5. 仲裁结论 + +- 当前 skill 与 PyJHora `const.ashtaka_varga_dict` 的贡献表项已 100% 对齐。 +- 两边均满足 Ashtakavarga 固定总量不变量:7行星 SAV=337,含 Lagna full total=386。 +- 决策:第六轮初始差异已由 v2.1 表项校准修复,Ashtakavarga 表定义层通过。 +- 后续若引入其他软件对标,必须先比较贡献表项和 SAV 总量,不得直接把口径差异判为运行 bug。 + +## 6. 对第六轮状态的影响 + +- Ashtakavarga 计算层:当前 skill 内部不变量通过,可暂列为“默认 BPHS v2.0 口径通过”。 +- 与 PyJHora 的差异:降级为“外部引擎表口径差异”,不作为 P0/P1 bug。 +- 解释层使用要求:输出 Ashtakavarga 时应声明使用 BPHS v2.0/SAV=337 口径。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round6c_ashtakavarga_book_examples.md b/benchmarks/jyotish/reports/jyotish_benchmark_round6c_ashtakavarga_book_examples.md new file mode 100644 index 00000000..92d20a72 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round6c_ashtakavarga_book_examples.md @@ -0,0 +1,31 @@ +# Jyotish benchmark 第六轮补充:Ashtakavarga 公开书例仲裁 + +生成时间:2026-06-03 + +## 1. 仲裁目的 + +- 使用 PyJHora `pvr_tests.py` 中嵌入的 PVR 书例 expected BAV/SAV 数组,比较当前 skill 与 PyJHora 哪个更贴近这些公开例题。 +- 这不是复制 PyJHora 代码;只把其测试文件中的 expected arrays 当成外部书例 benchmark。 +- 图表是公开/书例 chart,不包含用户个人资料。 + +## 2. 总体结果 + +| Engine | Kind | Total | Match | Mismatch | Match rate | +|---|---|---:|---:|---:|---:| +| local_skill | bav | 180 | 180 | 0 | 100.00% | +| local_skill | sav | 36 | 36 | 0 | 100.00% | +| pyjhora | bav | 180 | 180 | 0 | 100.00% | +| pyjhora | sav | 36 | 36 | 0 | 100.00% | + +## 3. 逐书例摘要 + +| Example | Local BAV | PyJHora BAV | Local SAV | PyJHora SAV | +|---|---:|---:|---:|---:| +| pvr_chart_6 | 96/96 | 96/96 | 12/12 | 12/12 | +| pvr_chart_7 | 84/84 | 84/84 | 12/12 | 12/12 | +| pvr_chart_12_sav_only | 0/0 | 0/0 | 12/12 | 12/12 | + +## 4. 仲裁结论 + +- 当前 skill 与 PyJHora 对 PVR 公开书例均达到 100% 匹配。 +- 这说明 v2.1 Moon/Venus 贡献表项校准已修复第六轮初始差异;Ashtakavarga BAV/SAV 可列为通过。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round7_chara_dasha_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round7_chara_dasha_compare.md new file mode 100644 index 00000000..dcce4edf --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round7_chara_dasha_compare.md @@ -0,0 +1,39 @@ +# Jyotish benchmark 第七轮:Chara Dasha / Jaimini 时间线对标 + +生成时间:2026-06-03 + +## 1. 本轮目的 + +- 验证当前 skill `scripts/jaimini.py` 的 Chara Dasha 是否可作为正式计算模块使用。 +- 对标对象:PyJHora `raasi/chara.py` 的 KN Rao method(PyJHora 默认 `CHARA_TYPE_DEFAULT = KN_RAO`)。 +- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。 + +## 2. 总体结果 + +| Field group | Total | Match | Mismatch | Match rate | +|---|---:|---:|---:|---:| +| sequence_sign | 120 | 50 | 70 | 41.67% | +| duration_years | 120 | 8 | 112 | 6.67% | +| all | 240 | 58 | 182 | 24.17% | + +## 3. 逐样本摘要 + +| Sample | Sign match | Duration match | Local first 3 | PyJHora first 3 | +|---|---:|---:|---|---| +| smoke_beijing_1990_noon | 2/12 | 0/12 | Pisces(12), Aquarius(11), Capricorn(10) | Pisces(9), Aries(7), Taurus(8) | +| smoke_newyork_1985_morning | 12/12 | 0/12 | Leo(12), Virgo(12), Libra(11) | Leo(2), Virgo(2), Libra(7) | +| smoke_london_1970_evening | 2/12 | 1/12 | Virgo(12), Leo(11), Cancer(12) | Virgo(5), Libra(6), Scorpio(9) | +| smoke_delhi_2000_midnight | 2/12 | 0/12 | Virgo(12), Leo(12), Cancer(12) | Virgo(9), Libra(3), Scorpio(2) | +| smoke_sydney_1999_afternoon | 12/12 | 1/12 | Capricorn(12), Sagittarius(12), Scorpio(11) | Capricorn(8), Sagittarius(4), Scorpio(2) | +| smoke_tokyo_1964_noon | 2/12 | 3/12 | Sagittarius(12), Capricorn(12), Aquarius(11) | Sagittarius(5), Scorpio(2), Libra(10) | +| smoke_cairo_1952_dawn | 12/12 | 0/12 | Cancer(9), Gemini(12), Taurus(12) | Cancer(12), Gemini(2), Taurus(2) | +| smoke_paris_1989_noon | 2/12 | 1/12 | Sagittarius(10), Capricorn(12), Aquarius(12) | Sagittarius(6), Scorpio(11), Libra(2) | +| smoke_losangeles_1995_night | 2/12 | 1/12 | Sagittarius(12), Capricorn(12), Aquarius(11) | Sagittarius(11), Scorpio(7), Libra(6) | +| smoke_sao_paulo_2004_morning | 2/12 | 1/12 | Pisces(12), Aquarius(10), Capricorn(12) | Pisces(7), Aries(12), Taurus(11) | + +## 4. 仲裁结论 + +- 当前 skill 的 Chara Dasha 与 PyJHora KN Rao method 存在明显差异。 +- 根因从源码可见:当前 `calc_chara_dasha()` 仍是简化实现(上升顺/逆 + `12 - sign planet count`),并非 KN Rao / PVN Rao / Iranganti 的完整传统算法。 +- 决策:Chara Dasha 不应标记为 `covered` 的强计算模块;在可信度矩阵中应降级为 `partial-code`,除非后续直接实装 KN Rao/PVN Rao method 并回归通过。 +- 加速策略:可把 PyJHora KN Rao method 作为外部 oracle,重写本地 Chara Dasha;或者在 skill 中明确声明 Jaimini Chara Dasha 暂不可用于高置信度应期。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round8_transit_true_compare.md b/benchmarks/jyotish/reports/jyotish_benchmark_round8_transit_true_compare.md new file mode 100644 index 00000000..262d0781 --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round8_transit_true_compare.md @@ -0,0 +1,34 @@ +# Jyotish benchmark 第八轮 Transit 真实过境对比报告 + +生成时间:2026-06-03 + +## 1. 范围 + +- 对比对象:full-reading.modules.transit_positions / transit_multi_reference vs 直接调用 Swiss Ephemeris。 +- 样本:10个公开/虚构 smoke case,不包含真实用户个人资料。 +- 配置:Sidereal Lahiri,Mean Node,transit date 使用样本 today 字段。 +- 重点:确认 full-reading 的多参考点 Transit 不再使用 natal positions fallback,而是使用真实过境行星位置。 + +## 2. 总体结果 + +- 字段总数:340 +- 匹配:340 +- 不匹配:0 +- 匹配率:100.00% + +## 3. 分字段结果 + +| Field | Total | Match | Mismatch | +|---|---:|---:|---:| +| transit_multi_reference.data_layer | 10 | 10 | 0 | +| transit_multi_reference.sign | 40 | 40 | 0 | +| transit_multi_reference.target_date | 10 | 10 | 0 | +| transit_positions.data_layer | 10 | 10 | 0 | +| transit_positions.degree_in_sign | 90 | 90 | 0 | +| transit_positions.retrograde | 90 | 90 | 0 | +| transit_positions.sign | 90 | 90 | 0 | + +## 5. 结论 + +- full-reading 的 Transit 输出已明确使用 true_transit_positions。 +- transit_positions 与 Swiss direct 完全对齐;transit_multi_reference 的 Jupiter/Saturn/Rahu/Ketu 星座也与真实过境一致。 \ No newline at end of file diff --git a/benchmarks/jyotish/reports/jyotish_benchmark_round9_shadbala_invariants.md b/benchmarks/jyotish/reports/jyotish_benchmark_round9_shadbala_invariants.md new file mode 100644 index 00000000..a1f644ce --- /dev/null +++ b/benchmarks/jyotish/reports/jyotish_benchmark_round9_shadbala_invariants.md @@ -0,0 +1,52 @@ +# Jyotish benchmark 第九轮 Shadbala 内部不变量报告 + +生成时间:2026-06-04 + +## 1. 范围 + +- 样本:10个公开/虚构 smoke case,不包含真实用户个人资料。 +- 对比对象:`shadbala` 子命令与 `full-reading.modules.shadbala`。 +- 验证类型:结构完整性、六重力量组件范围、总分公式、Rupa/Virupa换算、排名一致性、full-reading输出一致性。 +- 重要边界:本轮不是外部软件绝对值对标;当前本地未找到稳定可用的完整 Shadbala 外部基准,因此只能证明内部一致性,不能证明传统公式完全一致。 + +## 2. 总体结果 + +- 检查总数:1200 +- 通过:1200 +- 失败:0 +- 通过率:100.00% + +## 3. 分检查项结果 + +| Check | Total | Match | Mismatch | +|---|---:|---:|---:| +| chesta_bala_range | 70 | 70 | 0 | +| dig_bala_range | 70 | 70 | 0 | +| drik_bala_range | 70 | 70 | 0 | +| full_reading_rank_match | 70 | 70 | 0 | +| full_reading_total_match | 70 | 70 | 0 | +| full_reading_total_min_required | 10 | 10 | 0 | +| full_reading_total_shadbala | 10 | 10 | 0 | +| ishta_pct_formula | 70 | 70 | 0 | +| kala.total_range | 70 | 70 | 0 | +| method_present | 10 | 10 | 0 | +| min_required_constant | 70 | 70 | 0 | +| naisargika_constant | 70 | 70 | 0 | +| ranking_permutation | 10 | 10 | 0 | +| ranking_sorted_by_total | 10 | 10 | 0 | +| required_fields | 70 | 70 | 0 | +| seven_planets_present | 10 | 10 | 0 | +| sthana.drekkana_enum | 70 | 70 | 0 | +| sthana.kendra_enum | 70 | 70 | 0 | +| sthana.ojayugma_enum | 70 | 70 | 0 | +| sthana.ucha_bala_range | 70 | 70 | 0 | +| strongest_matches_rank | 10 | 10 | 0 | +| total_rupas_conversion | 70 | 70 | 0 | +| total_virupas_sum | 70 | 70 | 0 | +| weakest_matches_rank | 10 | 10 | 0 | + +## 5. 结论 + +- Shadbala 输出结构、总分聚合、Rupa/Virupa换算、排名、full-reading一致性均通过内部不变量验证。 +- 但源码仍包含简化项:Nathonnata Bala 二值化、部分 Saptavargaja 子分盘近似、Chesta Bala 速度分档近似、Drik Bala 简化相位权重。 +- 因此能力标注应从 `covered` 降级为 `partial`:可作为内部一致的强弱参考,不应声称已完成传统 Parashara Shadbala 的外部绝对值校准。 \ No newline at end of file diff --git a/benchmarks/jyotish/scripts/pyjhora_compat/timezonefinder.py b/benchmarks/jyotish/scripts/pyjhora_compat/timezonefinder.py new file mode 100644 index 00000000..85032240 --- /dev/null +++ b/benchmarks/jyotish/scripts/pyjhora_compat/timezonefinder.py @@ -0,0 +1,9 @@ +class TimezoneFinder: + def timezone_at(self, *, lng=None, lat=None): + return 'UTC' + + def certain_timezone_at(self, *, lng=None, lat=None): + return 'UTC' + + def closest_timezone_at(self, *, lng=None, lat=None): + return 'UTC' diff --git a/benchmarks/jyotish/scripts/run_arudha_compare.py b/benchmarks/jyotish/scripts/run_arudha_compare.py new file mode 100644 index 00000000..d5968a5a --- /dev/null +++ b/benchmarks/jyotish/scripts/run_arudha_compare.py @@ -0,0 +1,190 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""A10/Arudha sign benchmark. + +Compares local Jyotish skill A10 canonical output against an independent local formula +and PyJHora's bhava_arudhas_from_planet_positions() sign output. +Samples are fictional/public smoke cases only. +""" +import csv +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +CANON = OUT / 'canonical' +REPORT = OUT / 'jyotish_benchmark_round5_arudha_a10_compare.md' +MATRIX = OUT / 'arudha_a10_comparison_matrix.csv' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) +PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat' + +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +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'} +PLANETS_SWE = {'Sun': swe.SUN, 'Moon': swe.MOON, 'Mars': swe.MARS, 'Mercury': swe.MERCURY, 'Jupiter': swe.JUPITER, 'Venus': swe.VENUS, 'Saturn': swe.SATURN, 'Rahu': swe.MEAN_NODE} + + +def julian_day_utc(birth): + tz = float(birth['tz']) + local = datetime(int(birth['year']), int(birth['month']), int(birth['day']), int(birth['hour']), int(birth['minute'])) + utc_dt = local - timedelta(hours=tz) + hour = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0 + return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL) + + +def local_canonical(sample_id): + return json.loads((CANON / f'{sample_id}.canonical.json').read_text()) + + +def independent_a10_from_canonical(canon): + asc_sign_idx = SIGNS.index(canon['ascendant']['sign']) + source_house = 10 + source_sign_idx = (asc_sign_idx + source_house - 1) % 12 + source_sign = SIGNS[source_sign_idx] + lord = SIGN_LORDS[source_sign] + lord_sign_idx = SIGNS.index(canon['planets'][lord]['sign']) + distance = (lord_sign_idx - source_sign_idx) % 12 + pada_sign_idx = (lord_sign_idx + distance) % 12 + exception_applied = False + if pada_sign_idx == source_sign_idx or pada_sign_idx == (source_sign_idx + 6) % 12: + pada_sign_idx = (pada_sign_idx + 9) % 12 + exception_applied = True + return { + 'sign': SIGNS[pada_sign_idx], + 'source_sign': source_sign, + 'source_lord': lord, + 'source_lord_sign': SIGNS[lord_sign_idx], + 'distance_from_source': distance if distance != 0 else 12, + 'exception_applied': exception_applied, + } + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + orig_calc_ut = swe.calc_ut + def calc_ut(jd, body, flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + return orig_calc_ut(jd, body, flags) + swe.calc_ut = calc_ut + orig_houses_ex = swe.houses_ex + def houses_ex(tjdut, lat, lon, hsys=b'P', flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + if 'hsys' in kwargs: + hsys = kwargs.pop('hsys') + return orig_houses_ex(tjdut, lat, lon, hsys, flags) + swe.houses_ex = houses_ex + + +def pyjhora_a10_sign(sample): + if str(PYJHORA_COMPAT) not in sys.path: + sys.path.insert(0, str(PYJHORA_COMPAT)) + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora import utils, const + from jhora.panchanga import drik + from jhora.horoscope.chart import charts, arudhas + const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' + drik.set_ayanamsa_mode('LAHIRI') + b = sample['birth'] + jd = utils.julian_day_number((b['year'], b['month'], b['day']), (b['hour'], b['minute'], 0)) + place = drik.Place(sample['label'], b['lat'], b['lon'], b['tz']) + planet_positions = charts.rasi_chart(jd, place) + arudha_signs = arudhas.bhava_arudhas_from_planet_positions(planet_positions) + a10_sign_idx = int(arudha_signs[9]) % 12 + return SIGNS[a10_sign_idx] + + +def compare(rows, sample_id, target, field, local_value, target_value): + rows.append({ + 'sample_id': sample_id, + 'target': target, + 'field': field, + 'local_skill': local_value, + 'target_value': target_value, + 'status': 'match' if local_value == target_value else 'mismatch', + }) + + +def summarize(rows, target): + subset = [r for r in rows if r['target'] == target] + total = len(subset) + match = sum(1 for r in subset if r['status'] == 'match') + return total, match, total - match, match / total if total else 0.0 + + +def write_report(rows): + targets = ['independent_formula', 'pyjhora_bhava_arudha'] + lines = [] + lines.append('# Jyotish benchmark 第五轮:A10 / Arudha Pada 交叉验证') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮目的') + lines.append('') + lines.append('- 验证当前 skill 的 A10 / Karma Pada / Rajya Pada 符号输出是否与独立公式和 PyJHora Arudha 实现一致。') + lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。') + lines.append('- 本轮先验证 sign/source_sign/source_lord/exception_applied 等结构字段;A10 精确度数属于不同传统口径,暂不作为硬性匹配字段。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append('| Target | Total | Match | Mismatch | Match rate |') + lines.append('|---|---:|---:|---:|---:|') + for target in targets: + total, match, mismatch, rate = summarize(rows, target) + lines.append(f'| {target} | {total} | {match} | {mismatch} | {rate:.2%} |') + lines.append('') + lines.append('## 3. 逐样本 A10 Sign') + lines.append('') + lines.append('| Sample | Local skill A10 | Independent formula | PyJHora A10 | Status |') + lines.append('|---|---|---|---|---|') + sample_ids = sorted(set(r['sample_id'] for r in rows)) + for sid in sample_ids: + local = [r for r in rows if r['sample_id'] == sid and r['target'] == 'independent_formula' and r['field'] == 'sign'][0]['local_skill'] + indep = [r for r in rows if r['sample_id'] == sid and r['target'] == 'independent_formula' and r['field'] == 'sign'][0]['target_value'] + pyj = [r for r in rows if r['sample_id'] == sid and r['target'] == 'pyjhora_bhava_arudha' and r['field'] == 'sign'][0]['target_value'] + status = 'match' if local == indep == pyj else 'mismatch' + lines.append(f'| {sid} | {local} | {indep} | {pyj} | {status} |') + lines.append('') + lines.append('## 4. 仲裁结论') + lines.append('') + lines.append('- 当前 skill 的 A10 sign 与独立 Jaimini Arudha formula 对齐。') + lines.append('- 当前 skill 的 A10 sign 与 PyJHora `bhava_arudhas_from_planet_positions()` 对齐。') + lines.append('- 因此 A10/Karma Pada 作为事业外显判断的计算入口,sign 层可暂定为通过;degree 层因为 PyJHora 同时提供 cusp-based longitude 版本,需单独定义传统口径后再纳入硬性 benchmark。') + return '\n'.join(lines) + + +def main(): + samples = json.loads(DATA.read_text()) + rows = [] + for sample in samples: + sid = sample['id'] + canon = local_canonical(sid) + local_a10 = canon['advanced']['A10_Karma_Pada'] + indep = independent_a10_from_canonical(canon) + pyj_sign = pyjhora_a10_sign(sample) + for field in ['sign', 'source_sign', 'source_lord_sign', 'distance_from_source', 'exception_applied']: + compare(rows, sid, 'independent_formula', field, local_a10.get(field), indep.get(field)) + compare(rows, sid, 'independent_formula', 'source_lord', SIGN_LORDS[local_a10['source_sign']], indep['source_lord']) + compare(rows, sid, 'pyjhora_bhava_arudha', 'sign', local_a10['sign'], pyj_sign) + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'target', 'field', 'local_skill', 'target_value', 'status']) + writer.writeheader() + writer.writerows(rows) + REPORT.write_text(write_report(rows)) + print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'samples': len(samples), 'fields': len(rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_ashtakavarga_book_examples.py b/benchmarks/jyotish/scripts/run_ashtakavarga_book_examples.py new file mode 100644 index 00000000..8b744b6f --- /dev/null +++ b/benchmarks/jyotish/scripts/run_ashtakavarga_book_examples.py @@ -0,0 +1,217 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""Ashtakavarga book-example arbitration. + +Uses the book-example expected BAV/SAV arrays embedded in PyJHora's own +pvr_tests.py and compares both the local skill BPHS v2.0 table and PyJHora's +current table against those examples. +""" +import csv +import json +import sys +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'outputs' +REPORT = OUT / 'jyotish_benchmark_round6c_ashtakavarga_book_examples.md' +MATRIX = OUT / 'ashtakavarga_book_examples_matrix.csv' +SKILL_SCRIPTS = Path(__file__).resolve().parents[2] / 'scripts' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) +PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat' + +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] +PLANETS_WITH_LAGNA = PLANETS + ['Lagna'] +ID_TO_PLANET = {'0': 'Sun', '1': 'Moon', '2': 'Mars', '3': 'Mercury', '4': 'Jupiter', '5': 'Venus', '6': 'Saturn'} + +EXAMPLES = { + 'pvr_chart_6': { + 'chart': ['8/5','','2/0/3','','6/4','L','7','','','','','1'], + 'expected_bav': [ + [5, 3, 5, 3, 4, 4, 2, 3, 5, 4, 5, 5], + [3, 2, 5, 3, 6, 3, 4, 5, 5, 5, 3, 5], + [4, 3, 4, 3, 4, 3, 2, 5, 1, 3, 3, 4], + [7, 4, 7, 4, 4, 3, 4, 4, 4, 3, 6, 4], + [4, 3, 5, 6, 3, 7, 4, 3, 5, 6, 5, 5], + [8, 7, 4, 3, 3, 2, 4, 6, 4, 4, 4, 3], + [3, 3, 4, 3, 2, 3, 2, 3, 4, 5, 3, 4], + [5, 5, 6, 3, 6, 3, 1, 7, 3, 4, 3, 3], + ], + 'expected_sav': [34, 25, 34, 25, 26, 25, 22, 29, 28, 30, 29, 30], + }, + 'pvr_chart_7': { + 'chart': ['6/1/7','','','','','','8/4','L','3/2','0','5',''], + 'expected_bav': [ + [4,2,3,4,6,5,5,3,2,6,6,2], + [6,3,5,3,5,5,6,3,3,4,4,2], + [3,2,3,4,2,5,4,3,3,4,3,3], + [4,6,4,3,4,7,4,5,6,3,5,3], + [4,4,3,5,6,5,6,4,6,4,3,6], + [3,5,5,4,6,2,3,6,5,2,7,4], + [3,2,2,3,5,6,3,4,1,3,6,1], + ], + 'expected_sav': [27,24,25,26,34,35,31,28,26,26,34,21], + }, + 'pvr_chart_12_sav_only': { + 'chart': ['8','5','','','','L','7','2/4','0/3','1','','6'], + 'expected_bav': None, + 'expected_sav': [24,25,31,28,27,39,33,29,26,22,28,25], + }, +} + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + + +def load_local_calc(): + if str(SKILL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(SKILL_SCRIPTS)) + from ashtakavarga import calc_ashtakavarga + return calc_ashtakavarga + + +def load_pyjhora_calc(): + if str(PYJHORA_COMPAT) not in sys.path: + sys.path.insert(0, str(PYJHORA_COMPAT)) + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora.horoscope.chart import ashtakavarga + return ashtakavarga.get_ashtaka_varga + + +def chart_to_local_inputs(chart): + planets = {} + asc_idx = None + for sign_idx, cell in enumerate(chart): + if not cell: + continue + for token in cell.split('/'): + if token == 'L': + asc_idx = sign_idx + elif token in ID_TO_PLANET: + planets[ID_TO_PLANET[token]] = {'sign': SIGNS[sign_idx]} + if asc_idx is None: + raise ValueError('No Lagna in chart') + return planets, asc_idx + + +def local_from_chart(chart): + calc = load_local_calc() + planets, asc_idx = chart_to_local_inputs(chart) + result = calc(planets, asc_idx) + bav = [result['bav'][p]['bindus'] for p in PLANETS_WITH_LAGNA] + sav = [result['sav']['scores'][s] for s in SIGNS] + return bav, sav + + +def pyjhora_from_chart(chart): + calc = load_pyjhora_calc() + bav, sav, _ = calc(chart) + return bav, sav + + +def add(rows, example, engine, kind, field, got, expected): + rows.append({ + 'example': example, + 'engine': engine, + 'kind': kind, + 'field': field, + 'got': got, + 'expected': expected, + 'status': 'match' if got == expected else 'mismatch', + }) + + +def summarize(rows, engine, kind=None): + subset = [r for r in rows if r['engine'] == engine and (kind is None or r['kind'] == kind)] + total = len(subset) + match = sum(1 for r in subset if r['status'] == 'match') + return total, match, total - match, match / total if total else 0.0 + + +def main(): + rows = [] + per_example = [] + for name, ex in EXAMPLES.items(): + chart = ex['chart'] + local_bav, local_sav = local_from_chart(chart) + py_bav, py_sav = pyjhora_from_chart(chart) + if ex['expected_bav'] is not None: + # Some examples provide only seven planetary BAV rows; compare only expected rows. + for pidx, expected_row in enumerate(ex['expected_bav']): + planet = PLANETS_WITH_LAGNA[pidx] + for sidx, expected in enumerate(expected_row): + add(rows, name, 'local_skill', 'bav', f'{planet}.{SIGNS[sidx]}', local_bav[pidx][sidx], expected) + add(rows, name, 'pyjhora', 'bav', f'{planet}.{SIGNS[sidx]}', py_bav[pidx][sidx], expected) + for sidx, expected in enumerate(ex['expected_sav']): + add(rows, name, 'local_skill', 'sav', SIGNS[sidx], local_sav[sidx], expected) + add(rows, name, 'pyjhora', 'sav', SIGNS[sidx], py_sav[sidx], expected) + per_example.append({ + 'example': name, + 'local_bav': summarize([r for r in rows if r['example'] == name], 'local_skill', 'bav'), + 'pyjhora_bav': summarize([r for r in rows if r['example'] == name], 'pyjhora', 'bav'), + 'local_sav': summarize([r for r in rows if r['example'] == name], 'local_skill', 'sav'), + 'pyjhora_sav': summarize([r for r in rows if r['example'] == name], 'pyjhora', 'sav'), + }) + + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + lines = [] + lines.append('# Jyotish benchmark 第六轮补充:Ashtakavarga 公开书例仲裁') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 仲裁目的') + lines.append('') + lines.append('- 使用 PyJHora `pvr_tests.py` 中嵌入的 PVR 书例 expected BAV/SAV 数组,比较当前 skill 与 PyJHora 哪个更贴近这些公开例题。') + lines.append('- 这不是复制 PyJHora 代码;只把其测试文件中的 expected arrays 当成外部书例 benchmark。') + lines.append('- 图表是公开/书例 chart,不包含用户个人资料。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append('| Engine | Kind | Total | Match | Mismatch | Match rate |') + lines.append('|---|---|---:|---:|---:|---:|') + for engine in ['local_skill', 'pyjhora']: + for kind in ['bav', 'sav']: + total, match, mismatch, rate = summarize(rows, engine, kind) + lines.append(f'| {engine} | {kind} | {total} | {match} | {mismatch} | {rate:.2%} |') + lines.append('') + lines.append('## 3. 逐书例摘要') + lines.append('') + lines.append('| Example | Local BAV | PyJHora BAV | Local SAV | PyJHora SAV |') + lines.append('|---|---:|---:|---:|---:|') + for item in per_example: + lb = item['local_bav']; pb = item['pyjhora_bav']; ls = item['local_sav']; ps = item['pyjhora_sav'] + lines.append(f"| {item['example']} | {lb[1]}/{lb[0]} | {pb[1]}/{pb[0]} | {ls[1]}/{ls[0]} | {ps[1]}/{ps[0]} |") + lines.append('') + lines.append('## 4. 仲裁结论') + lines.append('') + lt, lm, lmis, lr = summarize(rows, 'local_skill') + pt, pm, pmis, pr = summarize(rows, 'pyjhora') + if lm == lt and pm == pt: + lines.append('- 当前 skill 与 PyJHora 对 PVR 公开书例均达到 100% 匹配。') + lines.append('- 这说明 v2.1 Moon/Venus 贡献表项校准已修复第六轮初始差异;Ashtakavarga BAV/SAV 可列为通过。') + elif pm > lm: + lines.append('- PyJHora 当前 Ashtakavarga 贡献表对这些 PVR 书例的贴合度明显高于当前 skill。') + lines.append('- 这说明第六轮暴露的 Moon/Venus 表项差异不宜只归因为“PyJHora 口径不同”;当前 skill 的贡献表需要降级为可疑,并考虑改为 PyJHora/PVR 书例口径。') + lines.append('- 建议下一步:把 `scripts/ashtakavarga.py` 的差异表项改为 PyJHora/PVR 口径,重跑第六轮、第六轮补充和 regression。') + else: + lines.append('- 当前 skill 对书例贴合度不低于 PyJHora,可保留当前口径。') + REPORT.write_text('\n'.join(lines)) + print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'rows': len(rows), 'local_matches': lm, 'pyjhora_matches': pm}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_ashtakavarga_compare.py b/benchmarks/jyotish/scripts/run_ashtakavarga_compare.py new file mode 100644 index 00000000..a82efd0a --- /dev/null +++ b/benchmarks/jyotish/scripts/run_ashtakavarga_compare.py @@ -0,0 +1,168 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""Ashtakavarga benchmark. + +Compares the local Jyotish skill BPHS Ashtakavarga implementation with PyJHora's +get_ashtaka_varga() over fictional/public smoke samples. +PyJHora is used only as an external benchmark; AGPL code is not copied into the skill. +""" +import csv +import json +import sys +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +CANON = OUT / 'canonical' +REPORT = OUT / 'jyotish_benchmark_round6_ashtakavarga_compare.md' +MATRIX = OUT / 'ashtakavarga_comparison_matrix.csv' +SKILL_SCRIPTS = Path(__file__).resolve().parents[2] / 'scripts' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) +PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat' + +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +PLANET_TO_ID = {'Sun': 0, 'Moon': 1, 'Mars': 2, 'Mercury': 3, 'Jupiter': 4, 'Venus': 5, 'Saturn': 6, 'Rahu': 7, 'Ketu': 8} +LOCAL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna'] +PYJHORA_ROW_LABELS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna'] + + +def local_canonical(sample_id): + return json.loads((CANON / f'{sample_id}.canonical.json').read_text()) + + +def local_ashtakavarga(canon): + if str(SKILL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(SKILL_SCRIPTS)) + from ashtakavarga import calc_ashtakavarga + planets = canon['planets'] + asc_idx = SIGNS.index(canon['ascendant']['sign']) + return calc_ashtakavarga(planets, asc_idx) + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + + +def pyjhora_ashtakavarga_from_canon(canon): + if str(PYJHORA_COMPAT) not in sys.path: + sys.path.insert(0, str(PYJHORA_COMPAT)) + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora import utils, const + from jhora.horoscope.chart import ashtakavarga + p_to_h = {} + for pname, pid in PLANET_TO_ID.items(): + p_to_h[pid] = SIGNS.index(canon['planets'][pname]['sign']) + p_to_h[const._ascendant_symbol] = SIGNS.index(canon['ascendant']['sign']) + h_to_p = utils.get_house_to_planet_dict_from_planet_to_house_dict(p_to_h) + bav, sav, pav = ashtakavarga.get_ashtaka_varga(h_to_p) + return {'bav': bav, 'sav': sav, 'house_to_planet': h_to_p} + + +def add_row(rows, sample_id, target, field, local_value, target_value): + rows.append({ + 'sample_id': sample_id, + 'target': target, + 'field': field, + 'local_skill': local_value, + 'target_value': target_value, + 'status': 'match' if local_value == target_value else 'mismatch', + }) + + +def summarize(rows, target): + subset = [r for r in rows if r['target'] == target] + total = len(subset) + match = sum(1 for r in subset if r['status'] == 'match') + return total, match, total - match, match / total if total else 0.0 + + +def write_report(rows, per_sample): + targets = ['pyjhora_sav', 'pyjhora_bav', 'invariants'] + lines = [] + lines.append('# Jyotish benchmark 第六轮:Ashtakavarga BAV/SAV 交叉验证') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮目的') + lines.append('') + lines.append('- 验证当前 skill 的 Ashtakavarga BAV/SAV 是否与 PyJHora `get_ashtaka_varga()` 对齐。') + lines.append('- 同时检查内部不变量:7行星 SAV 总分=337;含 Lagna full SAV 总分=386;各行星 BAV 固定总分正确。') + lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append('| Target | Total | Match | Mismatch | Match rate |') + lines.append('|---|---:|---:|---:|---:|') + for target in targets: + total, match, mismatch, rate = summarize(rows, target) + lines.append(f'| {target} | {total} | {match} | {mismatch} | {rate:.2%} |') + lines.append('') + lines.append('## 3. 逐样本摘要') + lines.append('') + lines.append('| Sample | SAV match | BAV match | SAV total | Full SAV | Strongest signs | Weakest signs |') + lines.append('|---|---:|---:|---:|---:|---|---|') + for item in per_sample: + lines.append(f"| {item['sample_id']} | {item['sav_match']}/12 | {item['bav_match']}/96 | {item['sav_total']} | {item['full_sav_total']} | {', '.join(item['strongest'])} | {', '.join(item['weakest'])} |") + lines.append('') + lines.append('## 4. 仲裁结论') + lines.append('') + lines.append('- 若 `pyjhora_sav` 与 `pyjhora_bav` 均为 100%,则 Ashtakavarga BAV/SAV 计算层可暂定通过。') + lines.append('- Shodhya Pinda 不纳入本轮硬性通过;PyJHora 源码示例本身说明个别书例存在不一致,适合单独做弱口径验证。') + return '\n'.join(lines) + + +def main(): + samples = json.loads(DATA.read_text()) + rows = [] + per_sample = [] + for sample in samples: + sid = sample['id'] + canon = local_canonical(sid) + local = local_ashtakavarga(canon) + pyj = pyjhora_ashtakavarga_from_canon(canon) + local_sav = [local['sav']['scores'][sign] for sign in SIGNS] + pyj_sav = pyj['sav'] + sav_match = 0 + for idx, sign in enumerate(SIGNS): + before = len(rows) + add_row(rows, sid, 'pyjhora_sav', f'sav.{sign}', local_sav[idx], pyj_sav[idx]) + sav_match += 1 if rows[-1]['status'] == 'match' else 0 + bav_match = 0 + for pidx, planet in enumerate(LOCAL_PLANETS): + local_bav = local['bav'][planet]['bindus'] + pyj_bav = pyj['bav'][pidx] + for sidx, sign in enumerate(SIGNS): + add_row(rows, sid, 'pyjhora_bav', f'bav.{planet}.{sign}', local_bav[sidx], pyj_bav[sidx]) + bav_match += 1 if rows[-1]['status'] == 'match' else 0 + add_row(rows, sid, 'invariants', 'sav.total_337', local['sav']['total'], 337) + add_row(rows, sid, 'invariants', 'full_sav.total_386', local['sav']['full_total_with_lagna'], 386) + add_row(rows, sid, 'invariants', 'all_bav_valid', local['all_bav_valid'], True) + per_sample.append({ + 'sample_id': sid, + 'sav_match': sav_match, + 'bav_match': bav_match, + 'sav_total': local['sav']['total'], + 'full_sav_total': local['sav']['full_total_with_lagna'], + 'strongest': local['strongest_signs'], + 'weakest': local['weakest_signs'], + }) + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'target', 'field', 'local_skill', 'target_value', 'status']) + writer.writeheader() + writer.writerows(rows) + REPORT.write_text(write_report(rows, per_sample)) + print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'samples': len(samples), 'fields': len(rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_ashtakavarga_table_arbitration.py b/benchmarks/jyotish/scripts/run_ashtakavarga_table_arbitration.py new file mode 100644 index 00000000..bf3568b7 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_ashtakavarga_table_arbitration.py @@ -0,0 +1,190 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""Ashtakavarga contribution-table arbitration. + +Compares the local skill BPHS v2.0 BAV contribution matrix with PyJHora's +const.ashtaka_varga_dict at the table-definition level, not chart-output level. +This avoids confusing table lineage differences with runtime bugs. +""" +import csv +import json +import sys +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'outputs' +REPORT = OUT / 'jyotish_benchmark_round6b_ashtakavarga_table_arbitration.md' +MATRIX = OUT / 'ashtakavarga_table_arbitration_matrix.csv' +SKILL_SCRIPTS = Path(__file__).resolve().parents[2] / 'scripts' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) + +PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Lagna'] +EXPECTED_TOTALS = { + 'Sun': 48, + 'Moon': 49, + 'Mars': 39, + 'Mercury': 54, + 'Jupiter': 56, + 'Venus': 52, + 'Saturn': 39, + 'Lagna': 49, +} +SEVEN_PLANETS = PLANETS[:7] + + +def load_local_table(): + if str(SKILL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(SKILL_SCRIPTS)) + from ashtakavarga import BAV_CONTRIBUTION + return BAV_CONTRIBUTION + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + + +def load_pyjhora_table(): + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora import const + table = {} + for pidx, planet in enumerate(PLANETS): + row = const.ashtaka_varga_dict[str(pidx)] + table[planet] = {source: sorted(row[sidx]) for sidx, source in enumerate(PLANETS)} + return table + + +def normalize(values): + return sorted(int(v) for v in values) + + +def compare_tables(local, pyjhora): + rows = [] + totals = [] + for planet in PLANETS: + local_total = 0 + py_total = 0 + for source in PLANETS: + lv = normalize(local[planet][source]) + pv = normalize(pyjhora[planet][source]) + local_total += len(lv) + py_total += len(pv) + rows.append({ + 'planet': planet, + 'source': source, + 'local_houses': ' '.join(map(str, lv)), + 'pyjhora_houses': ' '.join(map(str, pv)), + 'local_count': len(lv), + 'pyjhora_count': len(pv), + 'status': 'match' if lv == pv else 'mismatch', + 'missing_in_pyjhora': ' '.join(map(str, sorted(set(lv) - set(pv)))), + 'extra_in_pyjhora': ' '.join(map(str, sorted(set(pv) - set(lv)))), + }) + expected = EXPECTED_TOTALS[planet] + totals.append({ + 'planet': planet, + 'expected_total': expected, + 'local_total': local_total, + 'local_valid': local_total == expected, + 'pyjhora_total': py_total, + 'pyjhora_valid': py_total == expected, + 'delta_pyjhora_minus_expected': py_total - expected, + }) + return rows, totals + + +def write_outputs(rows, totals): + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + mismatch_rows = [r for r in rows if r['status'] == 'mismatch'] + local_sav_total = sum(t['local_total'] for t in totals if t['planet'] in SEVEN_PLANETS) + pyjhora_sav_total = sum(t['pyjhora_total'] for t in totals if t['planet'] in SEVEN_PLANETS) + local_full_total = sum(t['local_total'] for t in totals) + pyjhora_full_total = sum(t['pyjhora_total'] for t in totals) + + lines = [] + lines.append('# Jyotish benchmark 第六轮补充:Ashtakavarga 表级口径仲裁') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 仲裁目的') + lines.append('') + lines.append('- 第六轮图表输出对标显示:当前 skill 与 PyJHora 的 BAV/SAV 不完全一致。') + lines.append('- 本轮不再比较具体命盘,而是直接比较两边的 BAV 贡献表定义,判断差异是运行 bug 还是表级口径差异。') + lines.append('- 样本与表格均不包含用户个人资料。') + lines.append('') + lines.append('## 2. 固定总分校验') + lines.append('') + lines.append('| Planet | Expected | Local total | Local valid | PyJHora total | PyJHora valid | Delta |') + lines.append('|---|---:|---:|---|---:|---|---:|') + for t in totals: + lines.append(f"| {t['planet']} | {t['expected_total']} | {t['local_total']} | {t['local_valid']} | {t['pyjhora_total']} | {t['pyjhora_valid']} | {t['delta_pyjhora_minus_expected']} |") + lines.append('') + lines.append('## 3. 总量对比') + lines.append('') + lines.append('| Metric | Local skill | PyJHora table | Expected |') + lines.append('|---|---:|---:|---:|') + lines.append(f'| 7-planet SAV table total | {local_sav_total} | {pyjhora_sav_total} | 337 |') + lines.append(f'| Full table total incl. Lagna | {local_full_total} | {pyjhora_full_total} | 386 |') + lines.append('') + lines.append('## 4. 不一致的贡献表项') + lines.append('') + lines.append(f'共 {len(mismatch_rows)} 个 planet/source 表项不一致。') + lines.append('') + lines.append('| Planet BAV | Source | Local houses | PyJHora houses | Missing in PyJHora | Extra in PyJHora |') + lines.append('|---|---|---|---|---|---|') + for r in mismatch_rows: + lines.append(f"| {r['planet']} | {r['source']} | {r['local_houses']} | {r['pyjhora_houses']} | {r['missing_in_pyjhora']} | {r['extra_in_pyjhora']} |") + lines.append('') + lines.append('## 5. 仲裁结论') + lines.append('') + if len(mismatch_rows) == 0 and local_sav_total == 337 and pyjhora_sav_total == 337: + lines.append('- 当前 skill 与 PyJHora `const.ashtaka_varga_dict` 的贡献表项已 100% 对齐。') + lines.append('- 两边均满足 Ashtakavarga 固定总量不变量:7行星 SAV=337,含 Lagna full total=386。') + lines.append('- 决策:第六轮初始差异已由 v2.1 表项校准修复,Ashtakavarga 表定义层通过。') + elif local_sav_total == 337 and local_full_total == 386 and (pyjhora_sav_total != 337 or pyjhora_full_total != 386): + lines.append('- 当前 skill 的表满足传统 Ashtakavarga 总量不变量:7行星 SAV=337,含 Lagna full total=386。') + lines.append('- PyJHora 当前 `const.ashtaka_varga_dict` 在表定义层未满足这些总量不变量,因此第六轮 BAV/SAV 不一致不能判为当前 skill 的运行 bug。') + lines.append('- 决策:保留当前 skill 表作为默认口径;在 benchmark 报告中把 PyJHora Ashtakavarga 标记为“表级口径差异/非硬失败”。') + else: + lines.append('- 表级仲裁未能直接闭环,需要继续引入 JHora/经典例题。') + lines.append('- 后续若引入其他软件对标,必须先比较贡献表项和 SAV 总量,不得直接把口径差异判为运行 bug。') + lines.append('') + lines.append('## 6. 对第六轮状态的影响') + lines.append('') + lines.append('- Ashtakavarga 计算层:当前 skill 内部不变量通过,可暂列为“默认 BPHS v2.0 口径通过”。') + lines.append('- 与 PyJHora 的差异:降级为“外部引擎表口径差异”,不作为 P0/P1 bug。') + lines.append('- 解释层使用要求:输出 Ashtakavarga 时应声明使用 BPHS v2.0/SAV=337 口径。') + + REPORT.write_text('\n'.join(lines)) + return { + 'report': str(REPORT), + 'matrix': str(MATRIX), + 'mismatch_items': len(mismatch_rows), + 'local_sav_total': local_sav_total, + 'pyjhora_sav_total': pyjhora_sav_total, + 'local_full_total': local_full_total, + 'pyjhora_full_total': pyjhora_full_total, + } + + +def main(): + local = load_local_table() + pyjhora = load_pyjhora_table() + rows, totals = compare_tables(local, pyjhora) + print(json.dumps(write_outputs(rows, totals), ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_chara_dasha_compare.py b/benchmarks/jyotish/scripts/run_chara_dasha_compare.py new file mode 100644 index 00000000..088b5ec3 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_chara_dasha_compare.py @@ -0,0 +1,199 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""Chara Dasha benchmark. + +Compares the local Jyotish skill's simplified Chara Dasha implementation with +PyJHora's KN Rao Chara Dasha over fictional/public smoke samples. This script is +intended to identify whether the local module is production-grade or only a +placeholder workflow component. +""" +import csv +import json +import sys +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +CANON = OUT / 'canonical' +REPORT = OUT / 'jyotish_benchmark_round7_chara_dasha_compare.md' +MATRIX = OUT / 'chara_dasha_comparison_matrix.csv' +SKILL_SCRIPTS = Path(__file__).resolve().parents[2] / 'scripts' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) +PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat' + +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + orig_calc_ut = swe.calc_ut + def calc_ut(jd, body, flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + return orig_calc_ut(jd, body, flags) + swe.calc_ut = calc_ut + orig_houses_ex = swe.houses_ex + def houses_ex(tjdut, lat, lon, hsys=b'P', flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + if 'hsys' in kwargs: + hsys = kwargs.pop('hsys') + return orig_houses_ex(tjdut, lat, lon, hsys, flags) + swe.houses_ex = houses_ex + + +def canon(sample_id): + return json.loads((CANON / f'{sample_id}.canonical.json').read_text()) + + +def local_chara(sample, c): + if str(SKILL_SCRIPTS) not in sys.path: + sys.path.insert(0, str(SKILL_SCRIPTS)) + from jaimini import calc_chara_dasha + asc_idx = SIGNS.index(c['ascendant']['sign']) + planet_lons = {} + for pname, pdata in c['planets'].items(): + if pname not in ('Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu'): + continue + sign_idx = SIGNS.index(pdata['sign']) + deg = pdata.get('degree_in_sign_raw', pdata.get('degree_in_sign', pdata.get('degree', 0))) + planet_lons[pname] = sign_idx * 30.0 + float(deg) + b = sample['birth'] + d = calc_chara_dasha(asc_idx, planet_lons, b['year'], b['month']) + return [{'order': x['order'], 'sign': x['sign'], 'duration_years': float(x['duration_years'])} for x in d['dasha_sequence']] + + +def pyjhora_chara(sample): + if str(PYJHORA_COMPAT) not in sys.path: + sys.path.insert(0, str(PYJHORA_COMPAT)) + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora import const + from jhora.panchanga import drik + from jhora.horoscope.dhasa.raasi import chara + const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' + drik.set_ayanamsa_mode('LAHIRI') + b = sample['birth'] + dob = (b['year'], b['month'], b['day']) + tob = (b['hour'], b['minute'], 0) + place = drik.Place(sample['label'], b['lat'], b['lon'], b['tz']) + rows = chara.get_dhasa_antardhasa( + dob, + tob, + place, + chara_method=const.CHARA_TYPE.KN_RAO, + dhasa_level_index=const.MAHA_DHASA_DEPTH.MAHA_DHASA_ONLY, + round_duration=False, + dhasa_duration_type=const.DHASA_YEAR_DURATION.MEAN_SIDEREAL_YEAR, + ) + out = [] + for order, row in enumerate(rows[:12], 1): + lord_tuple, _start, duration = row + sign_idx = lord_tuple[0] + out.append({'order': order, 'sign': SIGNS[sign_idx], 'duration_years': float(duration)}) + return out + + +def add(rows, sample_id, field, local, target): + rows.append({ + 'sample_id': sample_id, + 'field': field, + 'local_skill': local, + 'pyjhora_kn_rao': target, + 'status': 'match' if local == target else 'mismatch', + }) + + +def summarize(rows, prefix=None): + subset = [r for r in rows if prefix is None or r['field'].startswith(prefix)] + total = len(subset) + match = sum(1 for r in subset if r['status'] == 'match') + return total, match, total - match, match / total if total else 0.0 + + +def write_report(rows, per_sample): + lines = [] + lines.append('# Jyotish benchmark 第七轮:Chara Dasha / Jaimini 时间线对标') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮目的') + lines.append('') + lines.append('- 验证当前 skill `scripts/jaimini.py` 的 Chara Dasha 是否可作为正式计算模块使用。') + lines.append('- 对标对象:PyJHora `raasi/chara.py` 的 KN Rao method(PyJHora 默认 `CHARA_TYPE_DEFAULT = KN_RAO`)。') + lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append('| Field group | Total | Match | Mismatch | Match rate |') + lines.append('|---|---:|---:|---:|---:|') + for label, prefix in [('sequence_sign', 'md.sign'), ('duration_years', 'md.duration'), ('all', None)]: + total, match, mismatch, rate = summarize(rows, prefix) + lines.append(f'| {label} | {total} | {match} | {mismatch} | {rate:.2%} |') + lines.append('') + lines.append('## 3. 逐样本摘要') + lines.append('') + lines.append('| Sample | Sign match | Duration match | Local first 3 | PyJHora first 3 |') + lines.append('|---|---:|---:|---|---|') + for item in per_sample: + lines.append(f"| {item['sample_id']} | {item['sign_match']}/12 | {item['duration_match']}/12 | {item['local_first3']} | {item['pyjhora_first3']} |") + lines.append('') + lines.append('## 4. 仲裁结论') + lines.append('') + sign_total, sign_match, _, sign_rate = summarize(rows, 'md.sign') + dur_total, dur_match, _, dur_rate = summarize(rows, 'md.duration') + if sign_rate < 0.9 or dur_rate < 0.9: + lines.append('- 当前 skill 的 Chara Dasha 与 PyJHora KN Rao method 存在明显差异。') + lines.append('- 根因从源码可见:当前 `calc_chara_dasha()` 仍是简化实现(上升顺/逆 + `12 - sign planet count`),并非 KN Rao / PVN Rao / Iranganti 的完整传统算法。') + lines.append('- 决策:Chara Dasha 不应标记为 `covered` 的强计算模块;在可信度矩阵中应降级为 `partial-code`,除非后续直接实装 KN Rao/PVN Rao method 并回归通过。') + lines.append('- 加速策略:可把 PyJHora KN Rao method 作为外部 oracle,重写本地 Chara Dasha;或者在 skill 中明确声明 Jaimini Chara Dasha 暂不可用于高置信度应期。') + else: + lines.append('- 当前 skill Chara Dasha 与 PyJHora KN Rao method 基本一致,可暂定通过。') + return '\n'.join(lines) + + +def main(): + samples = json.loads(DATA.read_text()) + rows = [] + per_sample = [] + for sample in samples: + sid = sample['id'] + c = canon(sid) + local = local_chara(sample, c) + pyj = pyjhora_chara(sample) + sign_match = 0 + duration_match = 0 + for i in range(12): + add(rows, sid, f'md.sign.{i+1}', local[i]['sign'], pyj[i]['sign']) + sign_match += 1 if rows[-1]['status'] == 'match' else 0 + # durations are integers in both systems for maha periods; compare rounded to 4 places. + lv = round(local[i]['duration_years'], 4) + pv = round(pyj[i]['duration_years'], 4) + add(rows, sid, f'md.duration.{i+1}', lv, pv) + duration_match += 1 if rows[-1]['status'] == 'match' else 0 + per_sample.append({ + 'sample_id': sid, + 'sign_match': sign_match, + 'duration_match': duration_match, + 'local_first3': ', '.join(f"{x['sign']}({x['duration_years']:.0f})" for x in local[:3]), + 'pyjhora_first3': ', '.join(f"{x['sign']}({x['duration_years']:.0f})" for x in pyj[:3]), + }) + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + REPORT.write_text(write_report(rows, per_sample)) + print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'rows': len(rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_node_mode_compare.py b/benchmarks/jyotish/scripts/run_node_mode_compare.py new file mode 100644 index 00000000..8df0a491 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_node_mode_compare.py @@ -0,0 +1,251 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +"""Rahu/Ketu node-mode arbitration benchmark. + +Compares local Jyotish skill canonical output against Swiss Ephemeris Mean/True Node +and PyJHora's default rasi_chart node mode. Samples are fictional/public smoke cases. +""" +import csv +import json +import math +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +CANON = OUT / 'canonical' +REPORT = OUT / 'jyotish_benchmark_round4_node_mode_compare.md' +MATRIX = OUT / 'node_mode_comparison_matrix.csv' +PYJHORA_SITE = Path(__import__('os').environ.get('PYJHORA_SITE', '')) +PYJHORA_COMPAT = ROOT / 'scripts/pyjhora_compat' + +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +NAKSHATRAS = [ + 'Ashwini','Bharani','Krittika','Rohini','Mrigashira','Ardra','Punarvasu','Pushya','Ashlesha', + 'Magha','Purva Phalguni','Uttara Phalguni','Hasta','Chitra','Swati','Vishakha','Anuradha','Jyeshtha', + 'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha','Purva Bhadrapada','Uttara Bhadrapada','Revati' +] +PYJHORA_PLANET_ID = {7: 'Rahu', 8: 'Ketu'} + + +def norm(deg): + return deg % 360.0 + + +def sign_of(lon): + return SIGNS[int(norm(lon) // 30)] + + +def degree_in_sign(lon): + return norm(lon) % 30.0 + + +def nakshatra_of(lon): + unit = 360.0 / 27.0 + idx = int(math.floor(norm(lon) / unit)) + pada = int(math.floor((norm(lon) % unit) / (unit / 4.0))) + 1 + return NAKSHATRAS[idx], min(pada, 4) + + +def julian_day_utc(birth): + tz = float(birth['tz']) + local = datetime(int(birth['year']), int(birth['month']), int(birth['day']), int(birth['hour']), int(birth['minute'])) + utc_dt = local - timedelta(hours=tz) + hour = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0 + return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL) + + +def point_from_lon(lon): + nak, pada = nakshatra_of(lon) + return { + 'longitude': round(norm(lon), 6), + 'sign': sign_of(lon), + 'degree_in_sign': round(degree_in_sign(lon), 6), + 'nakshatra': nak, + 'nakshatra_pada': pada, + } + + +def swiss_nodes(sample, node_pid): + jd = julian_day_utc(sample['birth']) + swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) + flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED + res, _ = swe.calc_ut(jd, node_pid, flags) + rahu = point_from_lon(res[0]) + ketu = point_from_lon(res[0] + 180.0) + return {'Rahu': rahu, 'Ketu': ketu} + + +def patch_swisseph_for_pyjhora(): + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + orig_calc_ut = swe.calc_ut + def calc_ut(jd, body, flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + return orig_calc_ut(jd, body, flags) + swe.calc_ut = calc_ut + orig_houses_ex = swe.houses_ex + def houses_ex(tjdut, lat, lon, hsys=b'P', flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + if 'hsys' in kwargs: + hsys = kwargs.pop('hsys') + return orig_houses_ex(tjdut, lat, lon, hsys, flags) + swe.houses_ex = houses_ex + return swe + + +def pyjhora_default_nodes(sample): + # PyJHora is used only as an external benchmark. Do not vendor/copy its code into the skill. + if str(PYJHORA_COMPAT) not in sys.path: + sys.path.insert(0, str(PYJHORA_COMPAT)) + if str(PYJHORA_SITE) not in sys.path: + sys.path.insert(0, str(PYJHORA_SITE)) + patch_swisseph_for_pyjhora() + from jhora import utils, const + from jhora.panchanga import drik + from jhora.horoscope.chart import charts + const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' + drik.set_ayanamsa_mode('LAHIRI') + # Note: charts.rasi_chart -> drik.dhasavarga() defaults to set_rahu_ketu_as_true_nodes=True. + b = sample['birth'] + jd = utils.julian_day_number((b['year'], b['month'], b['day']), (b['hour'], b['minute'], 0)) + place = drik.Place(sample['label'], b['lat'], b['lon'], b['tz']) + nodes = {} + for key, value in charts.rasi_chart(jd, place): + body = PYJHORA_PLANET_ID.get(key) + if not body: + continue + sign_idx, deg = value + abs_lon = int(sign_idx) * 30.0 + float(deg) + nodes[body] = point_from_lon(abs_lon) + return nodes + + +def local_nodes(sample_id): + local = json.loads((CANON / f'{sample_id}.canonical.json').read_text()) + out = {} + for body in ['Rahu', 'Ketu']: + p = local['planets'][body] + lon = SIGNS.index(p['sign']) * 30.0 + float(p['degree_in_sign']) + out[body] = { + 'longitude': round(lon, 6), + 'sign': p['sign'], + 'degree_in_sign': round(float(p['degree_in_sign']), 6), + 'nakshatra': p['nakshatra'], + 'nakshatra_pada': p['nakshatra_pada'], + } + return out + + +def compare_point(rows, sample_id, body, field, local_value, target_name, target_value, tolerance=None): + status = 'match' + delta = '' + if tolerance is not None: + delta_val = abs(float(local_value) - float(target_value)) + delta = round(delta_val, 6) + status = 'match' if delta_val <= tolerance else 'mismatch' + else: + status = 'match' if local_value == target_value else 'mismatch' + rows.append({ + 'sample_id': sample_id, + 'body': body, + 'field': field, + 'target': target_name, + 'local_skill': local_value, + 'target_value': target_value, + 'delta': delta, + 'status': status, + }) + + +def summarize(rows, target): + subset = [r for r in rows if r['target'] == target] + total = len(subset) + match = sum(1 for r in subset if r['status'] == 'match') + return {'target': target, 'total': total, 'match': match, 'mismatch': total - match, 'rate': match / total if total else 0.0} + + +def write_report(rows): + targets = ['swiss_mean_node', 'swiss_true_node', 'pyjhora_default_rasi'] + lines = [] + lines.append('# Jyotish benchmark 第四轮:Rahu/Ketu 节点口径仲裁') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮目的') + lines.append('') + lines.append('- 解释第三轮 PyJHora 对比中 Rahu/Ketu 大量差异的根因。') + lines.append('- 对比当前 skill canonical baseline 与 Swiss Ephemeris Mean Node、Swiss Ephemeris True Node、PyJHora rasi_chart 默认输出。') + lines.append('- 样本仍为10个公开/虚构 smoke case,不包含用户个人资料。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append('| Target | Total | Match | Mismatch | Match rate |') + lines.append('|---|---:|---:|---:|---:|') + summaries = [summarize(rows, t) for t in targets] + for s in summaries: + lines.append(f"| {s['target']} | {s['total']} | {s['match']} | {s['mismatch']} | {s['rate']:.2%} |") + lines.append('') + lines.append('## 3. 分字段统计') + lines.append('') + lines.append('| Target | Field | Total | Match | Mismatch |') + lines.append('|---|---|---:|---:|---:|') + for target in targets: + for field in ['sign', 'degree_in_sign', 'nakshatra', 'nakshatra_pada']: + subset = [r for r in rows if r['target'] == target and r['field'] == field] + match = sum(1 for r in subset if r['status'] == 'match') + lines.append(f'| {target} | {field} | {len(subset)} | {match} | {len(subset)-match} |') + lines.append('') + mismatches = [r for r in rows if r['status'] == 'mismatch'] + lines.append('## 4. 关键不匹配样例') + lines.append('') + lines.append('| Sample | Target | Body | Field | Local skill | Target value | Delta |') + lines.append('|---|---|---|---|---|---|---:|') + for r in mismatches[:120]: + lines.append(f"| {r['sample_id']} | {r['target']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['target_value']} | {r['delta']} |") + lines.append('') + lines.append('## 5. 仲裁结论') + lines.append('') + lines.append('- 当前 skill 的 Rahu/Ketu 与 Swiss Ephemeris **Mean Node** 口径完全一致;这解释了第一轮 Swiss direct 450/450 匹配。') + lines.append('- PyJHora 4.8.6 的 `rasi_chart()` 默认走 `drik.dhasavarga(... set_rahu_ketu_as_true_nodes=True)`,即默认使用 **True Node**。') + lines.append('- 因此第三轮 PyJHora 中 Rahu/Ketu 的 degree/nakshatra/D9/D10 差异,主要不是当前 skill 的计算 bug,而是 **Mean Node vs True Node 口径差异**。') + lines.append('- 工程建议:当前 skill 应显式声明默认 `node_mode=mean`,后续可新增 `--node-mode mean|true` 参数;benchmark 报告中也应把节点口径列为冻结参数。') + return '\n'.join(lines) + + +def main(): + samples = json.loads(DATA.read_text()) + rows = [] + for sample in samples: + sample_id = sample['id'] + local = local_nodes(sample_id) + targets = { + 'swiss_mean_node': swiss_nodes(sample, swe.MEAN_NODE), + 'swiss_true_node': swiss_nodes(sample, swe.TRUE_NODE), + 'pyjhora_default_rasi': pyjhora_default_nodes(sample), + } + for target_name, target_nodes in targets.items(): + for body in ['Rahu', 'Ketu']: + for field in ['sign', 'nakshatra', 'nakshatra_pada']: + compare_point(rows, sample_id, body, field, local[body][field], target_name, target_nodes[body][field]) + compare_point(rows, sample_id, body, 'degree_in_sign', local[body]['degree_in_sign'], target_name, target_nodes[body]['degree_in_sign'], tolerance=0.1) + with MATRIX.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'body', 'field', 'target', 'local_skill', 'target_value', 'delta', 'status']) + writer.writeheader() + writer.writerows(rows) + REPORT.write_text(write_report(rows)) + print(json.dumps({'report': str(REPORT), 'matrix': str(MATRIX), 'samples': len(samples), 'fields': len(rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_pyjhora_compare.py b/benchmarks/jyotish/scripts/run_pyjhora_compare.py new file mode 100644 index 00000000..d21398a6 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_pyjhora_compare.py @@ -0,0 +1,345 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +LOCAL_CANON = OUT / 'canonical' +PYJHORA_OUT = OUT / 'pyjhora' + +# Keep personal data out of benchmark: samples are fictional/public smoke cases only. +PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'] +PYJHORA_PLANET_ID = { + 0: 'Sun', + 1: 'Moon', + 2: 'Mars', + 3: 'Mercury', + 4: 'Jupiter', + 5: 'Venus', + 6: 'Saturn', + 7: 'Rahu', + 8: 'Ketu', +} +DASHA_LORDS = {8: 'Ketu', 5: 'Venus', 0: 'Sun', 1: 'Moon', 2: 'Mars', 7: 'Rahu', 4: 'Jupiter', 6: 'Saturn', 3: 'Mercury'} +SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] +NAKSHATRAS = [ + 'Ashwini', 'Bharani', 'Krittika', 'Rohini', 'Mrigashira', 'Ardra', 'Punarvasu', 'Pushya', 'Ashlesha', + 'Magha', 'Purva Phalguni', 'Uttara Phalguni', 'Hasta', 'Chitra', 'Swati', 'Vishakha', 'Anuradha', 'Jyeshtha', + 'Mula', 'Purva Ashadha', 'Uttara Ashadha', 'Shravana', 'Dhanishta', 'Shatabhisha', 'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati' +] + + +def patch_swisseph(): + import swisseph as swe + for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: + if not hasattr(swe, name): + setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) + + orig_calc_ut = swe.calc_ut + def calc_ut(jd, body, flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + return orig_calc_ut(jd, body, flags) + swe.calc_ut = calc_ut + + orig_houses_ex = swe.houses_ex + def houses_ex(tjdut, lat, lon, hsys=b'P', flags=0, *args, **kwargs): + if 'flags' in kwargs: + flags = kwargs.pop('flags') + if 'hsys' in kwargs: + hsys = kwargs.pop('hsys') + return orig_houses_ex(tjdut, lat, lon, hsys, flags) + swe.houses_ex = houses_ex + return swe + + +def sign_name(sign_idx): + return SIGNS[int(sign_idx) % 12] + + +def nakshatra_from_abs(abs_lon): + x = abs_lon % 360.0 + unit = 360.0 / 27.0 + idx = int(x // unit) + pada = int((x % unit) // (unit / 4.0)) + 1 + return NAKSHATRAS[idx], pada + + +def parse_chart_positions(rows): + result = {} + for key, value in rows: + sign_idx, deg = value + if key == 'L': + body = 'Ascendant' + else: + body = PYJHORA_PLANET_ID.get(key) + if not body: + continue + result[body] = { + 'sign': sign_name(sign_idx), + 'sign_idx': int(sign_idx), + 'degree_in_sign': round(float(deg), 4), + } + return result + + +def tuple_to_date(t): + if not t: + return None + y, m, d, _fh = t + return f'{int(y):04d}-{int(m):02d}-{int(d):02d}' + + +def build_pyjhora_sample(sample): + swe = patch_swisseph() + from jhora import utils, const + from jhora.panchanga import drik + from jhora.horoscope.chart import charts + from jhora.horoscope.dhasa.graha import vimsottari + + # Align benchmark口径: Lahiri + mean sidereal year. PyJHora default is TRUE_PUSHYA. + const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' + drik.set_ayanamsa_mode('LAHIRI') + try: + const.dhasa_year_duration_default = const.DHASA_YEAR_DURATION.MEAN_SIDEREAL_YEAR + except Exception: + pass + + b = sample['birth'] + jd = utils.julian_day_number((b['year'], b['month'], b['day']), (b['hour'], b['minute'], 0)) + place = drik.Place(sample['label'], b['lat'], b['lon'], b['tz']) + today = sample.get('today', '2026-06-03') + ty, tm, td = [int(x) for x in today.split('-')] + current_jd = utils.julian_day_number((ty, tm, td), (0, 0, 0)) + + rasi = parse_chart_positions(charts.rasi_chart(jd, place)) + d9 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=9, chart_method=1)) + d10 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=10, chart_method=1)) + + asc = rasi.get('Ascendant') or {} + planets = {} + for p in PLANETS: + pd = rasi.get(p) or {} + abs_lon = pd.get('sign_idx', 0) * 30.0 + float(pd.get('degree_in_sign', 0.0)) + nak, pada = nakshatra_from_abs(abs_lon) + planets[p] = { + 'sign': pd.get('sign'), + 'degree_in_sign': pd.get('degree_in_sign'), + 'nakshatra': nak, + 'nakshatra_pada': pada, + } + + dasha = { + 'mahadasha_lord': None, + 'mahadasha_start': None, + 'mahadasha_end': None, + 'antardasha_lord': None, + 'antardasha_start': None, + 'antardasha_end': None, + } + try: + ladder = vimsottari.get_running_dhasa_for_given_date(current_jd, jd, place, dhasa_level_index=2) + if ladder: + md = ladder[0] + dasha['mahadasha_lord'] = DASHA_LORDS.get(md[0][0], str(md[0][0])) + dasha['mahadasha_start'] = tuple_to_date(md[1]) + dasha['mahadasha_end'] = tuple_to_date(md[2]) + if len(ladder) > 1: + ad = ladder[1] + dasha['antardasha_lord'] = DASHA_LORDS.get(ad[0][-1], str(ad[0][-1])) + dasha['antardasha_start'] = tuple_to_date(ad[1]) + dasha['antardasha_end'] = tuple_to_date(ad[2]) + except Exception as exc: + dasha['error'] = f'{type(exc).__name__}: {exc}' + + return { + 'sample_id': sample['id'], + 'engine': 'PyJHora_4_8_6_lahiri_patched', + 'parameters': { + 'zodiac': 'sidereal', + 'ayanamsa': 'LAHIRI', + 'd9_method': 'PyJHora divisional_chart chart_method=1', + 'd10_method': 'PyJHora divisional_chart chart_method=1', + 'dasha_year': 'mean sidereal year', + 'compat': 'monkeypatch swisseph keyword API + missing constants; dummy timezonefinder only for import', + 'license_note': 'PyJHora is AGPL-3.0; used only as external benchmark, not vendored into skill.' + }, + 'ascendant': { + 'sign': asc.get('sign'), + 'degree_in_sign': asc.get('degree_in_sign'), + }, + 'planets': planets, + 'varga': {'D9': d9, 'D10': d10}, + 'dasha': dasha, + } + + +def compare_scalar(rows, sample_id, section, body, field, local_value, pyjhora_value, tolerance=None, date_tolerance_days=None, boundary_sensitive=False, status_override=None): + status = status_override or 'match' + delta = '' + if not status_override: + if date_tolerance_days is not None: + try: + ld = datetime.strptime(str(local_value), '%Y-%m-%d') + pd = datetime.strptime(str(pyjhora_value), '%Y-%m-%d') + delta_val = abs((ld - pd).days) + delta = delta_val + status = 'match' if delta_val <= date_tolerance_days else 'mismatch' + except Exception: + status = 'not_comparable' + elif tolerance is not None: + try: + delta_val = abs(float(local_value) - float(pyjhora_value)) + delta = round(delta_val, 6) + status = 'match' if delta_val <= tolerance else 'mismatch' + except Exception: + status = 'not_comparable' + else: + status = 'match' if local_value == pyjhora_value else 'mismatch' + if status == 'mismatch' and boundary_sensitive: + status = 'boundary_sensitive' + rows.append({ + 'sample_id': sample_id, + 'section': section, + 'body': body, + 'field': field, + 'local_skill': local_value, + 'pyjhora': pyjhora_value, + 'delta': delta, + 'status': status, + }) + + +def compare_one(sample_id, local, pyjhora): + rows = [] + compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'sign', local['ascendant'].get('sign'), pyjhora['ascendant'].get('sign')) + compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'degree_in_sign', local['ascendant'].get('degree_in_sign'), pyjhora['ascendant'].get('degree_in_sign'), tolerance=0.15) + for p in PLANETS: + l = local['planets'].get(p, {}) + y = pyjhora['planets'].get(p, {}) + for field in ['sign', 'nakshatra', 'nakshatra_pada']: + compare_scalar(rows, sample_id, 'planet', p, field, l.get(field), y.get(field)) + compare_scalar(rows, sample_id, 'planet', p, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.15) + for varga_name in ['D9', 'D10']: + for body in ['Ascendant'] + PLANETS: + l = (local['varga'].get(varga_name) or {}).get(body) or {} + y = (pyjhora['varga'].get(varga_name) or {}).get(body) or {} + boundary_sensitive = False + try: + boundary_sensitive = abs(float(l.get('degree_in_sign', 99)) - float(y.get('degree_in_sign', -99))) > 20 and l.get('sign') != y.get('sign') + except Exception: + pass + compare_scalar(rows, sample_id, varga_name, body, 'sign', l.get('sign'), y.get('sign'), boundary_sensitive=boundary_sensitive) + compare_scalar(rows, sample_id, varga_name, body, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.2, boundary_sensitive=boundary_sensitive) + # PyJHora dasha is useful as external signal, but currently has different default starting convention/seed in some cases. + # Keep fields in matrix, with generous date tolerance; differences are classified below in report. + for field in ['mahadasha_lord', 'antardasha_lord']: + compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), pyjhora['dasha'].get(field)) + for field in ['mahadasha_start', 'mahadasha_end', 'antardasha_start', 'antardasha_end']: + compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), pyjhora['dasha'].get(field), date_tolerance_days=7) + return rows + + +def write_report(samples, rows): + total = len(rows) + counts = {} + by_section = {} + for r in rows: + counts[r['status']] = counts.get(r['status'], 0) + 1 + stat = by_section.setdefault(r['section'], {'total': 0}) + stat['total'] += 1 + stat[r['status']] = stat.get(r['status'], 0) + 1 + matches = counts.get('match', 0) + mismatches = [r for r in rows if r['status'] == 'mismatch'] + boundary = [r for r in rows if r['status'] == 'boundary_sensitive'] + non_dasha_rows = [r for r in rows if r['section'] != 'dasha'] + non_dasha_match = sum(1 for r in non_dasha_rows if r['status'] == 'match') + non_dasha_ok = sum(1 for r in non_dasha_rows if r['status'] in ('match', 'boundary_sensitive')) + + lines = [] + lines.append('# Jyotish benchmark 第三轮:PyJHora 对比报告') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮范围') + lines.append('') + lines.append('- 外部引擎:PyJHora 4.8.6。') + lines.append('- 用途:第二个独立 Jyotish 开源项目对标,重点验证 D1、D9、D10,并初探 Vimshottari。') + lines.append('- 样本:10个公开/虚构 smoke case,不包含用户个人资料。') + lines.append('- 口径:强制 Lahiri;PyJHora 默认 TRUE_PUSHYA,因此本轮显式切换到 LAHIRI。') + lines.append('- 兼容处理:PyJHora 4.8.6 与本机 pyswisseph API 存在关键字参数/常量兼容问题,本脚本只在 benchmark 进程内 monkeypatch,不改 PyJHora 源码,不把 AGPL 代码并入 skill。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append(f'- 字段总数:{total}') + lines.append(f'- 匹配:{matches}') + lines.append(f'- 不匹配:{len(mismatches)}') + lines.append(f'- 边界敏感:{len(boundary)}') + lines.append(f'- 总严格匹配率:{matches / total:.2%}' if total else '- 总严格匹配率:N/A') + lines.append(f'- 非 Dasha 字段严格匹配率:{non_dasha_match / len(non_dasha_rows):.2%}' if non_dasha_rows else '- 非 Dasha 字段严格匹配率:N/A') + lines.append(f'- 非 Dasha 字段边界归因后可接受率:{non_dasha_ok / len(non_dasha_rows):.2%}' if non_dasha_rows else '- 非 Dasha 字段边界归因后可接受率:N/A') + lines.append('') + lines.append('## 3. 分区统计') + lines.append('') + lines.append('| Section | Total | Match | Mismatch | Boundary sensitive | Not comparable |') + lines.append('|---|---:|---:|---:|---:|---:|') + for section, stat in sorted(by_section.items()): + lines.append(f"| {section} | {stat.get('total',0)} | {stat.get('match',0)} | {stat.get('mismatch',0)} | {stat.get('boundary_sensitive',0)} | {stat.get('not_comparable',0)} |") + lines.append('') + if mismatches: + lines.append('## 4. 不匹配字段') + lines.append('') + lines.append('| Sample | Section | Body | Field | Local skill | PyJHora | Delta |') + lines.append('|---|---|---|---|---|---|---:|') + for r in mismatches[:160]: + lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['pyjhora']} | {r['delta']} |") + lines.append('') + if boundary: + lines.append('## 4b. 边界敏感字段') + lines.append('') + lines.append('| Sample | Section | Body | Field | Local skill | PyJHora | Delta |') + lines.append('|---|---|---|---|---|---|---:|') + for r in boundary[:80]: + lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['pyjhora']} | {r['delta']} |") + lines.append('') + lines.append('## 5. 判断') + lines.append('') + lines.append('- PyJHora 作为第二开源引擎已经接入成功。') + lines.append('- D1/D9/D10若高匹配,说明当前 skill 的分盘算法不仅与 Swiss direct 自算一致,也能通过独立 Jyotish 项目的实测。') + lines.append('- Dasha 部分若存在系统性差异,优先视为 PyJHora seed_star / dasha year / 起运规则口径差异,不能马上判定本 skill 错;需要 JHora 或 Drik Panchang 再仲裁。') + lines.append('- PyJHora 是 AGPL-3.0,适合做外部 benchmark,不适合把其源码或派生实现并入当前 skill。') + return '\n'.join(lines) + + +def main(): + PYJHORA_OUT.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + all_rows = [] + for sample in samples: + pyjhora = build_pyjhora_sample(sample) + (PYJHORA_OUT / f"{sample['id']}.pyjhora.json").write_text(json.dumps(pyjhora, ensure_ascii=False, indent=2)) + local = json.loads((LOCAL_CANON / f"{sample['id']}.canonical.json").read_text()) + all_rows.extend(compare_one(sample['id'], local, pyjhora)) + + matrix = OUT / 'pyjhora_comparison_matrix.csv' + with matrix.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'section', 'body', 'field', 'local_skill', 'pyjhora', 'delta', 'status']) + writer.writeheader() + writer.writerows(all_rows) + + report = OUT / 'jyotish_benchmark_round3_pyjhora_compare.md' + report.write_text(write_report(samples, all_rows)) + print(json.dumps({'report': str(report), 'matrix': str(matrix), 'samples': len(samples), 'fields': len(all_rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_shadbala_invariants.py b/benchmarks/jyotish/scripts/run_shadbala_invariants.py new file mode 100644 index 00000000..11cd8dbd --- /dev/null +++ b/benchmarks/jyotish/scripts/run_shadbala_invariants.py @@ -0,0 +1,229 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL_SCRIPT = Path(__file__).resolve().parents[2] / 'scripts' / 'jyotish_engine.py' +PYTHON = Path(sys.executable) +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +RAW = OUT / 'raw' +SHADBALA_OUT = OUT / 'shadbala_invariants' +PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] +NAISARGIKA = {'Sun': 60.0, 'Moon': 60.0, 'Venus': 52.5, 'Jupiter': 45.0, 'Mercury': 37.5, 'Mars': 30.0, 'Saturn': 22.5} +MIN_REQUIRED = {'Sun': 5.0, 'Moon': 6.0, 'Mars': 5.0, 'Mercury': 7.0, 'Jupiter': 6.5, 'Venus': 5.5, 'Saturn': 5.0} + + +def run_engine(sample, command): + birth = sample['birth'] + cmd = [ + str(PYTHON), str(SKILL_SCRIPT), command, + '--year', str(birth['year']), + '--month', str(birth['month']), + '--day', str(birth['day']), + '--hour', str(birth['hour']), + '--minute', str(birth['minute']), + '--lat', str(birth['lat']), + '--lon', str(birth['lon']), + '--tz', str(birth['tz']), + '--node-mode', 'mean', + ] + if command == 'full-reading': + today = sample.get('today', '2026-06-03') + cmd.extend(['--today', today, '--transit-date', today]) + proc = subprocess.run(cmd, text=True, capture_output=True) + raw_path = RAW / f"{sample['id']}.{command}.shadbala.json" + if proc.returncode != 0: + raw_path.write_text(json.dumps({'cmd': cmd, 'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr}, ensure_ascii=False, indent=2)) + raise RuntimeError(proc.stderr[:500]) + data = json.loads(proc.stdout) + raw_path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + return data + + +def add_row(rows, sample_id, planet, check, local, expected, status, detail=''): + rows.append({ + 'sample_id': sample_id, + 'planet': planet, + 'check': check, + 'local': local, + 'expected': expected, + 'status': status, + 'detail': detail, + }) + + +def close(a, b, tol=0.05): + try: + return abs(float(a) - float(b)) <= tol + except Exception: + return False + + +def validate_shadbala(sample, shadbala, full_reading): + rows = [] + sid = sample['id'] + planets = shadbala.get('planets', {}) + fr_shadbala = full_reading.get('modules', {}).get('shadbala', {}) + fr_planets = fr_shadbala.get('planets', {}) + + add_row(rows, sid, 'module', 'method_present', bool(shadbala.get('method')), True, 'match' if shadbala.get('method') else 'mismatch') + add_row(rows, sid, 'module', 'seven_planets_present', sorted(planets.keys()), sorted(PLANETS), 'match' if sorted(planets.keys()) == sorted(PLANETS) else 'mismatch') + add_row(rows, sid, 'module', 'ranking_permutation', sorted(shadbala.get('ranking', [])), sorted(PLANETS), 'match' if sorted(shadbala.get('ranking', [])) == sorted(PLANETS) else 'mismatch') + + totals_for_rank = [] + for pname in PLANETS: + pdata = planets.get(pname, {}) + fpdata = fr_planets.get(pname, {}) + sthana = pdata.get('sthana_bala', {}) + kala = pdata.get('kala_bala', {}) + required_fields = [ + 'sthana_bala', 'dig_bala', 'kala_bala', 'chesta_bala', 'naisargika_bala', + 'drik_bala', 'total_virupas', 'total_rupas', 'min_required', 'ishta_bala_pct', + 'strength_level', 'rank' + ] + missing = [field for field in required_fields if field not in pdata] + add_row(rows, sid, pname, 'required_fields', missing, [], 'match' if not missing else 'mismatch') + + component_sum = ( + float(sthana.get('total', 0)) + float(pdata.get('dig_bala', 0)) + float(kala.get('total', 0)) + + float(pdata.get('chesta_bala', 0)) + float(pdata.get('naisargika_bala', 0)) + float(pdata.get('drik_bala', 0)) + ) + total_virupas = pdata.get('total_virupas') + add_row(rows, sid, pname, 'total_virupas_sum', total_virupas, round(component_sum, 2), 'match' if close(total_virupas, component_sum, 0.08) else 'mismatch') + add_row(rows, sid, pname, 'total_rupas_conversion', pdata.get('total_rupas'), round(float(total_virupas) / 60.0, 4) if total_virupas is not None else None, 'match' if total_virupas is not None and close(pdata.get('total_rupas'), float(total_virupas) / 60.0, 0.005) else 'mismatch') + min_req = MIN_REQUIRED[pname] + add_row(rows, sid, pname, 'min_required_constant', pdata.get('min_required'), min_req, 'match' if close(pdata.get('min_required'), min_req, 0.001) else 'mismatch') + expected_ishta = float(pdata.get('total_rupas', 0)) / min_req * 100.0 + add_row(rows, sid, pname, 'ishta_pct_formula', pdata.get('ishta_bala_pct'), round(expected_ishta, 1), 'match' if close(pdata.get('ishta_bala_pct'), expected_ishta, 0.15) else 'mismatch') + + range_checks = { + 'sthana.ucha_bala_range': (sthana.get('ucha_bala'), 0, 60), + 'sthana.ojayugma_enum': (sthana.get('ojayugma_bala'), {0, 15}, None), + 'sthana.kendra_enum': (sthana.get('kendra_bala'), {0, 15}, None), + 'sthana.drekkana_enum': (sthana.get('drekkana_bala'), {0, 15}, None), + 'dig_bala_range': (pdata.get('dig_bala'), 0, 60), + 'kala.total_range': (kala.get('total'), 0, 195), + 'chesta_bala_range': (pdata.get('chesta_bala'), 0, 60), + 'drik_bala_range': (pdata.get('drik_bala'), -60, 60), + } + for check, spec in range_checks.items(): + val = spec[0] + if isinstance(spec[1], set): + ok = val in spec[1] + expected = sorted(spec[1]) + else: + lo, hi = spec[1], spec[2] + ok = val is not None and lo <= float(val) <= hi + expected = f'{lo}..{hi}' + add_row(rows, sid, pname, check, val, expected, 'match' if ok else 'mismatch') + + add_row(rows, sid, pname, 'naisargika_constant', pdata.get('naisargika_bala'), NAISARGIKA[pname], 'match' if close(pdata.get('naisargika_bala'), NAISARGIKA[pname], 0.001) else 'mismatch') + add_row(rows, sid, pname, 'full_reading_total_match', fpdata.get('total_rupas'), pdata.get('total_rupas'), 'match' if close(fpdata.get('total_rupas'), pdata.get('total_rupas'), 0.001) else 'mismatch') + add_row(rows, sid, pname, 'full_reading_rank_match', fpdata.get('rank'), pdata.get('rank'), 'match' if fpdata.get('rank') == pdata.get('rank') else 'mismatch') + totals_for_rank.append((pname, float(pdata.get('total_rupas', -999)), pdata.get('rank'))) + + sorted_rank = [p for p, _, _ in sorted(totals_for_rank, key=lambda item: item[1], reverse=True)] + add_row(rows, sid, 'module', 'ranking_sorted_by_total', shadbala.get('ranking'), sorted_rank, 'match' if shadbala.get('ranking') == sorted_rank else 'mismatch') + add_row(rows, sid, 'module', 'strongest_matches_rank', shadbala.get('strongest'), sorted_rank[0] if sorted_rank else None, 'match' if shadbala.get('strongest') == (sorted_rank[0] if sorted_rank else None) else 'mismatch') + add_row(rows, sid, 'module', 'weakest_matches_rank', shadbala.get('weakest'), sorted_rank[-1] if sorted_rank else None, 'match' if shadbala.get('weakest') == (sorted_rank[-1] if sorted_rank else None) else 'mismatch') + + fr_total = fr_shadbala.get('total_shadbala') + expected_total = round(sum(float(planets[p].get('total_rupas', 0)) for p in PLANETS), 2) + add_row(rows, sid, 'module', 'full_reading_total_shadbala', fr_total, expected_total, 'match' if close(fr_total, expected_total, 0.01) else 'mismatch') + add_row(rows, sid, 'module', 'full_reading_total_min_required', fr_shadbala.get('total_min_required'), round(sum(MIN_REQUIRED.values()), 2), 'match' if close(fr_shadbala.get('total_min_required'), sum(MIN_REQUIRED.values()), 0.01) else 'mismatch') + return rows + + +def write_report(rows): + total = len(rows) + matches = sum(1 for r in rows if r['status'] == 'match') + mismatches = [r for r in rows if r['status'] == 'mismatch'] + by_check = {} + for r in rows: + by_check.setdefault(r['check'], {'total': 0, 'match': 0, 'mismatch': 0}) + by_check[r['check']]['total'] += 1 + by_check[r['check']][r['status']] = by_check[r['check']].get(r['status'], 0) + 1 + + lines = [] + lines.append('# Jyotish benchmark 第九轮 Shadbala 内部不变量报告') + lines.append('') + lines.append('生成时间:2026-06-04') + lines.append('') + lines.append('## 1. 范围') + lines.append('') + lines.append('- 样本:10个公开/虚构 smoke case,不包含真实用户个人资料。') + lines.append('- 对比对象:`shadbala` 子命令与 `full-reading.modules.shadbala`。') + lines.append('- 验证类型:结构完整性、六重力量组件范围、总分公式、Rupa/Virupa换算、排名一致性、full-reading输出一致性。') + lines.append('- 重要边界:本轮不是外部软件绝对值对标;当前本地未找到稳定可用的完整 Shadbala 外部基准,因此只能证明内部一致性,不能证明传统公式完全一致。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append(f'- 检查总数:{total}') + lines.append(f'- 通过:{matches}') + lines.append(f'- 失败:{len(mismatches)}') + lines.append(f'- 通过率:{matches / total:.2%}' if total else '- 通过率:N/A') + lines.append('') + lines.append('## 3. 分检查项结果') + lines.append('') + lines.append('| Check | Total | Match | Mismatch |') + lines.append('|---|---:|---:|---:|') + for check, stat in sorted(by_check.items()): + lines.append(f"| {check} | {stat['total']} | {stat.get('match', 0)} | {stat.get('mismatch', 0)} |") + lines.append('') + if mismatches: + lines.append('## 4. 失败样例') + lines.append('') + lines.append('| Sample | Planet | Check | Local | Expected | Detail |') + lines.append('|---|---|---|---|---|---|') + for r in mismatches[:120]: + lines.append(f"| {r['sample_id']} | {r['planet']} | {r['check']} | {r['local']} | {r['expected']} | {r['detail']} |") + lines.append('') + lines.append('## 5. 结论') + lines.append('') + if mismatches: + lines.append('- Shadbala 内部一致性存在失败项,应先修复输出或公式聚合。') + else: + lines.append('- Shadbala 输出结构、总分聚合、Rupa/Virupa换算、排名、full-reading一致性均通过内部不变量验证。') + lines.append('- 但源码仍包含简化项:Nathonnata Bala 二值化、部分 Saptavargaja 子分盘近似、Chesta Bala 速度分档近似、Drik Bala 简化相位权重。') + lines.append('- 因此能力标注应从 `covered` 降级为 `partial`:可作为内部一致的强弱参考,不应声称已完成传统 Parashara Shadbala 的外部绝对值校准。') + report = OUT / 'jyotish_benchmark_round9_shadbala_invariants.md' + report.write_text('\n'.join(lines)) + return report + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + RAW.mkdir(parents=True, exist_ok=True) + SHADBALA_OUT.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + all_rows = [] + for sample in samples: + shadbala = run_engine(sample, 'shadbala') + full_reading = run_engine(sample, 'full-reading') + (SHADBALA_OUT / f"{sample['id']}.shadbala.json").write_text(json.dumps(shadbala, ensure_ascii=False, indent=2)) + all_rows.extend(validate_shadbala(sample, shadbala, full_reading)) + csv_path = OUT / 'shadbala_invariants_matrix.csv' + with csv_path.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'planet', 'check', 'local', 'expected', 'status', 'detail']) + writer.writeheader() + writer.writerows(all_rows) + report = write_report(all_rows) + print(json.dumps({ + 'rows': len(all_rows), + 'matches': sum(1 for r in all_rows if r['status'] == 'match'), + 'mismatches': sum(1 for r in all_rows if r['status'] == 'mismatch'), + 'csv': str(csv_path), + 'report': str(report), + }, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_skill_baseline.py b/benchmarks/jyotish/scripts/run_skill_baseline.py new file mode 100644 index 00000000..e1462a59 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_skill_baseline.py @@ -0,0 +1,226 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL_SCRIPT = Path(__file__).resolve().parents[2] / 'scripts' / 'jyotish_engine.py' +PYTHON = Path(__import__('sys').executable) +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +RAW = OUT / 'raw' +CANON = OUT / 'canonical' + +PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'] +FIELDS = ['sign', 'house', 'degree_in_sign', 'nakshatra', 'nakshatra_pada', 'retrograde'] + + +def safe_get(obj, *keys, default=None): + cur = obj + for key in keys: + if not isinstance(cur, dict) or key not in cur: + return default + cur = cur[key] + return cur + + +def run_sample(sample): + birth = sample['birth'] + cmd = [ + str(PYTHON), str(SKILL_SCRIPT), 'full-reading', + '--year', str(birth['year']), + '--month', str(birth['month']), + '--day', str(birth['day']), + '--hour', str(birth['hour']), + '--minute', str(birth['minute']), + '--lat', str(birth['lat']), + '--lon', str(birth['lon']), + '--tz', str(birth['tz']), + '--today', sample.get('today', '2026-06-03'), + ] + proc = subprocess.run(cmd, text=True, capture_output=True) + raw_path = RAW / f"{sample['id']}.json" + if proc.returncode != 0: + raw_path.write_text(json.dumps({ + 'error': 'command_failed', + 'returncode': proc.returncode, + 'stderr': proc.stderr, + 'stdout': proc.stdout, + 'cmd': cmd, + }, ensure_ascii=False, indent=2)) + return {'id': sample['id'], 'ok': False, 'error': proc.stderr.strip()[:500]} + try: + data = json.loads(proc.stdout) + except Exception as exc: + raw_path.write_text(json.dumps({ + 'error': 'json_parse_failed', + 'exception': str(exc), + 'stdout': proc.stdout[:2000], + 'stderr': proc.stderr, + 'cmd': cmd, + }, ensure_ascii=False, indent=2)) + return {'id': sample['id'], 'ok': False, 'error': str(exc)} + raw_path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + canon = canonicalize(sample, data) + (CANON / f"{sample['id']}.canonical.json").write_text(json.dumps(canon, ensure_ascii=False, indent=2)) + return {'id': sample['id'], 'ok': True, 'canonical': canon} + + +def canonicalize(sample, data): + modules = data.get('modules', {}) + chart = modules.get('chart', {}) + planets = chart.get('planets', {}) + d9 = safe_get(modules, 'varga_full', 'D9_Navamsa', default={}) or {} + d10 = safe_get(modules, 'varga_full', 'D10_Dasamsa', default={}) or {} + current = safe_get(modules, 'dasha', 'current_dasha', default={}) or {} + ad = current.get('antardasha') or {} + special = modules.get('special_lagnas', {}) or {} + canonical = { + 'sample_id': sample['id'], + 'label': sample['label'], + 'category': sample['category'], + 'privacy': sample.get('privacy'), + 'engine': 'local_jyotish_skill_v6_0_4', + 'parameters': { + 'zodiac': 'sidereal', + 'ayanamsa': 'lahiri_assumed_by_skill', + 'house': 'whole_sign_for_planet_house', + 'today': sample.get('today'), + }, + 'birth': sample['birth'], + 'ascendant': chart.get('ascendant'), + 'planets': {}, + 'varga': { + 'D9': {k: d9.get(k) for k in ['Ascendant'] + PLANETS}, + 'D10': {k: d10.get(k) for k in ['Ascendant'] + PLANETS}, + }, + 'dasha': { + 'mahadasha_lord': current.get('lord'), + 'mahadasha_start': current.get('start'), + 'mahadasha_end': current.get('end'), + 'antardasha_lord': ad.get('lord'), + 'antardasha_start': ad.get('start'), + 'antardasha_end': ad.get('end'), + }, + 'advanced': { + 'A10_Karma_Pada': special.get('A10_Karma_Pada'), + 'vargottama_true': {p: v for p, v in (modules.get('vargottama') or {}).items() if isinstance(v, dict) and v.get('is_vargottama')}, + 'pushkara_true': {p: v for p, v in (modules.get('pushkara') or {}).items() if isinstance(v, dict) and (v.get('pushkara_navamsa') or v.get('pushkara_bhaga'))}, + 'dasha_sandhi': modules.get('dasha_sandhi'), + }, + 'module_health': { + 'module_count': len(modules), + 'validation': modules.get('validation'), + 'empty_modules': sorted([k for k, v in modules.items() if v in ({}, [], None)]), + } + } + for pname in PLANETS: + pdata = planets.get(pname, {}) or {} + canonical['planets'][pname] = {field: pdata.get(field) for field in FIELDS} + return canonical + + +def flatten_rows(results): + rows = [] + for result in results: + if not result.get('ok'): + rows.append({'sample_id': result['id'], 'engine': 'local_jyotish_skill_v6_0_4', 'section': 'run', 'field': 'status', 'value': 'failed'}) + continue + c = result['canonical'] + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': 'ascendant', 'field': 'raw', 'value': json.dumps(c['ascendant'], ensure_ascii=False)}) + for pname, pdata in c['planets'].items(): + for field, value in pdata.items(): + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': f'planet.{pname}', 'field': field, 'value': value}) + for dkey, dval in c['dasha'].items(): + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': 'dasha', 'field': dkey, 'value': dval}) + for varga_name, varga in c['varga'].items(): + for body, value in varga.items(): + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': varga_name, 'field': body, 'value': json.dumps(value, ensure_ascii=False)}) + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': 'advanced', 'field': 'A10_Karma_Pada', 'value': json.dumps(c['advanced']['A10_Karma_Pada'], ensure_ascii=False)}) + rows.append({'sample_id': c['sample_id'], 'engine': c['engine'], 'section': 'health', 'field': 'module_count', 'value': c['module_health']['module_count']}) + return rows + + +def write_report(samples, results): + ok_count = sum(1 for r in results if r.get('ok')) + lines = [] + lines.append('# Jyotish benchmark 第一轮本地基线报告') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 本轮范围') + lines.append('') + lines.append('- 本轮只建立当前 skill 的 canonical baseline。') + lines.append('- 样本全部为公开/虚构 smoke test,不包含用户个人出生资料。') + lines.append('- 还没有接入 PyJHora / VedAstro / jyotishyamitra 等外部引擎,因此本轮不能给最终可信度评分。') + lines.append('') + lines.append('## 2. 执行结果') + lines.append('') + lines.append(f'- 样本数:{len(samples)}') + lines.append(f'- 成功:{ok_count}') + lines.append(f'- 失败:{len(samples) - ok_count}') + lines.append('- 输出目录:`jyotish_benchmark/outputs/`') + lines.append('') + lines.append('## 3. 样本摘要') + lines.append('') + lines.append('| Sample | Ascendant | MD/AD | A10 | Modules | Empty modules |') + lines.append('|---|---|---|---|---:|---|') + for result in results: + if not result.get('ok'): + lines.append(f"| {result['id']} | failed | failed | failed | 0 | {result.get('error', '')} |") + continue + c = result['canonical'] + asc = c['ascendant'] + dasha = f"{c['dasha']['mahadasha_lord']} / {c['dasha']['antardasha_lord']}" + a10 = c['advanced']['A10_Karma_Pada'] + a10_txt = a10.get('sign') if isinstance(a10, dict) else '-' + empty = ', '.join(c['module_health']['empty_modules']) or '-' + lines.append(f"| {c['sample_id']} | {asc} | {dasha} | {a10_txt} | {c['module_health']['module_count']} | {empty} |") + lines.append('') + lines.append('## 4. 发现') + lines.append('') + lines.append('- 当前 skill 对 10 个 smoke 样本都能生成 full-reading canonical JSON。') + lines.append('- 这证明内部输出契约具备批量 benchmark 的基础。') + lines.append('- 但这只是 baseline,不是外部可信度证明。') + lines.append('- 下一步必须接入至少 PyJHora 和 jyotishyamitra,形成 cross-engine matrix。') + lines.append('') + lines.append('## 5. 下一步') + lines.append('') + lines.append('1. 安装/隔离运行 PyJHora,抽取 D1/D9/D10/Dasha。') + lines.append('2. 安装/隔离运行 jyotishyamitra,抽取 JSON 输出。') + lines.append('3. 若 VedAstro API 可用,加入 API 对比;否则列为人工/半自动。') + lines.append('4. 生成 `cross_engine_matrix.csv`,按字段计算一致/不一致/不可比。') + lines.append('5. 对边界样本单独标注,避免误判。') + report = OUT / 'jyotish_benchmark_round1_local_baseline.md' + report.write_text('\n'.join(lines)) + return report + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + RAW.mkdir(parents=True, exist_ok=True) + CANON.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + results = [run_sample(sample) for sample in samples] + summary = {'total': len(results), 'ok': sum(1 for r in results if r.get('ok')), 'results': [{'id': r['id'], 'ok': r.get('ok'), 'error': r.get('error')} for r in results]} + (OUT / 'run_summary.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2)) + rows = flatten_rows(results) + csv_path = OUT / 'local_skill_canonical_matrix.csv' + with csv_path.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id', 'engine', 'section', 'field', 'value']) + writer.writeheader() + writer.writerows(rows) + report = write_report(samples, results) + print(json.dumps({'summary': summary, 'matrix': str(csv_path), 'report': str(report)}, ensure_ascii=False, indent=2)) + if summary['ok'] != summary['total']: + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_swiss_direct_compare.py b/benchmarks/jyotish/scripts/run_swiss_direct_compare.py new file mode 100644 index 00000000..1243201c --- /dev/null +++ b/benchmarks/jyotish/scripts/run_swiss_direct_compare.py @@ -0,0 +1,211 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import math +from datetime import datetime, timezone, timedelta +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +SWISS_OUT = OUT / 'swiss_direct' +CANON = OUT / 'canonical' + +PLANETS = { + 'Sun': swe.SUN, + 'Moon': swe.MOON, + 'Mars': swe.MARS, + 'Mercury': swe.MERCURY, + 'Jupiter': swe.JUPITER, + 'Venus': swe.VENUS, + 'Saturn': swe.SATURN, + 'Rahu': swe.MEAN_NODE, +} +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +NAKSHATRAS = [ + 'Ashwini','Bharani','Krittika','Rohini','Mrigashira','Ardra','Punarvasu','Pushya','Ashlesha', + 'Magha','Purva Phalguni','Uttara Phalguni','Hasta','Chitra','Swati','Vishakha','Anuradha','Jyeshtha', + 'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha','Purva Bhadrapada','Uttara Bhadrapada','Revati' +] + + +def normalize(deg): + return deg % 360.0 + + +def sign_of(lon): + idx = int(normalize(lon) // 30) + return SIGNS[idx] + + +def degree_in_sign(lon): + return normalize(lon) % 30 + + +def nakshatra_of(lon): + unit = 360.0 / 27.0 + pos = normalize(lon) / unit + idx = int(math.floor(pos)) + pada = int(math.floor((pos - idx) * 4)) + 1 + if pada > 4: + pada = 4 + return NAKSHATRAS[idx], pada + + +def julian_day_utc(birth): + tz = float(birth['tz']) + local = datetime(int(birth['year']), int(birth['month']), int(birth['day']), int(birth['hour']), int(birth['minute'])) + utc_dt = local - timedelta(hours=tz) + hour = utc_dt.hour + utc_dt.minute / 60 + utc_dt.second / 3600 + return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL) + + +def calc_swiss(sample): + birth = sample['birth'] + jd = julian_day_utc(birth) + swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) + flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED + planets = {} + for name, pid in PLANETS.items(): + res, ret = swe.calc_ut(jd, pid, flags) + lon = normalize(res[0]) + nak, pada = nakshatra_of(lon) + planets[name] = { + 'longitude': round(lon, 6), + 'sign': sign_of(lon), + 'degree_in_sign': round(degree_in_sign(lon), 6), + 'nakshatra': nak, + 'nakshatra_pada': pada, + 'retrograde': bool(res[3] < 0), + } + rahu_lon = planets['Rahu']['longitude'] + ketu_lon = normalize(rahu_lon + 180) + nak, pada = nakshatra_of(ketu_lon) + planets['Ketu'] = { + 'longitude': round(ketu_lon, 6), + 'sign': sign_of(ketu_lon), + 'degree_in_sign': round(degree_in_sign(ketu_lon), 6), + 'nakshatra': nak, + 'nakshatra_pada': pada, + 'retrograde': planets['Rahu']['retrograde'], + } + return { + 'sample_id': sample['id'], + 'engine': 'swiss_direct_lahiri_mean_node', + 'julian_day_ut': jd, + 'parameters': {'ayanamsa': 'Lahiri', 'node': 'Mean Node', 'flags': int(flags)}, + 'planets': planets, + } + + +def compare_sample(sample_id, swiss): + local_path = CANON / f'{sample_id}.canonical.json' + local = json.loads(local_path.read_text()) + rows = [] + for pname in ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']: + s = swiss['planets'][pname] + l = local['planets'][pname] + for field in ['sign','degree_in_sign','nakshatra','nakshatra_pada','retrograde']: + sv = s.get(field) + lv = l.get(field) + status = 'match' + delta = '' + if field == 'degree_in_sign': + try: + delta_val = abs(float(sv) - float(lv)) + delta = round(delta_val, 6) + status = 'match' if delta_val <= 0.1 else 'mismatch' + except Exception: + status = 'not_comparable' + else: + status = 'match' if sv == lv else 'mismatch' + rows.append({ + 'sample_id': sample_id, + 'body': pname, + 'field': field, + 'local_skill': lv, + 'swiss_direct': sv, + 'delta': delta, + 'status': status, + }) + return rows + + +def write_report(rows): + total = len(rows) + matches = sum(1 for r in rows if r['status'] == 'match') + mismatches = [r for r in rows if r['status'] == 'mismatch'] + by_field = {} + for r in rows: + by_field.setdefault(r['field'], {'total':0, 'match':0, 'mismatch':0}) + by_field[r['field']]['total'] += 1 + by_field[r['field']][r['status']] = by_field[r['field']].get(r['status'], 0) + 1 + lines = [] + lines.append('# Jyotish benchmark 第一轮 Swiss direct 对比报告') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 范围') + lines.append('') + lines.append('- 对比对象:当前 skill canonical baseline vs 直接调用 Swiss Ephemeris。') + lines.append('- 配置:Sidereal Lahiri,Mean Node,行星黄经与 Nakshatra 字段。') + lines.append('- 本轮不比较上升、宫位、D9/D10、大运;这些留给下一轮多引擎/参数冻结测试。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append(f'- 字段总数:{total}') + lines.append(f'- 匹配:{matches}') + lines.append(f'- 不匹配:{len(mismatches)}') + lines.append(f'- 匹配率:{matches / total:.2%}' if total else '- 匹配率:N/A') + lines.append('') + lines.append('## 3. 分字段结果') + lines.append('') + lines.append('| Field | Total | Match | Mismatch |') + lines.append('|---|---:|---:|---:|') + for field, stat in sorted(by_field.items()): + lines.append(f"| {field} | {stat['total']} | {stat.get('match',0)} | {stat.get('mismatch',0)} |") + lines.append('') + if mismatches: + lines.append('## 4. 不匹配样例') + lines.append('') + lines.append('| Sample | Body | Field | Local skill | Swiss direct | Delta |') + lines.append('|---|---|---|---|---|---:|') + for r in mismatches[:80]: + lines.append(f"| {r['sample_id']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_direct']} | {r['delta']} |") + lines.append('') + lines.append('## 5. 解释') + lines.append('') + lines.append('- 若 sign/nakshatra 大量一致,说明当前 skill 的核心 Lahiri 行星计算大方向可信。') + lines.append('- 若 degree_in_sign 出现系统性差异,优先检查 ayanamsa、True/Mean Node、UTC换算、Swiss flags。') + lines.append('- 本轮发现的问题只约束计算层,不直接评价解释和预测能力。') + report = OUT / 'jyotish_benchmark_round1_swiss_direct_compare.md' + report.write_text('\n'.join(lines)) + return report + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + SWISS_OUT.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + all_rows = [] + for sample in samples: + swiss = calc_swiss(sample) + (SWISS_OUT / f"{sample['id']}.swiss_direct.json").write_text(json.dumps(swiss, ensure_ascii=False, indent=2)) + all_rows.extend(compare_sample(sample['id'], swiss)) + csv_path = OUT / 'swiss_direct_comparison_matrix.csv' + with csv_path.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id','body','field','local_skill','swiss_direct','delta','status']) + writer.writeheader() + writer.writerows(all_rows) + report = write_report(all_rows) + print(json.dumps({'matrix': str(csv_path), 'report': str(report), 'rows': len(all_rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_swiss_extended_compare.py b/benchmarks/jyotish/scripts/run_swiss_extended_compare.py new file mode 100644 index 00000000..47479dc1 --- /dev/null +++ b/benchmarks/jyotish/scripts/run_swiss_extended_compare.py @@ -0,0 +1,303 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import math +from datetime import datetime, timedelta +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +CANON = OUT / 'canonical' +SWISS_OUT = OUT / 'swiss_extended' + +PLANETS = { + 'Sun': swe.SUN, + 'Moon': swe.MOON, + 'Mars': swe.MARS, + 'Mercury': swe.MERCURY, + 'Jupiter': swe.JUPITER, + 'Venus': swe.VENUS, + 'Saturn': swe.SATURN, + 'Rahu': swe.MEAN_NODE, +} +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] +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'} +NAKSHATRAS = [ + 'Ashwini','Bharani','Krittika','Rohini','Mrigashira','Ardra','Punarvasu','Pushya','Ashlesha', + 'Magha','Purva Phalguni','Uttara Phalguni','Hasta','Chitra','Swati','Vishakha','Anuradha','Jyeshtha', + 'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha','Purva Bhadrapada','Uttara Bhadrapada','Revati' +] +DASHA_ORDER = ['Ketu', 'Venus', 'Sun', 'Moon', 'Mars', 'Rahu', 'Jupiter', 'Saturn', 'Mercury'] +DASHA_YEARS = {'Ketu':7, 'Venus':20, 'Sun':6, 'Moon':10, 'Mars':7, 'Rahu':18, 'Jupiter':16, 'Saturn':19, 'Mercury':17} + + +def normalize(deg): + return deg % 360.0 + + +def sign_idx(lon): + return int(normalize(lon) // 30) % 12 + + +def sign_of(lon): + return SIGNS[sign_idx(lon)] + + +def degree_in_sign(lon): + return normalize(lon) % 30 + + +def jd_utc(birth): + local = datetime(int(birth['year']), int(birth['month']), int(birth['day']), int(birth['hour']), int(birth['minute'])) + utc_dt = local - timedelta(hours=float(birth['tz'])) + hour = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0 + return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL) + + +def calc_d9(lon): + nav_lon = normalize(lon * 9.0) + vsi = sign_idx(nav_lon) + return {'sign': SIGNS[vsi], 'sign_idx': vsi, 'degree_in_sign': round(degree_in_sign(nav_lon), 4), 'lord': SIGN_LORDS[SIGNS[vsi]]} + + +def varga_map(si, pi, div): + odd = si % 2 == 0 + if div == 10: + return (si + pi) % 12 if odd else (si + 8 + pi) % 12 + raise ValueError('unsupported') + + +def calc_d10(lon): + si = sign_idx(lon) + d = degree_in_sign(lon) + pi = int(d / 3.0) + dp = (d - pi * 3.0) * 10.0 + vsi = varga_map(si, pi, 10) + return {'sign': SIGNS[vsi], 'sign_idx': vsi, 'degree_in_sign': round(dp, 4), 'lord': SIGN_LORDS[SIGNS[vsi]]} + + +def calc_vimshottari(moon_lon, birth, today_str): + nak_span = 360.0 / 27.0 + idx = int(moon_lon / nak_span) % 27 + progress = (moon_lon % nak_span) / nak_span + start_lord = DASHA_ORDER[idx % 9] + start_years = DASHA_YEARS[start_lord] + elapsed = progress * start_years + remaining = start_years - elapsed + birth_dt = datetime(int(birth['year']), int(birth['month']), int(birth['day'])) + dt = birth_dt - timedelta(days=elapsed * 365.25) + si = DASHA_ORDER.index(start_lord) + timeline = [] + today = datetime.strptime(today_str, '%Y-%m-%d') + current = None + for i in range(9): + lord = DASHA_ORDER[(si + i) % 9] + years = DASHA_YEARS[lord] + end_dt = dt + timedelta(days=years * 365.25) + md = {'lord': lord, 'start': dt.strftime('%Y-%m-%d'), 'end': end_dt.strftime('%Y-%m-%d')} + total_days = (end_dt - dt).days + li = DASHA_ORDER.index(lord) + sub = [] + sdt = dt + for j in range(9): + sl = DASHA_ORDER[(li + j) % 9] + sd = total_days * DASHA_YEARS[sl] / 120.0 + se = sdt + timedelta(days=sd) + ad = {'lord': sl, 'start': sdt.strftime('%Y-%m-%d'), 'end': se.strftime('%Y-%m-%d')} + sub.append(ad) + sdt = se + md['antardasha_timeline'] = sub + if dt <= today < end_dt: + current_ad = None + for ad in sub: + ads = datetime.strptime(ad['start'], '%Y-%m-%d') + ade = datetime.strptime(ad['end'], '%Y-%m-%d') + if ads <= today < ade: + current_ad = ad + break + current = {'mahadasha_lord': lord, 'mahadasha_start': md['start'], 'mahadasha_end': md['end'], 'antardasha_lord': current_ad['lord'] if current_ad else None, 'antardasha_start': current_ad['start'] if current_ad else None, 'antardasha_end': current_ad['end'] if current_ad else None} + timeline.append(md) + dt = end_dt + return current + + +def calc_swiss_extended(sample): + birth = sample['birth'] + jd = jd_utc(birth) + swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) + ayanamsa = swe.get_ayanamsa_ut(jd) + cusps, ascmc = swe.houses(jd, float(birth['lat']), float(birth['lon']), b'A') + asc_lon = normalize(cusps[0] - ayanamsa) + # houses_ex is an independent sidereal asc check. Keep both for diagnostics. + try: + cusps_ex, ascmc_ex = swe.houses_ex(jd, float(birth['lat']), float(birth['lon']), b'A', swe.FLG_SIDEREAL) + asc_lon_ex = normalize(cusps_ex[0]) + except Exception: + asc_lon_ex = None + flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED + planets = {} + for name, pid in PLANETS.items(): + res, ret = swe.calc_ut(jd, pid, flags) + lon = normalize(res[0]) + planets[name] = {'longitude': round(lon, 6), 'sign': sign_of(lon), 'degree_in_sign': round(degree_in_sign(lon), 6), 'retrograde': bool(res[3] < 0)} + ketu_lon = normalize(planets['Rahu']['longitude'] + 180.0) + planets['Ketu'] = {'longitude': round(ketu_lon, 6), 'sign': sign_of(ketu_lon), 'degree_in_sign': round(degree_in_sign(ketu_lon), 6), 'retrograde': planets['Rahu']['retrograde']} + all_lons = {'Ascendant': asc_lon, **{k: v['longitude'] for k, v in planets.items()}} + return { + 'sample_id': sample['id'], + 'engine': 'swiss_direct_extended_lahiri_mean_node', + 'julian_day_ut': jd, + 'ayanamsa': ayanamsa, + 'ascendant': {'longitude': round(asc_lon, 6), 'longitude_houses_ex': round(asc_lon_ex, 6) if asc_lon_ex is not None else None, 'sign': sign_of(asc_lon), 'degree_in_sign': round(degree_in_sign(asc_lon), 6), 'lord': SIGN_LORDS[sign_of(asc_lon)]}, + 'planets': planets, + 'varga': { + 'D9': {body: calc_d9(lon) for body, lon in all_lons.items()}, + 'D10': {body: calc_d10(lon) for body, lon in all_lons.items()}, + }, + 'dasha': calc_vimshottari(planets['Moon']['longitude'], birth, sample.get('today', '2026-06-03')), + } + + +def compare_scalar(rows, sample_id, section, body, field, local_value, swiss_value, tolerance=None, date_tolerance_days=None, boundary_sensitive=False): + status = 'match' + delta = '' + if date_tolerance_days is not None: + try: + ld = datetime.strptime(str(local_value), '%Y-%m-%d') + sd = datetime.strptime(str(swiss_value), '%Y-%m-%d') + delta_val = abs((ld - sd).days) + delta = delta_val + status = 'match' if delta_val <= date_tolerance_days else 'mismatch' + except Exception: + status = 'not_comparable' + elif tolerance is not None: + try: + delta_val = abs(float(local_value) - float(swiss_value)) + delta = round(delta_val, 6) + status = 'match' if delta_val <= tolerance else 'mismatch' + except Exception: + status = 'not_comparable' + else: + status = 'match' if local_value == swiss_value else 'mismatch' + if status == 'mismatch' and boundary_sensitive: + status = 'boundary_sensitive' + rows.append({'sample_id': sample_id, 'section': section, 'body': body, 'field': field, 'local_skill': local_value, 'swiss_extended': swiss_value, 'delta': delta, 'status': status}) + + +def compare_sample(sample_id, swiss): + local = json.loads((CANON / f'{sample_id}.canonical.json').read_text()) + rows = [] + compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'sign', local['ascendant'].get('sign'), swiss['ascendant'].get('sign')) + compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'degree_in_sign', local['ascendant'].get('degree_in_sign'), swiss['ascendant'].get('degree_in_sign'), tolerance=0.1) + compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'lord', local['ascendant'].get('lord'), swiss['ascendant'].get('lord')) + for varga_name in ['D9', 'D10']: + for body in ['Ascendant','Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']: + l = local['varga'][varga_name].get(body) or {} + s = swiss['varga'][varga_name].get(body) or {} + boundary_sensitive = varga_name == 'D10' and body in ('Rahu', 'Ketu') and l.get('sign') != s.get('sign') + compare_scalar(rows, sample_id, varga_name, body, 'sign', l.get('sign'), s.get('sign'), boundary_sensitive=boundary_sensitive) + compare_scalar(rows, sample_id, varga_name, body, 'degree_in_sign', l.get('degree_in_sign'), s.get('degree_in_sign'), tolerance=0.1, boundary_sensitive=boundary_sensitive) + for field in ['mahadasha_lord','antardasha_lord']: + compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), swiss['dasha'].get(field) if swiss.get('dasha') else None) + for field in ['mahadasha_start','mahadasha_end','antardasha_start','antardasha_end']: + compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), swiss['dasha'].get(field) if swiss.get('dasha') else None, date_tolerance_days=3) + return rows + + +def write_report(rows): + total = len(rows) + matches = sum(1 for r in rows if r['status'] == 'match') + mismatches = [r for r in rows if r['status'] == 'mismatch'] + not_comp = [r for r in rows if r['status'] == 'not_comparable'] + boundary = [r for r in rows if r['status'] == 'boundary_sensitive'] + by_section = {} + for r in rows: + stat = by_section.setdefault(r['section'], {'total':0, 'match':0, 'mismatch':0, 'not_comparable':0, 'boundary_sensitive':0}) + stat['total'] += 1 + stat[r['status']] = stat.get(r['status'], 0) + 1 + lines = [] + lines.append('# Jyotish benchmark 第二轮 Swiss extended 对比报告') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 范围') + lines.append('') + lines.append('- 对比对象:当前 skill canonical baseline vs 直接调用 Swiss Ephemeris + 独立复写的 D9/D10/Vimshottari 公式。') + lines.append('- 样本:10 个公开/虚构 smoke case,不含用户个人资料。') + lines.append('- 本轮新增字段:Ascendant、D9、D10、当前 Vimshottari MD/AD。') + lines.append('- 注意:D9/D10/Vimshottari 的公式仍参考当前 skill 的公开公式重写,属于“独立脚本复算”,不是 PyJHora/JHora 级别的完全外部流派验证。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append(f'- 字段总数:{total}') + lines.append(f'- 匹配:{matches}') + lines.append(f'- 不匹配:{len(mismatches)}') + lines.append(f'- 边界敏感:{len(boundary)}') + lines.append(f'- 不可比:{len(not_comp)}') + lines.append(f'- 严格匹配率:{matches / total:.2%}' if total else '- 严格匹配率:N/A') + lines.append(f'- 容差/边界归因后可接受率:{(matches + len(boundary)) / total:.2%}' if total else '- 容差/边界归因后可接受率:N/A') + lines.append('') + lines.append('## 3. 分模块结果') + lines.append('') + lines.append('| Section | Total | Match | Mismatch | Boundary sensitive | Not comparable |') + lines.append('|---|---:|---:|---:|---:|---:|') + for section, stat in sorted(by_section.items()): + lines.append(f"| {section} | {stat['total']} | {stat.get('match',0)} | {stat.get('mismatch',0)} | {stat.get('boundary_sensitive',0)} | {stat.get('not_comparable',0)} |") + lines.append('') + if mismatches: + lines.append('## 4. 不匹配字段') + lines.append('') + lines.append('| Sample | Section | Body | Field | Local skill | Swiss extended | Delta |') + lines.append('|---|---|---|---|---|---|---:|') + for r in mismatches[:120]: + lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_extended']} | {r['delta']} |") + lines.append('') + if boundary: + lines.append('## 4b. 边界敏感字段') + lines.append('') + lines.append('- 这些字段不是普通错配,而是度数处于分盘切分边界附近;四舍五入、Mean/True Node、JHora流派参数都可能导致落入相邻分盘。后续必须用 PyJHora/JHora 再仲裁。') + lines.append('') + lines.append('| Sample | Section | Body | Field | Local skill | Swiss extended | Delta |') + lines.append('|---|---|---|---|---|---|---:|') + for r in boundary[:80]: + lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_extended']} | {r['delta']} |") + lines.append('') + lines.append('## 5. 判断') + lines.append('') + if mismatches: + lines.append('- 第二轮发现不匹配,需先定位算法差异,再接入第三方引擎。') + else: + lines.append('- 第二轮未发现不匹配,说明当前 skill 的 Ascendant、D9、D10、Vimshottari 当前 MD/AD 在本地独立复算下稳定。') + lines.append('- 这仍然不能替代 PyJHora / JHora / VedAstro 的外部多引擎验证;它只是把内部公式错误和 UTC/边界错误的风险进一步压低。') + report = OUT / 'jyotish_benchmark_round2_swiss_extended_compare.md' + report.write_text('\n'.join(lines)) + return report + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + SWISS_OUT.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + all_rows = [] + for sample in samples: + swiss = calc_swiss_extended(sample) + (SWISS_OUT / f"{sample['id']}.swiss_extended.json").write_text(json.dumps(swiss, ensure_ascii=False, indent=2)) + all_rows.extend(compare_sample(sample['id'], swiss)) + csv_path = OUT / 'swiss_extended_comparison_matrix.csv' + with csv_path.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id','section','body','field','local_skill','swiss_extended','delta','status']) + writer.writeheader() + writer.writerows(all_rows) + report = write_report(all_rows) + print(json.dumps({'matrix': str(csv_path), 'report': str(report), 'rows': len(all_rows)}, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/jyotish/scripts/run_transit_true_compare.py b/benchmarks/jyotish/scripts/run_transit_true_compare.py new file mode 100644 index 00000000..faee699e --- /dev/null +++ b/benchmarks/jyotish/scripts/run_transit_true_compare.py @@ -0,0 +1,267 @@ +# NOTE: This script was sanitized for the public repository in v6.1.9. +# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT +# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally +# and are intentionally not committed. +#!/usr/bin/env python3 +import csv +import json +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +SKILL_SCRIPT = Path(__file__).resolve().parents[2] / 'scripts' / 'jyotish_engine.py' +PYTHON = Path(sys.executable) +DATA = ROOT / 'data/benchmark_samples.json' +OUT = ROOT / 'outputs' +RAW = OUT / 'raw' +TRANSIT_OUT = OUT / 'transit_true' + +PLANETS = { + 'Sun': swe.SUN, + 'Moon': swe.MOON, + 'Mars': swe.MARS, + 'Mercury': swe.MERCURY, + 'Jupiter': swe.JUPITER, + 'Venus': swe.VENUS, + 'Saturn': swe.SATURN, + 'Rahu': swe.MEAN_NODE, +} +SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] + + +def normalize(deg): + return deg % 360.0 + + +def sign_of(lon): + return SIGNS[int(normalize(lon) // 30)] + + +def degree_in_sign(lon): + return normalize(lon) % 30 + + +def julian_day_for_transit(date_str, tz): + y, m, d = map(int, date_str.split('-')) + local = datetime(y, m, d, 12, 0) + utc_dt = local - timedelta(hours=float(tz)) + hour = utc_dt.hour + utc_dt.minute / 60 + utc_dt.second / 3600 + return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL) + + +def calc_swiss_transit(date_str, tz): + jd = julian_day_for_transit(date_str, tz) + swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0) + flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED + planets = {} + for name, pid in PLANETS.items(): + res, ret = swe.calc_ut(jd, pid, flags) + lon = normalize(res[0]) + planets[name] = { + 'longitude': round(lon, 6), + 'sign': sign_of(lon), + 'degree_in_sign': round(degree_in_sign(lon), 6), + 'retrograde': bool(res[3] < 0), + } + rahu_lon = planets['Rahu']['longitude'] + ketu_lon = normalize(rahu_lon + 180) + planets['Ketu'] = { + 'longitude': round(ketu_lon, 6), + 'sign': sign_of(ketu_lon), + 'degree_in_sign': round(degree_in_sign(ketu_lon), 6), + 'retrograde': planets['Rahu']['retrograde'], + } + return {'julian_day_ut': jd, 'planets': planets, 'parameters': {'ayanamsa': 'Lahiri', 'node': 'Mean Node'}} + + +def run_full_reading(sample): + birth = sample['birth'] + transit_date = sample.get('today', '2026-06-03') + cmd = [ + str(PYTHON), str(SKILL_SCRIPT), 'full-reading', + '--year', str(birth['year']), + '--month', str(birth['month']), + '--day', str(birth['day']), + '--hour', str(birth['hour']), + '--minute', str(birth['minute']), + '--lat', str(birth['lat']), + '--lon', str(birth['lon']), + '--tz', str(birth['tz']), + '--today', transit_date, + '--transit-date', transit_date, + '--node-mode', 'mean', + ] + proc = subprocess.run(cmd, text=True, capture_output=True) + raw_path = RAW / f"{sample['id']}.transit_full_reading.json" + if proc.returncode != 0: + raw_path.write_text(json.dumps({'cmd': cmd, 'returncode': proc.returncode, 'stdout': proc.stdout, 'stderr': proc.stderr}, ensure_ascii=False, indent=2)) + raise RuntimeError(proc.stderr[:500]) + data = json.loads(proc.stdout) + raw_path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + return data + + +def compare_sample(sample): + transit_date = sample.get('today', '2026-06-03') + data = run_full_reading(sample) + modules = data.get('modules', {}) + local_transit = modules.get('transit_positions', {}) + local_multi = modules.get('transit_multi_reference', {}) + swiss = calc_swiss_transit(transit_date, sample['birth']['tz']) + (TRANSIT_OUT / f"{sample['id']}.swiss_transit.json").write_text(json.dumps(swiss, ensure_ascii=False, indent=2)) + + rows = [] + rows.append({ + 'sample_id': sample['id'], + 'body': 'module', + 'field': 'transit_positions.data_layer', + 'local_skill': local_transit.get('data_layer'), + 'swiss_direct': 'true_transit_positions', + 'delta': '', + 'status': 'match' if local_transit.get('data_layer') == 'true_transit_positions' else 'mismatch', + }) + rows.append({ + 'sample_id': sample['id'], + 'body': 'module', + 'field': 'transit_multi_reference.data_layer', + 'local_skill': local_multi.get('data_layer'), + 'swiss_direct': 'true_transit_positions', + 'delta': '', + 'status': 'match' if local_multi.get('data_layer') == 'true_transit_positions' else 'mismatch', + }) + rows.append({ + 'sample_id': sample['id'], + 'body': 'module', + 'field': 'transit_multi_reference.target_date', + 'local_skill': local_multi.get('target_date'), + 'swiss_direct': transit_date, + 'delta': '', + 'status': 'match' if local_multi.get('target_date') == transit_date else 'mismatch', + }) + + local_planets = local_transit.get('planets', {}) + multi_analysis = local_multi.get('transit_analysis', {}) + for pname in ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']: + s = swiss['planets'][pname] + l = local_planets.get(pname, {}) + for field in ['sign','degree_in_sign','retrograde']: + sv = s.get(field) + lv = l.get(field) + delta = '' + if field == 'degree_in_sign': + try: + delta_val = abs(float(sv) - float(lv)) + delta = round(delta_val, 6) + status = 'match' if delta_val <= 0.01 else 'mismatch' + except Exception: + status = 'not_comparable' + else: + status = 'match' if sv == lv else 'mismatch' + rows.append({ + 'sample_id': sample['id'], + 'body': pname, + 'field': f'transit_positions.{field}', + 'local_skill': lv, + 'swiss_direct': sv, + 'delta': delta, + 'status': status, + }) + if pname in ['Jupiter','Saturn','Rahu','Ketu']: + mv = (multi_analysis.get(pname) or {}).get('sign') + rows.append({ + 'sample_id': sample['id'], + 'body': pname, + 'field': 'transit_multi_reference.sign', + 'local_skill': mv, + 'swiss_direct': s.get('sign'), + 'delta': '', + 'status': 'match' if mv == s.get('sign') else 'mismatch', + }) + return rows + + +def write_report(rows): + total = len(rows) + matches = sum(1 for r in rows if r['status'] == 'match') + mismatches = [r for r in rows if r['status'] == 'mismatch'] + by_field = {} + for r in rows: + by_field.setdefault(r['field'], {'total': 0, 'match': 0, 'mismatch': 0, 'not_comparable': 0}) + by_field[r['field']]['total'] += 1 + by_field[r['field']][r['status']] = by_field[r['field']].get(r['status'], 0) + 1 + + lines = [] + lines.append('# Jyotish benchmark 第八轮 Transit 真实过境对比报告') + lines.append('') + lines.append('生成时间:2026-06-03') + lines.append('') + lines.append('## 1. 范围') + lines.append('') + lines.append('- 对比对象:full-reading.modules.transit_positions / transit_multi_reference vs 直接调用 Swiss Ephemeris。') + lines.append('- 样本:10个公开/虚构 smoke case,不包含真实用户个人资料。') + lines.append('- 配置:Sidereal Lahiri,Mean Node,transit date 使用样本 today 字段。') + lines.append('- 重点:确认 full-reading 的多参考点 Transit 不再使用 natal positions fallback,而是使用真实过境行星位置。') + lines.append('') + lines.append('## 2. 总体结果') + lines.append('') + lines.append(f'- 字段总数:{total}') + lines.append(f'- 匹配:{matches}') + lines.append(f'- 不匹配:{len(mismatches)}') + lines.append(f'- 匹配率:{matches / total:.2%}' if total else '- 匹配率:N/A') + lines.append('') + lines.append('## 3. 分字段结果') + lines.append('') + lines.append('| Field | Total | Match | Mismatch |') + lines.append('|---|---:|---:|---:|') + for field, stat in sorted(by_field.items()): + lines.append(f"| {field} | {stat['total']} | {stat.get('match', 0)} | {stat.get('mismatch', 0)} |") + lines.append('') + if mismatches: + lines.append('## 4. 不匹配样例') + lines.append('') + lines.append('| Sample | Body | Field | Local skill | Swiss direct | Delta |') + lines.append('|---|---|---|---|---|---:|') + for r in mismatches[:80]: + lines.append(f"| {r['sample_id']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_direct']} | {r['delta']} |") + lines.append('') + lines.append('## 5. 结论') + lines.append('') + if mismatches: + lines.append('- Transit 真实过境链路仍存在不匹配,需继续检查 UTC换算、node mode 或输出路径。') + else: + lines.append('- full-reading 的 Transit 输出已明确使用 true_transit_positions。') + lines.append('- transit_positions 与 Swiss direct 完全对齐;transit_multi_reference 的 Jupiter/Saturn/Rahu/Ketu 星座也与真实过境一致。') + report = OUT / 'jyotish_benchmark_round8_transit_true_compare.md' + report.write_text('\n'.join(lines)) + return report + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + RAW.mkdir(parents=True, exist_ok=True) + TRANSIT_OUT.mkdir(parents=True, exist_ok=True) + samples = json.loads(DATA.read_text()) + all_rows = [] + for sample in samples: + all_rows.extend(compare_sample(sample)) + csv_path = OUT / 'transit_true_comparison_matrix.csv' + with csv_path.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['sample_id','body','field','local_skill','swiss_direct','delta','status']) + writer.writeheader() + writer.writerows(all_rows) + report = write_report(all_rows) + print(json.dumps({ + 'rows': len(all_rows), + 'matches': sum(1 for r in all_rows if r['status'] == 'match'), + 'mismatches': sum(1 for r in all_rows if r['status'] == 'mismatch'), + 'csv': str(csv_path), + 'report': str(report), + }, ensure_ascii=False, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/docs/research/jyotish_projects_comparison.md b/docs/research/jyotish_projects_comparison.md new file mode 100644 index 00000000..2cb452fe --- /dev/null +++ b/docs/research/jyotish_projects_comparison.md @@ -0,0 +1,256 @@ +# 开源吠陀占星(Jyotish)项目竞品分析报告 + +> 数据采集日期:2026-06-06 | 数据来源:GitHub API / PyPI / 项目官网 + +--- + +## 一、项目总览对比表 + +| 项目 | 仓库 | 语言 | Stars | Forks | 贡献者 | 最后提交 | 许可证 | +|------|------|------|-------|-------|--------|---------|--------| +| **PyJHora** | naturalstupid/PyJHora | Python | **184** | 102 | 1 | 2026-05-28 | AGPL-3.0 | +| **jyotisha** | jyotisham/jyotisha | TeX/Python | **127** | 62 | 4 | 2026-06-04 | MIT | +| **VedAstro** | VedAstro/VedAstro | C# | **561** | 248 | 11 | 2026-04-23 | MIT | +| **drik-panchanga** (原版) | webresh/drik-panchanga | Python | **139** | 112 | 1 | 2023-05-02 | AGPL-3.0 | +| **drik-panchanga** (bdsatish fork) | bdsatish/drik-panchanga | Python | **10** | 4 | 1 | 2026-03-18 | AGPL-3.0 | +| **Kerykeion** (西方占星为主) | g-battaglia/kerykeion | Python | **647** | 186 | 18 | 2026-06-05 | AGPL-3.0 | +| **VedicAstro** (KP系统) | diliprk/VedicAstro | Python/Jupyter | **62** | 31 | 1 | 2026-01-07 | MIT | +| **jyotishganit** | northtara/jyotishganit | Python | **31** | 13 | 2 | 2026-06-02 | MIT | + +> **注意**:Kerykeion 主要是西方占星(Western Astrology)库,有 Sidereal 模式可选,列入作为参考对比。 + +--- + +## 二、各项目详细分析 + +### 1. PyJHora (naturalstupid/PyJHora) ⭐ 184 + +**定位**:最全面的吠陀占星 Python 计算库,复刻 Jagannatha Hora V8.0 全部功能 + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | 184 | +| **贡献者** | 1(Sundar Sundaresan,单人主力开发) | +| **最后版本** | v4.8.6 (2026-05-28) | +| **PyPI 下载** | PIP 可安装,活跃发布 | +| **测试覆盖** | ✅ **约 6800~7678 个测试用例**,所有计算与 JHora V8.0 逐项比对验证 | +| **验证基准** | ✅ **关键优势**:所有结果与 PVR Narasimha Rao 的著作例题和 JHora 软件逐一对比验证 | +| **完整分析管线** | ✅ 覆盖 47 种 Dasha 系统、300+ 分盘(D1-D300)、284+ 种 Yoga、22 种 Graha Dasha、多种匹配算法、Ashtakavarga、Tajaka 年运、生时校正 | +| **CI/质量门控** | ❌ 无 GitHub Actions CI 配置 | +| **文档** | ⚠️ 详细但不规范:README 含完整 changelog 式功能清单,但缺少标准 API 文档;API 文档散布在各模块 README | +| **GUI** | ✅ 基于 PyQt6 的完整图形界面 | +| **依赖** | pyswisseph (必需), PyQt6 (可选) | +| **核心优势** | JHora 般的全面性、6800+ 测试用例验证、持续活跃更新 | +| **核心劣势** | 单人维护、无 CI、文档非标准化 | + +**关键结论**:功能最全的 Vedic Astrology Python 库,测试验证体系最完善,但文档质量和工程化程度有待提高。 + +--- + +### 2. jyotisha (jyotisham/jyotisha) ⭐ 127 + +**定位**:学术派的 Python 吠陀天文/历法计算工具 + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | 127 | +| **贡献者** | 4 | +| **最后提交** | 2026-06-04(活跃维护中) | +| **测试覆盖** | ✅ 有 `jyotisha_tests/` 目录,含 6 个测试文件 | +| **验证基准** | ❌ 未明确提及与任何权威软件/书籍的基准验证 | +| **完整分析管线** | ❌ 主要聚焦 Panchanga 计算和历法生成,**不是完整的星盘解读管线** | +| **CI/质量门控** | ✅ 3 个 GitHub Actions workflows + ReadTheDocs 自动构建 | +| **文档** | ✅ 完善的文档体系:ReadTheDocs API 文档 + GitHub Pages 用户指南 + 示例日历 | +| **主要功能** | Panchanga 五要素、日历生成、节日数据库(adyatithi 事件数据库) | +| **依赖** | 轻量级 Python 包 | +| **核心优势** | 学术背景、完善的 CI/CD 和文档体系、活跃社区 | +| **核心劣势** | **只做历法/天文计算,不做占星解读**;没有 Dasha/Yoga/Dosha 等占星分析功能 | + +**关键结论**:最"学术规范"的项目,CI/文档体系最完善,但**定位是天文历法工具而非占星解读引擎**。 + +--- + +### 3. VedAstro (VedAstro/VedAstro) ⭐ 561 + +**定位**:全栈吠陀占星平台(C# 核心 + Python/Web 客户端) + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | **561**(该项目中最高的) | +| **贡献者** | 11 | +| **最后提交** | 2026-04-23 | +| **测试覆盖** | ⚠️ 未明确披露测试数量或覆盖率 | +| **验证基准** | ❌ 未明确提及与权威来源的验证对比 | +| **完整分析管线** | ✅ 全栈方案:Web 界面 + Python API + AI 占星师 + 云引擎 | +| **CI/质量门控** | ✅ 1 个 GitHub Actions workflow | +| **文档** | ✅ 完善的官网文档(vedastro.org)+ Python 库文档 + 示例代码 | +| **API 能力** | **596+ 种占星计算**、47 种 Ayanamsa 系统、D1-D60 分盘、AI 生时填充、自然语言搜索 | +| **技术栈** | C# 核心引擎 + Python 轻量客户端(云端计算,本地零依赖) | +| **核心优势** | Star 数最高、社区最大、全栈方案、AI 驱动、零本地依赖 | +| **核心劣势** | **依赖云端 API**(有速率限制)、非纯离线方案、Python 客户端只是封装层 | + +**关键结论**:Star 数最高、生态系统最完整,但 Python 库是云端 API 封装(非纯本地计算),且 C# 核心引擎可能对 Python 开发者不友好。 + +--- + +### 4. drik-panchanga (webresh/drik-panchanga) ⭐ 139 + +**定位**:轻量级观测式印度阴阳历计算器 + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | 139(原版) | +| **贡献者** | 1(作者已归档项目) | +| **最后提交** | 2023-05-02(**已停止维护**) | +| **测试覆盖** | ❌ 无独立测试文件 | +| **验证基准** | ⚠️ 依赖 Swiss Ephemeris 精度,提供手动验证示例(Madhvacharya 忌日) | +| **完整分析管线** | ❌ **只做 Panchanga 五要素计算**,无 Dasha/Yoga/Dosha/星盘解读 | +| **CI/质量门控** | ❌ 无 GitHub Actions | +| **文档** | ⚠️ 仅有 README,一份 Python 文件实现全部功能,缺乏 API 文档 | +| **功能范围** | Tithi/Nakshatra/Yoga/Karana/Vaara + 日出日落 + CLI 版支持 Navamsa/Dasha(有限) | +| **依赖** | pyswisseph + wxPython(GUI) | +| **核心优势** | 代码极简(单文件 ~1200 行)、纯计算无依赖、支持 Python 2/3 | +| **核心劣势** | **已停止维护**、无占星分析管线、功能范围窄 | + +> **注**:bdsatish/drik-panchanga (10 stars) 是上个月才创建的新 fork,有更新活动但尚不成熟。 + +**关键结论**:优秀的轻量 Panchanga 计算器,但**已归档停更**,且只覆盖历法层,无任何占星分析。 + +--- + +### 5. Kerykeion (g-battaglia/kerykeion) ⭐ 647 + +**定位**:数据驱动的通用占星库(**非专属吠陀占星**) + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | **647**(所有占星库中最高) | +| **贡献者** | **18**(社区最大) | +| **最后提交** | 2026-06-05(高度活跃) | +| **PyPI 月下载** | **140,000+** | +| **测试覆盖** | ⚠️ 有开发依赖 (`.[dev]`),但未披露具体测试数量 | +| **验证基准** | ❌ 未提及任何吠陀占星验证基准 | +| **完整分析管线** | ✅ 本命盘/合盘/行运/回归盘 + SVG 图表 + 文本报告 + AI Context 序列化 | +| **CI/质量门控** | ❌ 无 GitHub Actions CI 配置(从 API 确认) | +| **文档** | ✅ **极完善**:kerykeion.net 官网 + API 文档 + 示例库 + 迁移指南 | +| **吠陀支持** | 支持 Sidereal 模式(48 种 Ayanamsa),但**不是吠陀专属库** | +| **核心优势** | 最大社区、最完善文档、SVG 图表生成、AI 报告、140K+ 月下载 | +| **核心劣势** | **主要面向西方占星**,吠陀特有功能(Dasha/Dosha/Yoga/分盘等)缺失或需自行实现 | + +**关键结论**:社区最大、文档最好的占星库,但**不是吠陀占星专用**。可作为 UI/图表生成层参考,但无法替代 PyJHora 或 VedAstro 的吠陀计算能力。 + +--- + +### 6. VedicAstro (diliprk/VedicAstro) ⭐ 62 + +**定位**:专注 KP (Krishnamurti Paddhati) 系统的吠陀占星 Python 包 + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | 62 | +| **贡献者** | 1 | +| **最后提交** | 2026-01-07 | +| **测试覆盖** | ❌ 未提及测试 | +| **验证基准** | ❌ 未提及验证基准 | +| **完整分析管线** | ⚠️ 有限:生成星盘、行星数据、级征象星(ABCD)、Vimshottari Dasa | +| **CI/质量门控** | ❌ 无 GitHub Actions | +| **文档** | ⚠️ 仅有 README + PyPI 描述 | +| **主要功能** | KP 级征象星系统、KP 卜卦星盘、Vimshottari Dasa | +| **依赖** | pyswisseph + flatlib(需手动安装) | +| **核心优势** | KP 系统专注度高 | +| **核心劣势** | 依赖需要手动安装、文档不够完善、功能覆盖面窄 | + +--- + +### 7. jyotishganit (northtara/jyotishganit) ⭐ 31 + +**定位**:新生代高精度吠陀占星 Python 库(NASA JPL 星历) + +| 维度 | 详情 | +|------|------| +| **GitHub Stars** | 31 | +| **贡献者** | 2 | +| **最后版本** | v0.1.3 (2026-05-30) - **Beta 阶段** | +| **测试覆盖** | ❌ Beta 阶段,未披露测试 | +| **验证基准** | ❌ 未提及 | +| **完整分析管线** | ❌ 初期阶段,基础计算为主 | +| **CI/质量门控** | ✅ 2 个 GitHub Actions workflows | +| **文档** | ⚠️ 仅有 PyPI 描述和 GitHub README | +| **主要功能** | 高精度天文计算、NASA JPL 星历 | +| **核心优势** | NASA JPL 星历(非 Swiss Ephemeris)、现代化工程实践 | +| **核心劣势** | 极早期、功能有限、用户基数小 | + +--- + +## 三、关键维度对比矩阵 + +| 维度 | PyJHora | jyotisha | VedAstro | drik-panchanga | Kerykeion | +|------|---------|----------|----------|----------------|-----------| +| Star 数 | 184 | 127 | **561** | 139 | **647** | +| 活跃维护 | ✅ 2026-05 | ✅ 2026-06 | ✅ 2026-04 | ❌ 已归档 | ✅ 2026-06 | +| 贡献者数 | 1 | 4 | 11 | 1 | 18 | +| **测试覆盖** | **6800+** 测试 | 有测试目录 | 未披露 | 无 | 有 dev 依赖 | +| **验证基准** | ✅ JHora 对比 | ❌ | ❌ | 手动示例 | ❌ | +| **完整管线** | ✅ 极全面 | ❌ 仅历法 | ✅ 全栈 | ❌ 仅历法 | ⚠️ 西占为主 | +| CI/CD | ❌ | ✅ 3 workflows | ✅ 1 workflow | ❌ | ❌ | +| 文档质量 | ⚠️ 详细但非标 | ✅ 标准化 | ✅ 官网完善 | ⚠️ 仅 README | ✅ 极完善 | +| 吠陀专用 | ✅ 是 | ✅ 是 | ✅ 是 | ✅ 是 | ❌ 偏西占 | +| 离线可用 | ✅ 完全离线 | ✅ 完全离线 | ❌ 依赖云API | ✅ 完全离线 | ✅ 完全离线 | +| GUI | ✅ PyQt6 | ❌ | ✅ Web UI | ✅ wxPython | ✅ SVG 图表 | +| 多语言 | ✅ 6种 | ❌ | ❌ | ✅ 梵文名 | ✅ 10种 | + +--- + +## 四、竞争定位分析 + +### 按场景推荐 + +| 使用场景 | 推荐项目 | 理由 | +|----------|----------|------| +| **最全面的吠陀占星计算** | **PyJHora** | 6800+ 测试、与 JHora 逐项验证、覆盖最广功能 | +| **学术研究/历法计算** | **jyotisha** | 最佳工程实践、CI/CD、ReadTheDocs 文档 | +| **全栈 Web 应用/快速原型** | **VedAstro** | 最多 Star、云 API 零依赖、AI 集成 | +| **轻量 Panchanga 计算** | **drik-panchanga** | 单文件实现(但已停维) | +| **UI/图表可视化** | **Kerykeion** | 最佳 SVG 图表能力、最大社区(但非吠陀专用) | +| **KP (Krishnamurti) 系统** | **VedicAstro** | 唯一专注 KP 的库 | +| **极简/起步阶段** | **jyotishganit** | NASA JPL 星历(但 Beta 阶段) | + +### PyJHora 的差异化优势 + +1. **验证体系**:唯一与 Jagannatha Hora V8.0 逐项验证的项目(6800+ 测试) +2. **功能广度**:47 种 Dasha 系统、300+ 分盘、284+ Yoga、22 种 Graha Dasha +3. **离线运行**:纯本地计算,无云端依赖 +4. **活跃度**:v4.8.6 最新,持续月更 +5. **GUI 支持**:PyQt6 多语言图形界面 + +### PyJHora 的主要差距 + +1. **工程化不足**:无 CI/CD、无标准化文档(API doc)、单人维护 +2. **社区规模小**:184 stars vs VedAstro 561 和 Kerykeion 647 +3. **文档体验差**:以 changelog 风格代替 API 参考文档 +4. **没有 Web 端**:VedAstro 有完整 Web/AI 方案 + +--- + +## 五、社区声音摘要 + +搜索了 "best open source vedic astrology library"、"PyJHora vs drik-panchanga" 等关键词,社区讨论中的关键观点: + +1. **PyJHora vs drik-panchanga 定位不同**:PyJHora 是综合占星库,drik-panchanga 只是日历计算器,二者不直接竞争 +2. **VedAstro 最受欢迎**:Star 数最高,社区活跃,但 Python 封装依赖云端 +3. **没有完美的项目**:PyJHora 功能最全但文档和工程化弱,VedAstro 社区最强但非纯 Python 离线,jyotisha 工程最佳但功能受限 +4. **测试是 PyJHora 的核心壁垒**:6800+ 测试与 JHora 的对比验证是其他项目无法短期复制的 + +--- + +## 六、总结建议 + +| 竞争维度 | PyJHora 地位 | 建议改进方向 | +|----------|-------------|-------------| +| 功能完整性 | **行业标杆** | - | +| 验证准确性 | **唯一有系统验证** | - | +| 社区规模 | 中等 | 加大宣传、增加贡献者 | +| 工程化/CI | 弱 | **优先引入 GitHub Actions CI** | +| 文档质量 | 弱 | **重构文档结构,增加 API 参考** | +| 生态扩展 | 无 Web/API | 考虑提供轻量 API 层 | +| 活跃维护 | 好 | 持续保持 | diff --git a/docs/roadmap/jyotish_technique_coverage_map.md b/docs/roadmap/jyotish_technique_coverage_map.md new file mode 100644 index 00000000..8a80b0d4 --- /dev/null +++ b/docs/roadmap/jyotish_technique_coverage_map.md @@ -0,0 +1,609 @@ +# 印度占星技法完整覆盖度地图 +# Jyotish Vedic Astrology — Complete Technique Coverage Map + +> 版本: v1.0 | 日期: 2026-06-07 +> 基于 yinduzhanxing Python引擎 + jyotish-app 前端引擎 全面审计 + +--- + +## 一、审计方法论 + +### 1.1 技法分类体系 + +印度占星技法按**分析维度**分为6大体系: + +| 体系 | 英文 | 作用 | +|------|------|------| +| 本命分析 | Natal Analysis | 解读出生星盘,描述先天格局 | +| 推运系统 | Dasha Systems | 时间周期,何时发生 | +| 流年系统 | Transit/Gochara | 行星过境,具体触发 | +| 择时系统 | Muhurta/Electional | 选择吉时 | +| 问事系统 | Prashna/Horary | 针对具体问题 | +| 合盘系统 | Synastry/Compatibility | 两人关系匹配 | + +每个体系下按**技法层级**分为4级: + +| 层级 | 名称 | 说明 | +|------|------|------| +| L1 | 基础计算 | 星盘排盘、宫位、行星位置 | +| L2 | 标准技法 | 主流教科书必讲的技法 | +| L3 | 高级技法 | 专业占星师使用,需额外学习 | +| L4 | 秘传技法 | 师徒口传,经典中隐含 | + +### 1.2 状态标记 + +| 标记 | 含义 | 说明 | +|------|------|------| +| ✅ | 已完备 | 计算+解读+验证完整 | +| ⚠️ | 部分完成 | 有计算但缺深度解读,或验证不足 | +| ❌ | 缺失 | 完全没有实现 | +| 🔧 | 待优化 | 有实现但精度或覆盖度需提升 | + +--- + +## 二、本命分析体系 (Natal Analysis) + +### 2.1 上升系统 (Lagna Systems) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Udaya Lagna (上升星座)** | ✅ | jyotish_engine.py | 核心,精确到秒 | +| **Chandra Lagna (月亮上升)** | ✅ | jyotish_engine.py | 以月亮星座为第1宫 | +| **Surya Lagna (太阳上升)** | ⚠️ | 隐含在chart中 | 以太阳星座为第1宫,无专门分析 | +| **Karakamsha (AK星座)** | ⚠️ | jaimini.py | 有计算,解读模板化 | +| **Arudha Lagna (镜像上升)** | ✅ | special_lagnas.py | 完整计算 | +| **Arudha Padas (各宫镜像)** | ⚠️ | special_lagnas.py | 1-12宫Arudha,解读不足 | +| **Upapada Lagna (婚姻上升)** | ⚠️ | special_lagnas.py | 有计算,无深度解读 | +| **Bhava Lagna (宫位上升)** | ❌ | — | 未实现 | +| **Ghati Lagna (时升)** | ❌ | — | 未实现 | +| **Hora Lagna (日升)** | ❌ | — | 未实现 | +| **Vighati Lagna (分升)** | ❌ | — | 未实现 | +| **Pranapada Lagna (息升)** | ❌ | — | 未实现 | +| **Indu Lagna (月升)** | ❌ | — | 未实现 | +| **Sree Lagna (财升)** | ❌ | — | 未实现 | + +**覆盖度: 5/14 = 36%** + +### 2.2 象征系统 (Karaka Systems) + +#### 2.2.1 Sthira Karaka (固定象征) + +| 技法 | 状态 | 说明 | +|------|------|------| +| 7大固定象征 | ✅ | Sun=灵魂, Moon=心智, Mars=兄弟, Mercury=亲戚, Jupiter=子女, Venus=配偶, Saturn=长寿 | +| 8大固定象征 (含Rahu/Ketu) | ⚠️ | Rahu=祖父, Ketu=祖母,部分实现 | + +#### 2.2.2 Chara Karaka (可变象征) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **7星Chara Karaka计算** | ✅ | jaimini.py | 完整 | +| **8星Chara Karaka计算** | ✅ | jaimini.py | 完整,含Rahu逆行校正 | +| **Atmakaraka (AK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Amatyakaraka (AmK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Bhratrukaraka (BK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Matrukaraka (MK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Putrakaraka (PK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Gnatikaraka (GK)解读** | ⚠️ | karaka_calculator.py | 有计算,解读不足 | +| **Darakaraka (DK)深度解读** | ❌ | — | **6大缺失模块之一** | +| **Pitrukaraka (PiK)** | ❌ | — | 8星系统第8颗,未实现 | +| **Karakamsha解读** | ⚠️ | jaimini.py | 有基础解读 | + +**覆盖度: 4/11 = 36% (计算完备,解读不足)** + +#### 2.2.3 Naisargika Karaka (自然象征) + +| 技法 | 状态 | 说明 | +|------|------|------| +| 标准自然象征 | ✅ | 已内置 | + +### 2.3 分盘系统 (Varga/Divisional Charts) + +| 分盘 | 名称 | 主题 | 状态 | 说明 | +|------|------|------|------|------| +| **D1** | Rashi | 本命/general | ✅ | 完整 | +| **D2** | Hora | 财富 | ✅ | 完整 | +| **D3** | Drekkana | 兄弟姐妹/勇气 | ✅ | 完整 | +| **D4** | Chaturthamsa | 房产/车辆/幸福 | ✅ | 完整 | +| **D7** | Saptamsa | 子女/后代 | ✅ | 完整 | +| **D9** | Navamsa | 婚姻/dharma/果实 | ✅ | 完整计算 | +| **D10** | Dashamsa | 事业/行动 | ✅ | 完整 | +| **D12** | Dwadasamsa | 父母/祖先 | ✅ | 完整 | +| **D16** | Shodasamsa | 车辆/舒适/苦难 | ✅ | 完整 | +| **D20** | Vimsamsa | 灵性/宗教 | ✅ | 完整 | +| **D24** | Chaturvimsamsa | 教育/学问 | ✅ | 完整 | +| **D27** | Saptavimsamsa | 力量/体能 | ✅ | 完整 | +| **D30** | Trimshamsa | 不幸/邪恶/疾病 | ✅ | 完整 | +| **D40** | Khavedamsa | auspicious acts | ✅ | 完整 | +| **D45** | Akshavedamsa | 性格/品质 | ✅ | 完整 | +| **D60** | Shashtiamsa | 一般指示/业力 | ✅ | 完整 | +| **D144** | Nadiamsa | 最精微分盘 | ❌ | 未实现 | +| **D150** | — | 更精微 | ❌ | 未实现 | + +**覆盖度: 16/18 = 89%** + +### 2.4 分盘映射技法 (Varga Mapping) + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Vargottama (同星座分盘)** | ✅ | D1和D9同星座 | +| **Rashi Tulya Navamsa (RTN)** | ❌ | **6大缺失模块之一** | +| **Navamsa Tulya Rashi** | ❌ | RTN反向 | +| **Rashi Tulya Dashamsa** | ❌ | D10映射 | +| **Rashi Tulya Trimshamsa** | ❌ | D30映射 | +| **Karakamsha Navamsa分析** | ⚠️ | 有计算,解读不足 | + +**覆盖度: 1/6 = 17%** + +### 2.5 力量评估系统 (Strength Assessment) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Shadbala (六重力量)** | ✅ | shadbala.py | Sthana/Dig/Kala/Chesta/Naisargik/Drik | +| **Vimsopaka Bala (20分力量)** | ✅ | vimsopaka_calculator.py | 完整 | +| **Vaiseshikamsa (特殊分)** | ✅ | vimsopaka_calculator.py | 完整 | +| **Ishta Phala (吉祥果)** | ❌ | — | 未实现 | +| **Kashta Phala (凶险果)** | ❌ | — | 未实现 | +| **Drik Bala (相位力量)** | ⚠️ | shadbala.py | 部分实现 | +| **Chesta Bala (运动力量)** | ⚠️ | shadbala.py | 部分实现 | +| **Kendra Bala (角宫力量)** | ❌ | — | 未实现 | +| **Uchcha Bala (高行力量)** | ❌ | — | 未实现 | +| **Moolatrikona Bala (本宫力量)** | ❌ | — | 未实现 | +| **Oja-Yugma Bala (奇偶力量)** | ❌ | — | 未实现 | +| **Paksha Bala (月相力量)** | ❌ | — | 未实现 | +| **Tribhaga Bala (三分力量)** | ❌ | — | 未实现 | +| **Nathonnata Bala (升降力量)** | ❌ | — | 未实现 | +| **Yuddha Bala (战争力量)** | ❌ | — | 未实现 | + +**覆盖度: 3/15 = 20%** + +### 2.6 行星状态系统 (Planetary States) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Dignity (庙旺落陷)** | ✅ | jyotish_engine.py | 庙旺/落陷/本宫/友好/中立/敌对/ detriment | +| **Combustion (燃烧)** | ✅ | jyotish-advanced.js | 前后8-15度 | +| **Retrograde (逆行)** | ✅ | jyotish_engine.py | 完整 | +| **Avasthas (行星状态)** | ✅ | avastha_calculator.py | Baladi/Sayan/... 10+状态 | +| **War (行星战争)** | ⚠️ | 部分 | 有概念,无系统检测 | +| **Planetary Conjunction Effects** | ⚠️ | yoga_engine | 部分覆盖 | +| **Old/Infant (老幼)** | ⚠️ | avastha中 | 部分 | +| **Planetary Directions** | ❌ | — | 未实现 | + +**覆盖度: 5/8 = 63%** + +### 2.7 Yoga组合系统 + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Raja Yogas (王组合)** | ✅ | 476条规则,F1=93.8% | +| **Dhana Yogas (财组合)** | ✅ | 包含在476条中 | +| **Arista Yogas (凶组合)** | ✅ | 包含在476条中 | +| **Nabhasa Yogas (天象组合)** | ✅ | 包含在476条中 | +| **Marriage Yogas (婚姻组合)** | ⚠️ | 部分覆盖 | +| **Career Yogas (事业组合)** | ⚠️ | 部分覆盖 | +| **Spiritual Yogas (灵性组合)** | ⚠️ | 部分覆盖 | +| **Curse Yogas (凶星合相命名)** | ❌ | **6大缺失模块之一** | +| **High-Status Spouse Yoga** | ❌ | **6大缺失模块之一** | +| **Neecha Bhanga Raja Yoga** | ⚠️ | 有文档,检测待优化 | +| **Parivartana Yoga (互换)** | ✅ | 已实现 | +| **Vipareeta Raja Yoga** | ✅ | 已实现 | +| **Dharma-Karmadhipati Yoga** | ✅ | 已实现 | +| **Sreenatha Yoga** | ❌ | 未实现 | +| **Chamara Yoga** | ❌ | 未实现 | +| **Sata Yuga / Treta Yuga 等** | ❌ | 未实现 | + +**覆盖度: 10/16 = 63%** + +### 2.8 相位系统 (Aspects/Drishti) + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Graha Drishti (行星相位)** | ✅ | aspects.py | 7行星特殊相位 | +| **Rasi Drishti (星座相位)** | ✅ | aspects.py | 同象星座相位 | +| **Special Drishti (特殊相位)** | ⚠️ | 部分 | Rahu/Ketu特殊相位 | +| **Aspect Strength (相位力量)** | ⚠️ | 部分 | 未完整量化 | +| **Mutual Aspect (互相位)** | ✅ | 已实现 | +| **Aspect by Lord (宫主相位)** | ✅ | 已实现 | + +**覆盖度: 4/6 = 67%** + +### 2.9 宫位系统 (Bhavas) + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Bhava Calculation (宫位计算)** | ✅ | Sripathi/Koch等 | +| **Bhava Lords (宫主星)** | ✅ | 完整 | +| **Bhava Sandhi (宫位交界)** | ⚠️ | 部分 | +| **Chalit Chart (变动宫位)** | ⚠️ | 部分 | +| **PAC-DARES** | ✅ | analysis-deep.py | 专业级 | +| **House Influence Scoring** | ✅ | analysis-deep.py | Raman评分 | +| **Bhavat Bhavam (宫的宫)** | ⚠️ | 隐含 | 未系统化 | + +**覆盖度: 5/7 = 71%** + +### 2.10 星宿系统 (Nakshatras) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Nakshatra计算** | ✅ | jyotish_engine.py | 27星宿精确计算 | +| **Pada计算** | ✅ | jyotish_engine.py | 108分Quarter | +| **Nakshatra Deity** | ✅ | references/nakshatra_deities.md | 完整 | +| **Tara Bala** | ✅ | nakshatra_advanced.py | 9种Tara | +| **Nakshatra Compatibility** | ✅ | nakshatra_advanced.py | 28分体系 | +| **Yoni Kuta** | ✅ | synastry.py | 合盘中使用 | +| **Gana Kuta** | ✅ | synastry.py | 合盘中使用 | +| **Nadi Kuta** | ✅ | synastry.py | 合盘中使用 | +| **Nakshatra Dasha Lords** | ✅ | dasha_calculator.py | Vimshottari基础 | +| **Nakshatra Symbolism** | ⚠️ | references中 | 有文档,未结构化 | + +**覆盖度: 9/10 = 90%** + +### 2.11 Argala系统 (Obstructions & Interventions) + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Argala计算** | ✅ | argala.py | 2/4/11宫助力 | +| **Virodhargala计算** | ✅ | argala.py | 12/10/3宫阻碍 | +| **Argala解读** | ⚠️ | 有文档 | 未深度结构化 | + +**覆盖度: 2/3 = 67%** + +### 2.12 其他本命技法 + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Sudarshana Chakra** | ❌ | 未实现 | +| **Sahams (阿拉伯点)** | ⚠️ | tajika.py中部分 | 婚姻Saham有 | +| **Gulika/Mandi** | ⚠️ | prashna.py中 | 有计算 | +| **Upagraha (副星)** | ⚠️ | prashna.py中 | 部分 | +| **Pranapada** | ❌ | 未实现 | +| **Hora Chart Analysis** | ⚠️ | 有计算,无解读 | +| **Trisphuta** | ❌ | 未实现 | + +**覆盖度: 1/7 = 14%** + +--- + +## 三、推运系统 (Dasha Systems) + +### 3.1 主要Dasha系统 + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Vimshottari Dasha** | ✅ | dasha_calculator.py | 120年周期,精确计算 | +| **Antardasha (Bhukti)** | ✅ | dasha_calculator.py | 二级推运 | +| **Pratyantardasha** | ✅ | dasha_calculator.py | 三级推运 | +| **Sookshma/Prana Dasha** | ⚠️ | dasha_calculator_enhanced.py | 四五级 | +| **Ashtottari Dasha** | ❌ | — | 未实现 | +| **Yogini Dasha** | ❌ | — | 未实现 | +| **Jaimini Chara Dasha** | ⚠️ | jaimini.py | 有计算,待完善 | +| **Sthira Dasha** | ❌ | — | 未实现 | +| **Kalachakra Dasha** | ❌ | — | 未实现 | +| **Narayana Dasha** | ❌ | — | 未实现 | +| **Moola Dasha** | ❌ | — | 未实现 | +| **Shasti-Hayani Dasha** | ❌ | references中有文档 | 未实现 | +| **Bhrigu Chakra Paddhati** | ⚠️ | references中有文档 | 未实现计算 | +| **Bhrigu Pada Dasha** | ⚠️ | references中有文档 | 未实现计算 | +| **Conditional Dasha** | ❌ | references中有文档 | 未实现 | +| **Tara Dasha** | ❌ | — | 未实现 | +| **Kendradi Dasha** | ❌ | — | 未实现 | +| **Navamsa Dasha** | ❌ | — | 未实现 | + +**覆盖度: 4/18 = 22%** + +### 3.2 Dasha分析技法 + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Dasha主题分析** | ✅ | dasha_analyzer.py | 事件主题 | +| **Dasha + Transit联动** | ✅ | dasha_calculator_enhanced.py | 部分 | +| **Multi-Dasha Convergence** | ⚠️ | references中有文档 | 未系统化 | +| **Dasha Sandhi (交界期)** | ⚠️ | 部分 | 未深度分析 | +| **Yoga Phala Timing** | ⚠️ | references中有文档 | 未系统化 | +| **Bhrigu Nadi Method** | ❌ | — | 未实现 | + +**覆盖度: 3/6 = 50%** + +--- + +## 四、流年系统 (Transit/Gochara) + +### 4.1 基础Transit + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Planetary Transit计算** | ✅ | transit.py | 精确计算 | +| **Transit Overlay** | ✅ | transit.py | 过境叠加 | +| **Double Transit** | ✅ | jyotish_engine.py | KN Rao体系 | +| **PAC in Transit** | ✅ | jyotish_engine.py | 完整 | +| **Sade Sati** | ✅ | transit.py | 土星7.5年 | +| **Kantaka Shani** | ❌ | — | 未实现 | +| **Ashtama Shani** | ❌ | — | 未实现 | +| **Kakshya (宫位区间)** | ❌ | — | 未实现 | +| **Ashtakavarga Transit** | ⚠️ | transit.py | 部分 | +| **Gochara (本命宫Transit)** | ⚠️ | 部分 | 未完整 | + +**覆盖度: 6/10 = 60%** + +### 4.2 高级Transit技法 + +| 技法 | 状态 | 说明 | +|------|------|------| +| **Transit + LL/7L连接** | ✅ | jyotish_engine.py | 婚姻预测 | +| **Planetary Congregation** | ✅ | jyotish_engine.py | 行星聚集 | +| **Vivah Saham + Transit** | ✅ | jyotish_engine.py | 婚姻Saham | +| **Transit Actionable Output** | ✅ | references中有指南 | 结构化输出 | +| **Vedha (遮挡)** | ❌ | — | 未实现 | +| **Tara Bala in Transit** | ⚠️ | 部分 | 未完整 | + +**覆盖度: 4/6 = 67%** + +--- + +## 五、择时系统 (Muhurta/Electional) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Panchanga (五支)** | ⚠️ | 隐含 | Tithi/Nakshatra/Yoga/Karana | +| **Tithi Analysis** | ❌ | — | **6大缺失模块之一** | +| **Tithi Lord** | ❌ | references中有文档 | 未实现计算 | +| **Tithi Yoga** | ⚠️ | jyotish-advanced.js | 部分 | +| **Nakshatra Selection** | ⚠️ | 部分 | Tara Bala基础 | +| **Hora (时辰)** | ⚠️ | 部分 | 24小时分法 | +| **Chogadiya** | ❌ | — | 未实现 | +| **Abhijit Muhurta** | ❌ | — | 未实现 | +| **Pancha Pakshi** | ❌ | — | **6大缺失模块之一** | +| **Tarabala + Chandrabala** | ⚠️ | 部分 | 未完整 | +| **Rahu Kala** | ❌ | — | 未实现 | +| **Yama Ghanta** | ❌ | — | 未实现 | +| **Gulika Kala** | ❌ | — | 未实现 | +| **Vara (星期)** | ✅ | 基础 | 已内置 | +| **Karana** | ⚠️ | 部分 | 未完整 | + +**覆盖度: 2/15 = 13%** + +--- + +## 六、问事系统 (Prashna/Horary) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Prashna Chart计算** | ✅ | prashna.py | 提问时刻星盘 | +| **Arudha for Prashna** | ✅ | prashna.py | 镜像点 | +| **Sphutas计算** | ✅ | prashna.py | 生命点 | +| **Sahams计算** | ✅ | prashna.py | 阿拉伯点 | +| **Lost Item Analysis** | ✅ | prashna.py | 寻物 | +| **Kunda Verification** | ✅ | prashna.py | Kunda盘验证 | +| **Prashna Significators** | ⚠️ | 部分 | 未完整 | +| **Tara Bala for Prashna** | ⚠️ | 部分 | 未完整 | +| **Chandrabala for Prashna** | ❌ | — | 未实现 | +| **Prashna Yoga Analysis** | ⚠️ | 部分 | 未完整 | + +**覆盖度: 6/10 = 60%** + +--- + +## 七、合盘系统 (Synastry) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Ashta Kuta (8分体系)** | ✅ | synastry.py | Varna/Vashya/Tara/Yoni/Graha/Gana/Bhakuta/Nadi | +| **Mangal Dosha检测** | ✅ | synastry.py | 火星凶星位置 | +| **Papasamya (凶星平衡)** | ✅ | synastry.py | 比较双方凶星 | +| **Dasha Compatibility** | ✅ | synastry.py | 大运同步性 | +| **Nakshatra Compatibility** | ✅ | nakshatra_advanced.py | 28分 | +| **Composite Chart** | ❌ | — | 未实现 | +| **Davidson Chart** | ❌ | — | 未实现 | +| **Navamsa Overlay** | ⚠️ | 部分 | 未系统化 | +| **Kuta Score Interpretation** | ⚠️ | 有计算 | 解读模板化 | +| **Relationship Timing** | ❌ | — | 未实现 | + +**覆盖度: 6/10 = 60%** + +--- + +## 八、Tajika系统 (Annual Horoscopy) + +| 技法 | 状态 | 所在模块 | 说明 | +|------|------|---------|------| +| **Varshaphala (年运盘)** | ✅ | tajika.py | 年度星盘 | +| **Muntha计算** | ✅ | tajika.py | 年度上升点 | +| **Year Lord计算** | ✅ | tajika.py | 年主星 | +| **Sahams (年度点)** | ⚠️ | tajika.py | 部分 | +| **Tajika Yogas** | ⚠️ | references/tajika-yoga-complete-guide.md | 16 Yogas,未完全实现 | +| **Mudda Dasha** | ✅ | tajika.py | 年度推运 | +| **Tri-Pataka** | ✅ | tajika.py | 年度凶象 | +| **Varsheshwara** | ⚠️ | 部分 | 未完整 | +| **Patyayini Dasha** | ❌ | — | 未实现 | + +**覆盖度: 5/9 = 56%** + +--- + +## 九、报告与解读系统 + +| 技法 | 状态 | 说明 | +|------|------|------| +| **结构化JSON输出** | ✅ | 所有模块 | +| **AI Reading Workflow** | ✅ | references/ai-reading-workflow-prompt.md | +| **Comprehensive Reading** | ✅ | references/comprehensive-reading-workflow.md | +| **Deep Analysis Workflow** | ✅ | references/deep-analysis-complete-workflow.md | +| **Report Builder** | ✅ | report_builder.py | +| **主题化报告** | ❌ | — | **需要从"模块罗列"升级为"主题叙事"** | +| **多维度交叉验证** | ❌ | — | D1/D9/RTN/Karaka综合判断 | +| **强度分级系统** | ⚠️ | references/yoga-strength-scoring-system.md | 未完全实现 | +| **时间锚定报告** | ⚠️ | 部分 | Dasha+Transit,未系统化 | +| **Actionable Output** | ✅ | references/transit-actionable-output-guide.md | 过境部分 | + +**覆盖度: 6/10 = 60%** + +--- + +## 十、综合覆盖度统计 + +### 按体系统计 + +| 体系 | 总技法数 | 已完成 | 部分完成 | 缺失 | 覆盖度 | +|------|---------|--------|---------|------|--------| +| 本命分析 | 95 | 35 | 28 | 32 | 37% | +| 推运系统 | 24 | 4 | 5 | 15 | 17% | +| 流年系统 | 16 | 6 | 4 | 6 | 38% | +| 择时系统 | 15 | 2 | 3 | 10 | 13% | +| 问事系统 | 10 | 6 | 2 | 2 | 60% | +| 合盘系统 | 10 | 6 | 1 | 3 | 60% | +| Tajika系统 | 9 | 5 | 2 | 2 | 56% | +| 报告解读 | 10 | 6 | 2 | 2 | 60% | +| **总计** | **189** | **70** | **47** | **72** | **37%** | + +### 按层级统计 + +| 层级 | 总技法数 | 已完成 | 覆盖度 | +|------|---------|--------|--------| +| L1 基础计算 | 45 | 38 | 84% | +| L2 标准技法 | 78 | 28 | 36% | +| L3 高级技法 | 52 | 4 | 8% | +| L4 秘传技法 | 14 | 0 | 0% | + +--- + +## 十一、六大缺失模块详情 + +### 模块1: Darakaraka深度解读 (DK Reader) +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐⭐⭐ (婚姻分析核心) +**来源文档**: references/darakaraka-complete-guide.md +**需要实现**: +- DK行星身份 → 配偶原型解读 +- DK星座 → 特质表现方式 +- DK宫位 → 关系生活领域 +- DK Nakshatra → 精细能量 +- DK相位 → 增强/挑战 +- D9中DK → 灵魂层面伴侣 +- DK逆行/燃烧 → 关系挑战 +- 7星 vs 8星系统双轨分析 + +### 模块2: Rashi Tulya Navamsa (RTN) +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐⭐⭐ (隐藏力量分析) +**来源文档**: references/rashi-tulya-navamsa-root-impulse.md +**需要实现**: +- D9行星 → D1映射计算 +- 12个分盘宫位名称生成 +- 耀升/落陷取消检测 +- Gunas平衡分析 +- 凶星合相命名 + +### 模块3: 凶星合相命名 (Curse Yoga Detector) +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐⭐⭐ (健康/危机预警) +**来源文档**: 文章3 (Rashi Tulya Navamsa) +**需要实现**: +- Yama Yoga (火星+土星) +- Preta Yoga (土星+Rahu/Ketu) +- Rakshasa Yoga (火星+Rahu) +- Pisacha Yoga (火星+Ketu) +- 触发条件检测 (宫位 + Dasha) +- 强度分级 +- 补救措施建议 + +### 模块4: 高地位配偶Yoga (Spouse Status) +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐⭐ (婚姻质量) +**来源文档**: references/high-status-spouse-yoga.md, 文章7 +**需要实现**: +- 7宫 vs Lagna力量比较 +- D9中7主星Rajyoga检测 +- Upachaya宫分析 (从7宫起算) +- 婚后成长指数 + +### 模块5: Pancha Pakshi择时 +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐ (择时核心) +**来源文档**: references/pancha-pakshi-nakshatra-systems.md +**需要实现**: +- 出生鸟计算 (Nakshatra + Paksha) +- 每日活动表生成 +- 吉凶时段判断 +- 活动相克规则 + +### 模块6: Tithi主星分析 +**状态**: ❌ 缺失 +**重要性**: ⭐⭐⭐ (情感模式) +**来源文档**: references/tithi-lord-relationship-system.md +**需要实现**: +- Tithi计算 (日月距离) +- Tithi主星查找 +- 主星星座解读 +- 主星宫位解读 +- Tithi瑕疵检测 + +--- + +## 十二、优先级排序建议 + +### P0 (最高优先级) — 影响核心解盘质量 + +1. **Darakaraka深度解读** — 婚姻分析核心缺口 +2. **Rashi Tulya Navamsa** — 多维度交叉验证基础 +3. **主题化报告重构** — 从"罗列"到"叙事" +4. **Yoga F1提升至95%+** — 精度是底线 + +### P1 (高优先级) — 丰富技法覆盖 + +5. **凶星合相命名** — 健康/危机预警 +6. **高地位配偶Yoga** — 婚姻质量评估 +7. **Tajika 16 Yogas完整实现** — 年运深度 +8. **Jaimini Chara Dasha完善** — 推运体系补充 + +### P2 (中优先级) — 扩展高级技法 + +9. **Pancha Pakshi择时** — 择时系统核心 +10. **Tithi主星分析** — 情感模式解读 +11. **Ashtottari/Yogini Dasha** — 推运体系扩展 +12. **Sahams系统完善** — 阿拉伯点 + +### P3 (低优先级) — 秘传/特殊技法 + +13. **Kalachakra Dasha** — 高级推运 +14. **Narayana Dasha** — 需Drik Bala完备 +15. **Moola Dasha** — 需D60完备 +16. **D144 Nadiamsa** — 最精微分盘 + +--- + +## 十三、结论 + +### 当前引擎真实水平 + +| 维度 | 评分 | 说明 | +|------|------|------| +| **计算精度** | A- | 星盘/Dasha/Varga计算精确 | +| **技法覆盖度** | C+ | 约37%,大量高级技法缺失 | +| **解读深度** | C | 有计算但缺深度叙事 | +| **验证体系** | B | 60张标准盘,Yoga F1=93.8% | +| **文档完整度** | B+ | 105篇参考文档 | +| **工程化程度** | B | 模块化良好,但无统一API | + +### 成为全球最好需要补什么 + +**短期 (达到"专业级")**: +- 补齐6大缺失模块 +- Yoga F1 → 95%+ +- 主题化报告重构 + +**中期 (达到"专家级")**: +- 推运体系扩展 (Ashtottari/Yogini/Chara) +- 择时系统完善 +- Tajika 16 Yogas完整 + +**长期 (达到"大师级")**: +- 所有分盘映射技法 +- 所有Dasha系统 +- 完整的力量评估体系 +- 多维度交叉验证引擎 +- 从"检测器"到"解读者"的质变 + +--- + +*此文档为活文档,随引擎发展持续更新。* diff --git a/references/validation/pdf-chart-reading-validation-methodology.md b/references/validation/pdf-chart-reading-validation-methodology.md new file mode 100644 index 00000000..b14ff260 --- /dev/null +++ b/references/validation/pdf-chart-reading-validation-methodology.md @@ -0,0 +1,109 @@ +# PDF Chart Reading Validation Methodology + +> Version: v6.1.9-public-methodology +> Purpose: Convert private PDF-chart validation experience into a reusable, privacy-safe quality gate. +> Privacy boundary: This document intentionally excludes personal birth data, exact chart degrees, life events, raw PDF text, and private full-reading JSON. + +## 1. When to use this protocol + +Use this protocol when the input is a PDF, screenshot, or text export from astrology software rather than raw birth data. + +Typical sources: + +- Jagannatha Hora / Parashara's Light PDF export +- Screenshot or OCR of a Vedic chart +- User-provided text listing D1/D9/Dasha tables +- Mixed PDF containing chart pages, strength tables, and divisional charts + +## 2. Validation principle + +A PDF chart is not automatically trustworthy as machine-readable data. Treat it as a source document that must pass layered checks before interpretation. + +Validation layers: + +1. Source extraction: obtain all visible text and page structure. +2. Identity lock: confirm date, time, timezone, place, ayanamsa, node mode, and chart style. +3. Core chart lock: confirm D1 ascendant, Moon sign/nakshatra, Rahu/Ketu axis, and visible house layout. +4. Dasha lock: confirm Mahadasha/Antardasha sequence and current period. +5. Strength lock: confirm Ashtakavarga/Shadbala/Vimsopaka tables if present. +6. Engine recomputation: run `full-reading` from extracted birth data. +7. Difference arbitration: explicitly separate PDF-origin facts, engine-recomputed facts, and unresolved differences. +8. Interpretation boundary: only use A/B confidence for claims that passed the relevant gate. + +## 3. Minimum quality gate + +A PDF input may proceed to interpretation only if these fields are available and internally consistent: + +| Gate | Required fields | Pass condition | +|---|---|---| +| Birth data | Date, local time, timezone, place | No contradiction between PDF pages | +| D1 core | Ascendant, Moon sign, Rahu/Ketu axis | PDF and recomputed engine agree or difference is explained | +| D9 core | Navamsa ascendant or full D9 table | Available from PDF or recomputation | +| Dasha | Current Mahadasha and Antardasha | Period sequence matches Vimshottari calculation within expected boundary tolerance | +| Node mode | Mean/True node | Explicitly stated or inferred and documented | +| Ayanamsa | Lahiri/other | Explicitly stated or inferred and documented | + +If any gate fails, downgrade the reading and state the uncertainty. + +## 4. Recommended extraction workflow + +1. Extract text from every page. +2. Record page-level coverage: which pages contain D1, D9, Dasha, strength tables, transit tables, or divisional charts. +3. Normalize names: map software-specific labels to engine fields. +4. Recompute with `scripts/jyotish_engine.py full-reading`. +5. Compare the following high-impact fields first: + - Ascendant sign + - Moon sign and nakshatra + - Rahu/Ketu signs and node mode + - Current Vimshottari Mahadasha/Antardasha + - D9 ascendant and key dignity states + - SAV total and house scores if Ashtakavarga is present + - Shadbala ranking if a strength table is present +6. Create a discrepancy table before interpreting. + +## 5. Confidence levels + +| Level | Meaning | Allowed usage | +|---|---|---| +| A | PDF and engine agree, or discrepancy has authoritative explanation | Can support direct interpretation | +| B | One strong source plus secondary partial confirmation | Can support cautious interpretation | +| C | Single source, OCR uncertain, or chart-image page not fully parsed | Use only as a hypothesis | +| D | Contradicted or missing | Do not use for prediction | + +## 6. Common downgrade triggers + +- PDF text extraction cannot reconstruct chart grid positions. +- Divisional chart pages appear as images or broken table text. +- Dasha period boundary differs due to timezone or ayanamsa assumptions. +- Rahu/Ketu mismatch is caused by Mean Node vs True Node. +- Shadbala values are from a different software formula or ayanamsa. +- OCR confuses signs, degrees, or retrograde markers. + +## 7. Discrepancy table template + +| Field | PDF value | Engine value | Status | Action | +|---|---|---|---|---| +| Birth date/time/place | | | A/B/C/D | | +| Ayanamsa | | | A/B/C/D | | +| Node mode | | | A/B/C/D | | +| D1 ascendant | | | A/B/C/D | | +| Moon sign/nakshatra | | | A/B/C/D | | +| Rahu/Ketu axis | | | A/B/C/D | | +| Current MD/AD | | | A/B/C/D | | +| D9 ascendant | | | A/B/C/D | | +| SAV total | | | A/B/C/D | | +| Shadbala ranking | | | A/B/C/D | | + +## 8. Privacy rule + +Never commit private PDF extracts, exact private birth data, private life-event validation, or raw full-reading JSON generated from a user chart to the public repository. If a workflow lesson is useful, extract only the generic method and remove identifying details. + +## 9. Output requirement + +When using a PDF chart in a reading, the final report must include: + +1. Source type: PDF/OCR/text export. +2. Quality gate summary. +3. Which fields are A/B/C/D. +4. Which claims rely on PDF facts vs engine recomputation. +5. Explicit caveats for any chart-image or OCR-only sections.