Merge codex/goel-jaimini-strength-20261022 (#94) into staging
Independent Staging Quality Gate / validate (push) Successful in 15m44s
Independent Staging Quality Gate / publish (push) Successful in 4m27s

# Conflicts:
#	CHANGELOG.md
#	docs/tasks/README.md
This commit is contained in:
Jesse_Chen
2026-10-22 08:53:17 +08:00
13 changed files with 1234 additions and 6 deletions
+5
View File
@@ -26,6 +26,11 @@
- 报告「行星原始位置」表按内容定列宽。星宿的英文名用浅色小字,跟在中文后面同一行。速度写到小数点后 4 位。放不下时表在自己的框里横向滑动。
- Skill 版本不变(BUG-1410)。
## 2026-10-22 — 引擎算出 Goel Jaimini 强度和三个特殊点
- full-reading 与 jaimini 输出增加 Goel 第 1 章完整强度,以及 Brahma、Maheshwara、Rudra。报告正文暂不显示这一章。
- Skill 版本不 bump。
## 2026-10-21 — 合盘时间线写出起止,删掉的模型改用默认模型
- 关系时间线每段标题写出起止年月。第一段从本月开始时,起点写「现在」或 Now。
@@ -0,0 +1,206 @@
上游 `5fb8379c` 原文;我方 Niryaana 选择器在 `scripts/rashi_dashas_native.py`;书稿未入库
# Goel Jaimini public calculation module
## Scope and entry points
`scripts/goel_jaimini_strength.py` publishes the existing frozen runtime's
position-only arithmetic, with input validation and explicit blocked points.
It contains no customer identity, birth record, report, private fixture, or
runtime dependency on a frozen directory. The historical frozen-engine
publication receipt remains true: its complete payload is not published.
Public functions:
- `calculate_goel_strength(planet_longitudes, node_occupancy_policy=...)`
- `build_goel_chart_evidence(planet_longitudes, ascendant_longitude)`
Both accept already calculated sidereal longitudes in degrees, in `[0, 360)`.
Supply exactly Sun, Moon, Mars, Mercury, Jupiter, Venus, Saturn, Rahu and Ketu;
nodes must be opposite. The builder also requires a finite numeric Ascendant.
No ayanamsa, node mode, birth time or ephemeris is inferred. The caller owns
those upstream settings. Invalid inputs raise `ValueError`; equal seven-karaka
degrees block the calculation instead of inventing a ranking.
The native `jaimini --mode special` / `--mode all` commands and `full-reading`
now expose `goel_full_strength` and `goel_special_points`. The builder's
`strength` and `special_points` keys map to those two report keys. Existing
eight-karaka and other Jaimini profiles remain independent.
## Sources and page numbering
V. P. Goel, *Predicting through Jaimini Astrology*, Chapter 1, printed pp10–17
(PDF file pages14–21). Local source:
`V. P. Goel, Predicting through Jaimini Astrology`.
Transcription SHA256:
`0a8eb5e6de409d5d8ae6684be927b1273acdc5fa72a7f1c36c31de33907341a6`.
Line numbers below refer to that transcription, not printed line numbers.
| Calculation | Printed pages | PDF pages | Transcription locator |
|---|---|---|---|
| Dignity values and natural friendship | 10–11 | 14–15 | L461 onward |
| Seven-karaka rank; Kendriya, Kartari, Adhipati | 11–12 | 15–16 | L510–553 |
| Sign permanent and karaka components | 13 | 17 | L558–593 |
| Sthana, aspect roles, sign-lord strength | 14 | 18 | L598–632 |
| Worked planetary/sign totals | 15 | 19 | L636–697 |
| Brahma and Maheshwara; first Rudra rule | 16 | 20 | L701–727 |
| Rudra malefic-contact exception | 17 | 21 | L731–736 |
| Mercury/Jupiter as sign lord: double role credit | 103 | 107 | L4359–4389 |
The p103 clarification occurs in Goel's **short method** chapter. Only its
explicit double-role explanation is used to clarify the three-role reading
of p14. The short method's different weights are NOT mixed into this full
method. Numeric reproduction of p15 also checks this role convention.
Degree-specific Moolatrikona ranges reuse the existing named
`raman_manual_degree_mt_exaltation_first_v1` profile from
`scripts/trikona_raman.py`: B. V. Raman, *A Manual of Hindu Astrology*,
section29, printed p12. Goel provides the score categories; this auxiliary
profile supplies degree ranges. Exaltation is checked first. This is an
explicit method choice, not a claim that all Goel/Raman conventions coincide.
## Formula contract
Planet total is dignity + karaka + kendriya + kartari + adhipati.
- Dignity: exalted60, Moolatrikona45, own30, friend22.5, neutral15,
enemy7.5, debilitated3.75; natural friendship, not compound friendship.
- Seven classical planets ranked by degree within sign: 60,45,30,22.5,15,7.5,3.75.
Nodes never receive these karaka points.
- Kendra / Panaphara / Apoklima from AK: 60 / 30 / 15.
- Kartari: 60 when the planet is in an odd sign and both adjacent signs have
occupants; nodes count in this component under both occupancy policies.
- Adhipati: 60 for a qualifying odd-sign lord with a malefic conjunction or
Jaimini rasi aspect. All classical planets except Moon own an odd sign.
This implementation declares Sun/Mars/Saturn/Rahu/Ketu malefics and excludes
self-contact; phase-dependent Moon and conditioned Mercury are not added.
Sign total is permanent + occupying karaka points + Sthana + aspect roles
+ sign-lord total. Movable/fixed/dual permanent values are15/30/60.
Sthana is zero if empty, otherwise45+15*n. Jupiter, Mercury and sign lord are
three roles worth60 each; if one planet fulfills two roles it can earn120.
Conjunction and aspect qualify a role once, not twice. Aspects are Jaimini
sign aspects, not Parashari planetary aspects.
Special points:
- Brahma first checks AK's eighth lord occupying AK's eighth. Otherwise it
compares Lagna/seventh Goel totals and selects the strongest non-Saturn
6/8/12 lord from the stronger anchor. The Saturn exclusion belongs to the
fallback branch; it is not silently applied to the first override.
- Maheshwara is AK's eighth lord; if exalted, compare AK's twelfth lord.
Saturn is allowed.
- Rudra compares Lagna's second/eighth lords. The weaker candidate wins when
it alone has malefic contact; when both have contact the stronger wins.
The frozen convention treats conjunction and rasi aspect as contact;
the p17 exception itself says “aspect” and its discussion adds association.
Retain this declared convention when porting rather than hiding it.
- Equal final scores remain `blocked`; no silent degree or list-order tie-break.
## Node discrepancy and default
Printed p14 explicitly includes Rahu and Ketu in Sthana. The p15 worked
totals reproduce when they are excluded from **that component only**.
`include_nodes_text` is the default because it follows the explicit rule.
`exclude_nodes_worked_example` is the reproducibility alternative, retained
under `strength.worked_example_node_exclusion` by the builder.
This switch does NOT remove nodes from Kartari or malefic contacts and does
not give them karaka points. For the published example, excluding nodes gives
Virgo281.25 / Pisces363.75; including them gives296.25 /378.75. The other ten
sign totals and all seven planet totals agree between policies. This is an
observed text/table discrepancy, not proof of the author's intended erratum.
## Relation to 9f08b57b and 258e08e3
These are **different algorithms**:
| Field / producer | Comparison | Method / use |
|---|---|---|
| `lagna_seventh_strength` | First/seventh Goel numeric totals | Goel Brahma/Sthira-related explicit selector evidence |
| Historical `niryaana_shoola_strength` | Same first/seventh totals | Deprecated frozen-export alias only |
| `select_niryaana_shoola_start_sign` | Second/eighth | PVR Ayur ordered comparison: occupancy, all-planet rasi aspects, exalted-occupant presence |
`9f08b57b` implements the last row and `258e08e3` explicitly includes both
nodes in its occupancy/aspect counts. Those changes are already on main.
This publication does not replace them. The legacy alias now carries
`deprecated: true`, `comparison: lagna_vs_seventh`, and `not_for` metadata.
Use the canonical new key for Goel totals; use the existing PVR selector for
Niryaana. Sthira's existing explicit-input short selector is a separate
contract: do not label Goel full weights as the Chapter5 short weights, and
do not infer that publishing these points automatically executes Sthira Dasha.
## Portable synthetic example
```python
from scripts.goel_jaimini_strength import build_goel_chart_evidence
positions = dict(Sun=29., Moon=58., Mars=87., Mercury=116.,
Jupiter=145., Venus=174., Saturn=203., Rahu=245., Ketu=65.)
result = build_goel_chart_evidence(positions, 0.)
```
This is deliberately invented positional data, not a real birth chart.
| Planet | Dignity | Karaka | Kendriya | Kartari | Adhipati | Total |
|---|---:|---:|---:|---:|---:|---:|
| Sun | 60 | 60 | 60 | 0 | 0 | 180 |
| Moon | 60 | 45 | 30 | 0 | 0 | 135 |
| Mars | 7.5 | 30 | 15 | 60 | 60 | 172.5 |
| Mercury | 7.5 | 22.5 | 60 | 0 | 0 | 90 |
| Jupiter | 22.5 | 15 | 30 | 60 | 60 | 187.5 |
| Venus | 3.75 | 7.5 | 15 | 0 | 60 | 86.25 |
| Saturn | 60 | 3.75 | 60 | 0 | 0 | 123.75 |
AK=Sun. Lagna=367.5; seventh=225. Brahma=Jupiter; Maheshwara=Mars;
Rudra=Mars. Gemini Sthana=75 (alternative60); Sagittarius Sthana=60
(alternative0). The existing PVR Niryaana selector instead selects Taurus.
`tests/test_goel_jaimini_strength.py` contains these literal expectations,
the printed p15 totals without birth identity, branch checks, input rejection,
blocked points and native entry wiring. Run:
```bash
python3 -m pytest -q tests/test_goel_jaimini_strength.py tests/test_niryaana_shoola_dasha.py
```
Status: source-bounded wired, with one published numerical table replay and
synthetic arithmetic regression. No predictive-case validation, global
closure, PL9 parity or automatic change to a frozen customer pin is claimed.
Verification:166 tests passed across `test_goel_jaimini_strength`,
`test_niryaana_shoola_dasha`, `test_sthira_brahma_goel_short_method`,
`test_trikona_raman_native`, `test_jaimini`, `test_jaimini_source_identity`
and `test_bphs_validation_runtime`. This includes the real full-reading CLI
and its Goel output, as well as the existing PVR Niryaana behavior.
## Current-window exploration and publication boundary
Started from main258e08e3 in an isolated `codex/goel-publication` worktree;
the original workspace was on customer-export-r32-release with unrelated
dirty files, all preserved. Checked all six existing linked worktree paths,
branch/recent-commit status, tracked file inventory, producer/CLI/report/test
searches and the two whole-machine sweep records. WorkBuddy skill is a
symlink to the primary repository; its registry remains a separate authority.
The configured r32 runtime supplies the reviewed position-only Goel module;
its hash was235fc8f90718b6da45af0ee1edc2a7c195be92664ff55bbb7b8e0398b605b33d.
The archived 10-16 publication receipt confirms the full payload was excluded.
The separately inspected local 10-16 r2 engine has neither this named module
nor the two Goel output fields; therefore the archive date must not be used
as proof of that report's actual generating pin. This extraction is from
the inspected r32 source, not a claim that the omitted r2 payload contained it.
Repository APIs/renderers remain consumers of the native engine, not a second
implementation. No external engine code or book pages are republished here.
Pre-work check passed, including external adapter diagnostics and remote
visibility; direct `git ls-remote origin refs/heads/main` returned258e08e3.
The broader governance audit separately flags `main_worktree_dirty` and
`remote_head_unverified`; the former is real and preserved, and the latter
does not certify publication despite the successful direct visibility check.
Capability authority snapshot and governance audit were invoked;
the former's historical customer examples are not authority for this module.
The reused chains are Goel position arithmetic, Raman dignity helpers,
Jaimini rasi aspects, and the existing PVR Niryaana selector. Unclosed items
are lineage differences, equal-strength selections without a source rule and
independent predictive verification. This is code publication, not a chart
interpretation, personal report export, or MEVG predictive validation.
+6
View File
@@ -484,3 +484,9 @@ Prevention: 打分路径新增或改动输入模块时,先查它在不在两
`TASK-report-chart-blank-columns-20261003` T2 写的是「`_map_to_houses_values` 增字段」,`scripts/ashtakavarga.py` 在 `sealed_holdout_rerun.PRODUCTION_FILES` 里,只加字段、分数不变也让 `test_rectification_validation_integrity_gate` 4 条与 `test_sealed_holdout_contract_freshness` 3 条失败(ERR-110 同型)。改为在 `pl9_reader_export.py` 用引擎已有字段拆分,冻结文件零改动,门禁恢复全绿,无需重新冻结。
Prevention: 写任务书时(不只是执行时)就对照 `PRODUCTION_FILES` 与 dataset `frozen_scoring.files`;只为展示的字段优先在消费层(reader / mapper)从已有数据推出,不进冻结文件。关联 ERR-110、BUG-1200。
## ERR-116 | Goel 首/七强度不是 PVR Niryaana 的二/八,完整法也不是简法 | observed 2026-10-22
Goel《Predicting through Jaimini Astrology》第 1 章完整强度的 `lagna_seventh_strength` 比的是命宫和第七宫的 Goel 总分。弃用别名 `niryaana_shoola_strength` 是同一组首/七数字,带 `deprecated`、`replacement`、`not_for`。我方 Niryaana Shoola(#84)的 `select_niryaana_shoola_start_sign` 在 `scripts/rashi_dashas_native.py`,比的是第二宫和第八宫的 PVR Ayur 口径。两套不能互相接入。`scripts/goel_short_strength.py` 是第 5 章简法,给 Sthira / Niryaana 等用,也不是这一套完整法。
Prevention: 不要把 `lagna_seventh_strength` 或 `niryaana_shoola_strength` 接到 Niryaana 选择或大运计算。不要用 `goel_jaimini_strength` 替换 `goel_short_strength`。报告里印这一章交给 #91。关联 issue #94、#84。
@@ -0,0 +1,105 @@
# 进度:Goel Jaimini 强度(issue #94,2026-10-22)
执行分支 `codex/goel-jaimini-strength-20261022`。工作树 `.worktrees/goel-jaimini-strength-20261022`。未 push。Python 用 `G:\Ferti\Jyotisha\.worktrees\agent-loop\.venv\Scripts\python.exe`(本工作树没有 `.venv`)。`core.autocrlf=false`。上游只读仓 `G:\Ferti\yinduzhanxing-ref`,提交 `5fb8379c`。
## 做了什么
- T1:四个上游模块放到 `scripts/` 根目录。`ROOT_PY_BUDGET` 448 → 452,常量旁写了三栏。`scripts/*.py` 计数 452。
- T2:测试原样搬入,只把 Niryaana 选择器的 import 改到 `scripts.rashi_dashas_native`。`test_bphs_validation_runtime.py` 新增 `test_full_reading_exposes_goel_jaimini_strength`,用文件里已有的虚构 `BASE_BIRTH_ARGS`。
- T3:`_goel_jaimini_evidence` 接到 `cmd_jaimini`(`mode` 为 `all` 或 `special`)和 full-reading 写入 `modules.jaimini` 之前。
- T4:接线后的口径测试(两种交点政策、七星同度 blocked 且不出 AK / 排序、full-reading 喂给 Goel 的经度)。
- T5:三张固定虚构盘与 200 张固定种子虚构盘。没有让步缩盘:三张盘 full-reading 均值 5.212 秒,200 张 × 交点两种口径约 34.74 分钟,低于 40 分钟,mean 与 true 都跑了。
- T6:研究笔记、ERR-116、CHANGELOG。Skill 版本不 bump。
## 四个 blob
| 文件 | 上游 `5fb8379c` | 落地后 `git hash-object` |
| --- | --- | --- |
| `scripts/goel_jaimini_strength.py` | `2170f781740affbad7b8ff4e048b88d87f997bcc` | `d13b3f785a86a17970e450f111d60fc8d52108ac` |
| `scripts/trikona_raman.py` | `1fc9283396acba98b9113bde7af0e27a80841a30` | 相同 |
| `scripts/sthira_brahma_goel_short_method.py` | `6e010b54f5ba480db3e6d8d79e711a1cc2d59fbf` | 相同 |
| `scripts/trikona_dasha.py` | `e6c8a27cf5e23e5c686154406ba5183226d12d79` | 相同 |
`goel_jaimini_strength.py` 相对上游的 diff 只有 `SOURCE` 一行,LF。旧值是上游本地书稿路径,没有入库。新值是 `SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'`。
测试文件上游 blob `e29c47182c03c2277cff46e271a7f664c3349601`。相对上游先只改一行:
```
-from scripts.niryaana_shoola_dasha import select_niryaana_shoola_start_sign
+from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign
```
T4 在这个文件末尾另加了三条接线测试,不再是单行 diff。
## 接线与上游的两处差异
1. 捕获 `(ImportError, KeyError, TypeError, ValueError)` 时 `logger.warning` 记下异常类和信息,`reason` 写成 `f'{exc.__class__.__name__}: {exc}'`。这几类失败在 helper 内返回 blocked,不会抛进 full-reading 外层的 `except Exception`,因此不会把整个 `modules.jaimini` 带掉。
2. `cmd_jaimini` 传给 Goel 的经度用 `pd.get('degree_raw', pd['degree'])`。模拟盘只有 `degree` 时回退,上游那条特殊点测试仍过。full-reading 本来的 `planet_lons` 已是 `degree_raw`,原样传给 Goel。上升点仍用原来的 `asc_deg`(`lon`)。
没有把 `lagna_seventh_strength` / `niryaana_shoola_strength` 接到 Niryaana 选择或大运。没有改冻结文件、报告渲染、API 主文件、数据库。
## T5 数据(全是虚构盘)
三张盘是 `tests/test_report_english_edition.py` 的 day / night / south。`PYTHONHASHSEED=0`。
单独计时的 full-reading:day 6.343 秒、night 4.692 秒、south 4.600 秒,均值 5.212 秒。三张都是 `executed`,Goel AK 与 `chara_karaka_7` 的 AK 都是同一颗星(Venus / Mercury / Jupiter)。四个新键都在。
改前引擎是接线提交的父本 `eb67962d7`。改前改后:
| 检查 | 结果 |
| --- | --- |
| 中文完整数据版 Markdown | 三张 SHA-256 相同(day `1de47056e69f…`,night `0415e1a20dfe…`,south `51eb80c838cf…`) |
| 英文完整数据版 Markdown | 三张 SHA-256 相同(day `3e6eaadae15e…`,night `a36abde746d5…`,south `adef6214ebbb…`) |
| full-reading 与 `cmd_jaimini` 的 jaimini,去掉四个新键 | SHA-256 相同 |
| 改后四个新键 | full-reading 与 `cmd_jaimini`(mode=all)都有,状态 `executed` |
| `build_domain_facts` 选出的对话卡 | 12 个域 × 3 张盘,规范化 JSON 相同 |
`read_full_data_domain` 的原始副本不是逐字节相同。它整份拷贝 `worksheets`,其中 `advanced_systems.jaimini` 多了四个新键;这是任务书已经写明的资料包 JSON 变大,正文不印。另外两边还有运行时噪声:审计耗时、缓存时间、PyJHora 年盘快照、一张盘的 Yogini `reference_date`。去掉四个新键和这些噪声字段后,day / night 还剩 PyJHora 年盘快照差异,south 只剩 `reference_date`。没有为了抹平这些差异去改渲染或对话卡。
200 张固定种子(`Random(20261022)`,年 1940–2015、日 1–28、纬度 −45~60、经度 −120~150、时区按经度取半小时、岁差在 lahiri / raman / kp 间)。mean 与 true 各 200 次,4 个进程,墙钟 556.6 秒。
| 项 | 数 |
| --- | --- |
| 次数 | 400 |
| `goel_full_strength.status == executed` | 400 |
| blocked | 0 |
| AK 与 `chara_karaka_7` 不一致 | 0 |
| 单次 full-reading | 均值 5.537 秒,最小 4.839,最大 7.848 |
| mean / true 均值 | 5.530 / 5.544 秒 |
三张盘改前改后的 full-reading 耗时互相重叠(改前均值 5.672 秒,同一次改后 6.069 秒,另一次改后计时 5.212 秒),没有拆出稳定的增量。
冻结身份交集(`PRODUCTION_FILES` + `RESEARCH_FILES` + `frozen_scoring.files` 对上 `origin/staging...HEAD`)输出是 `[]`。
## 测试
工作树内 `pytest -q`。Windows 默认 GBK 标准输出打不出 full-reading 里已有的 emoji(`U+1F4D0`、`U+1F534`),`test_bphs_validation_runtime.py` 两条因此在子进程退出码 1。下面的数字是 `PYTHONUTF8=1` 再跑的结果。屏蔽 PyJHora 用临时目录的 `sitecustomize.py` 放在 `PYTHONPATH` 最前(`import jhora` 变成 `ModuleNotFoundError`),没有卸载包。
两次都是 238 条收集、237 通过、1 跳过、0 失败。跳过的是 `test_consultation_contract_golden.py` 里未设置 `JYOTISH_LIVE_CONTRACT_GOLDEN=1` 的线上重抓。
| 文件 | 结果 |
| --- | --- |
| `tests/test_goel_jaimini_strength.py` | 22 通过 |
| `tests/test_bphs_validation_runtime.py` | 3 通过 |
| `tests/test_python_hygiene_budget.py` | 2 通过 |
| `tests/test_rashi_dashas_native.py` | 18 通过 |
| `tests/test_niryaana_shoola_dasha.py` | 3 通过 |
| `tests/test_jaimini.py` | 42 通过 |
| `tests/test_runtime_jhora_import_scan.py` | 4 通过 |
| `tests/test_api_server_growth_contract.py` | 9 通过 |
| `tests/test_consultation_contract_golden.py` | 2 通过,1 跳过 |
| `tests/test_sealed_holdout_contract_freshness.py` | 9 通过 |
| `tests/test_rectification_validation_integrity_gate.py` | 22 通过 |
| `tests/test_reported_offset_research.py` | 14 通过 |
| `tests/test_rectification_engine_memoization.py` | 25 通过 |
| `tests/test_repo_privacy_markers.py` | 62 通过(文档提交前再复跑一次) |
没有弱化既有断言。没有重冻,没有改冻结记录或 golden。
## 移交 #91
本单不印报告。交给 #91:
- 报告印「Goel Jaimini 强度与特殊点」章,中英标签齐全。`blocked` 的中文写「无法判定」、英文写「Not determinable」,并注明原因(七星度数相同 / 强度相同无唯一选择)。
- `jyotish_engine.py` 受限资料 `jaimini_special_points.reason` 里「Brahma/Maheshwara/Rudra still lack a same-profile structured producer」随印章一并改。
- 交点两种口径并列印出,标明默认遵循正文(`include_nodes_text`)。
+1 -1
View File
@@ -518,7 +518,7 @@
| `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `codex/report-english-han-leak-20261021` |
| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` |
| `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` |
| `TASK-goel-jaimini-strength-20261022.md` | — | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 待领取(可与 #89 并行;须在 #91 印章前合入) | `codex/goel-jaimini-strength-20261022` |
| `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` |
| `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` |
| `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` |
| `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-20261021.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 待领取 | `codex/synastry-staging-fix-20261021` |
+137
View File
@@ -0,0 +1,137 @@
"""Goel's full Jaimini strength and special-point profile, not Bhava Bala.
Source: Predicting through Jaimini Astrology, printed pp10-17 (PDF pp14-21).
The published p15 example is the numeric regression. Dignity degree ranges
reuse the explicitly named Raman manual profile; no universal lineage claim.
"""
import math
from scripts.trikona_raman import CLASSICAL, DIGNITY_PROFILE, _dignity, _validate_positions
from scripts.narayana_dasha import _rasi_aspects
from scripts.sthira_brahma_goel_short_method import SIGNS, SIGN_LORDS
PROFILE = 'goel_full_jaimini_strength_ch1_v1'
SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'
KARAKA_POINTS = (60, 45, 30, 22.5, 15, 7.5, 3.75)
MALEFICS = ('Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu')
def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include_nodes_text'):
if node_occupancy_policy not in {'include_nodes_text', 'exclude_nodes_worked_example'}:
raise ValueError('explicit Goel node occupancy policy required')
_validate_positions(planet_longitudes)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
if any(math.isclose(degrees[a], degrees[b], rel_tol=0, abs_tol=1e-10)
for i, a in enumerate(ranked) for b in ranked[i + 1:]):
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions', profile_id=PROFILE)
ak = ranked[0]
karakas = dict(zip(ranked, KARAKA_POINTS))
def contacts(sign, candidates):
return [p for p in candidates if signs[p] == sign or _rasi_aspects(signs[p], sign)]
planets = {}
for p in CLASSICAL:
sign = signs[p]
dignity, moola = _dignity(p, planet_longitudes[p])
kendra = (60, 30, 15)[(sign - signs[ak]) % 3]
before = [q for q, s in signs.items() if s == (sign - 1) % 12]
after = [q for q, s in signs.items() if s == (sign + 1) % 12]
kartari = 60 if sign % 2 == 0 and before and after else 0
malefic_contacts = contacts(sign, [q for q in MALEFICS if q != p])
adhipati = 60 if p != 'Moon' and malefic_contacts else 0
planets[p] = dict(sign_idx=sign, degree_in_sign=degrees[p],
dignity=dignity, mooltrikona=moola, karaka=karakas[p], kendriya=kendra,
kartari=kartari, adhipati=adhipati, malefic_contacts=malefic_contacts,
kartari_before=before, kartari_after=after,
total=moola + karakas[p] + kendra + kartari + adhipati)
rows = []
for sign in range(12):
lord = SIGN_LORDS[sign]
occupants = [p for p, s in signs.items() if s == sign]
permanent = (15, 30, 60)[sign % 3]
karaka = sum(karakas.get(p, 0) for p in occupants)
counted = occupants if node_occupancy_policy == 'include_nodes_text' else [p for p in occupants if p in CLASSICAL]
sthana = 45 + 15 * len(counted) if counted else 0
# Three roles, not distinct planets: Goel p103 explicitly explains
# double credit when Mercury/Jupiter is also the sign lord.
aspect_roles = [role for role, p in [('Jupiter', 'Jupiter'), ('Mercury', 'Mercury'), ('lord', lord)]
if contacts(sign, [p])]
aspect = 60 * len(aspect_roles)
rows.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
permanent=permanent, karaka=karaka, sthana=sthana, occupants=occupants, counted_occupants=counted,
aspect=aspect, aspect_roles=aspect_roles, lord_strength=planets[lord]['total'],
total=permanent + karaka + sthana + aspect + planets[lord]['total']))
return dict(status='executed', confidence_status='parameter_sensitive', profile_id=PROFILE,
source=SOURCE, source_locator='PDF pp14-21; printed pp10-17; lines461-736',
dignity_profile=DIGNITY_PROFILE, malefic_profile='Sun_Mars_Saturn_Rahu_Ketu_excluding_self',
node_occupancy_policy=node_occupancy_policy,
aspect_profile='jaimini_rasi_drishti', aspect_role_policy='distinct_roles',
atmakaraka=ak, planets=planets, rashi_strengths=rows,
claim_boundary='Named Goel full method with declared dignity ranges; not PL9 or all-lineage parity.')
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
if (type(ascendant_longitude) not in (int, float)
or not math.isfinite(ascendant_longitude)
or not 0 <= ascendant_longitude < 360):
raise ValueError('ascendant longitude must be a finite number in [0,360)')
strength = calculate_goel_strength(planet_longitudes)
result = dict(strength=strength, special_points={})
if strength['status'] != 'executed':
result['special_points'] = {
name: dict(status='blocked', reason=strength['reason'], profile_id=PROFILE)
for name in ('Brahma', 'Maheshwara', 'Rudra')
}
return result
strength['worked_example_node_exclusion'] = calculate_goel_strength(
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
strength['source_discrepancy'] = (
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
'Both numeric alternatives are preserved; the explicit text is the active profile.')
lagna = int(float(ascendant_longitude) % 360 // 30)
seventh = (lagna + 6) % 12
planets = strength['planets']
rashis = strength['rashi_strengths']
ak_sign = planets[strength['atmakaraka']]['sign_idx']
def choose(candidates, rule):
candidates = list(dict.fromkeys(candidates))
scores = {p: planets[p]['total'] for p in candidates}
winners = [p for p in candidates if scores[p] == max(scores.values())]
return dict(status='executed' if len(winners) == 1 else 'blocked',
value=winners[0] if len(winners) == 1 else None,
candidates=scores, rule=rule,
reason=None if len(winners) == 1 else 'equal_Goel_strength_no_unique_selector',
profile_id=PROFILE, confidence_status='parameter_sensitive',
source=SOURCE, source_locator='PDF pp20-21; printed pp16-17; lines701-736')
eighth_ak = SIGN_LORDS[(ak_sign + 7) % 12]
if planets[eighth_ak]['sign_idx'] == (ak_sign + 7) % 12:
brahma = choose([eighth_ak], 'eighth_lord_from_AK_occupies_eighth_from_AK')
elif rashis[lagna]['total'] == rashis[seventh]['total']:
brahma = dict(status='blocked', reason='equal_Lagna_seventh_Goel_strength', profile_id=PROFILE)
else:
anchor = lagna if rashis[lagna]['total'] > rashis[seventh]['total'] else seventh
brahma = choose([SIGN_LORDS[(anchor + h - 1) % 12] for h in (6, 8, 12)
if SIGN_LORDS[(anchor + h - 1) % 12] != 'Saturn'],
'strongest_6_8_12_lord_from_stronger_Lagna_seventh_excluding_Saturn')
brahma['anchor_sign_idx'] = anchor
maheswara = choose([eighth_ak] + ([SIGN_LORDS[(ak_sign + 11) % 12]]
if planets[eighth_ak]['dignity'] == 'exalted' else []),
'eighth_lord_from_AK_compare_twelfth_if_exalted')
rudra_candidates = list(dict.fromkeys([SIGN_LORDS[(lagna + 7) % 12], SIGN_LORDS[(lagna + 1) % 12]]))
afflicted = [p for p in rudra_candidates if planets[p]['malefic_contacts']]
rudra = choose(afflicted if len(afflicted) == 1 else rudra_candidates,
'stronger_8th_2nd_lord_unless_only_weaker_has_malefic_contact')
result['special_points'] = dict(Brahma=brahma, Maheshwara=maheswara, Rudra=rudra)
result['lagna_seventh_strength'] = dict(lagna=rashis[lagna]['total'],
seventh=rashis[seventh]['total'], profile_id=PROFILE, source=SOURCE,
comparison='lagna_vs_seventh')
# Frozen-export compatibility only. PVR Niryaana is second/eighth Ayur
# comparison; callers must not route these Goel totals into that selector.
result['niryaana_shoola_strength'] = dict(result['lagna_seventh_strength'],
deprecated=True, replacement='lagna_seventh_strength',
not_for='pvr_niryaana_shoola_ayur_rasi_strength_v1')
return result
+39
View File
@@ -14339,6 +14339,39 @@ def cmd_aspects(args):
# ============================================================================
# 17. Jaimini系统(v3.7新增)
# ============================================================================
def _goel_jaimini_evidence(planet_longitudes, ascendant_longitude):
"""Shared CLI/full-reading view of the source-bounded Goel producer.
Failures stay inside this helper. A Goel error must not escape into the
full-reading ``except Exception`` that drops the whole jaimini module.
"""
try:
from scripts.goel_jaimini_strength import build_goel_chart_evidence
goel = build_goel_chart_evidence(planet_longitudes, ascendant_longitude)
fields = {
'goel_full_strength': goel['strength'],
'goel_special_points': goel['special_points'],
}
for key in ('lagna_seventh_strength', 'niryaana_shoola_strength'):
if key in goel:
fields[key] = goel[key]
return fields
except (ImportError, KeyError, TypeError, ValueError) as exc:
logger.warning(
"Goel Jaimini strength failed: %s: %s",
exc.__class__.__name__,
exc,
)
reason = f'{exc.__class__.__name__}: {exc}'
return {
'goel_full_strength': {'status': 'blocked', 'reason': reason},
'goel_special_points': {
name: {'status': 'blocked', 'reason': reason}
for name in ('Brahma', 'Maheshwara', 'Rudra')
},
}
def cmd_jaimini(args):
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args)
if chart is None:
@@ -14411,6 +14444,11 @@ def cmd_jaimini(args):
result['graha_padas'] = calc_graha_padas(planet_lons)
if mode in ('all', 'special'):
result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0)
goel_lons = {}
for pn, pd in planets.items():
if isinstance(pd, dict) and 'degree' in pd:
goel_lons[pn] = pd.get('degree_raw', pd['degree'])
result.update(_goel_jaimini_evidence(goel_lons, asc_deg))
return _build_response_envelope('jaimini', result, args=args, execution_status='executed')
@@ -16283,6 +16321,7 @@ def cmd_full_reading(args):
except Exception as dk_e:
jaimini_result['darakaraka'] = {'error': str(dk_e)}
jaimini_result.update(_goel_jaimini_evidence(planet_lons, asc_deg))
report['modules']['jaimini'] = jaimini_result
except Exception as e:
report['errors'].append(f"jaimini: {e}")
+112
View File
@@ -0,0 +1,112 @@
"""Source-bounded Brahma selector for the Goel/Rao short-method profile.
This is intentionally explicit-input only. It implements the short method
described in the local Jaimini transcriptions: choose the stronger of Lagna
and the seventh sign, inspect the 6th/8th/12th lords from that anchor, exclude
Saturn, then rank the remaining candidates by declared strength and degree.
It does not implement the separate odd/back-rising or BPHS override clauses.
"""
from __future__ import annotations
from typing import Any
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon",
4: "Sun", 5: "Mercury", 6: "Venus", 7: "Mars",
8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
}
PROFILE_ID = "goel_rao_short_brahma_method_v1"
def _candidate(
*,
house: int,
anchor_sign_index: int,
planets: dict[str, dict[str, Any]],
strengths: dict[str, float],
) -> dict[str, Any]:
sign_index = (anchor_sign_index + house - 1) % 12
lord = SIGN_LORDS[sign_index]
planet = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
degree = planet.get("degree_in_sign", planet.get("degree"))
return {
"source_house": house,
"source_sign_index": sign_index,
"source_sign": SIGNS[sign_index],
"planet": lord,
"planet_sign_index": planet.get("sign_index", planet.get("sign_idx")),
"planet_sign": planet.get("sign"),
"degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None,
"declared_strength": strengths.get(lord),
"saturn_excluded": lord == "Saturn",
}
def select_brahma_goel_short_method(
*,
lagna_sign_index: int,
planets: dict[str, dict[str, Any]],
planet_strengths: dict[str, float],
lagna_strength: float,
seventh_strength: float,
) -> dict[str, Any]:
"""Select Brahma under the explicit Goel/Rao short-method profile."""
if not 0 <= int(lagna_sign_index) < 12:
return {"status": "blocked", "reason": "lagna_sign_index must be 0..11", "candidates": []}
if not isinstance(planets, dict) or not isinstance(planet_strengths, dict):
return {"status": "blocked", "reason": "planets and planet_strengths are required", "candidates": []}
if not isinstance(lagna_strength, (int, float)) or not isinstance(seventh_strength, (int, float)):
return {"status": "blocked", "reason": "lagna_strength and seventh_strength are required", "candidates": []}
anchor = int(lagna_sign_index) if float(lagna_strength) >= float(seventh_strength) else (int(lagna_sign_index) + 6) % 12
candidates = [
_candidate(house=house, anchor_sign_index=anchor, planets=planets, strengths=planet_strengths)
for house in (6, 8, 12)
]
eligible = [row for row in candidates if not row["saturn_excluded"] and row["declared_strength"] is not None]
if not eligible:
return {
"profile_id": PROFILE_ID,
"status": "blocked",
"confidence_status": "parameter_sensitive",
"reason": "No non-Saturn candidate has a declared strength.",
"anchor": {"sign_index": anchor, "sign": SIGNS[anchor], "selected_by": "lagna_vs_seventh_strength"},
"candidates": candidates,
}
eligible.sort(
key=lambda row: (
float(row["declared_strength"]),
float(row["degree_in_sign"]) if row["degree_in_sign"] is not None else -1.0,
),
reverse=True,
)
selected = eligible[0]
return {
"profile_id": PROFILE_ID,
"status": "executed",
"confidence_status": "parameter_sensitive",
"verification_status": "source_bounded_explicit_input",
"anchor": {
"sign_index": anchor,
"sign": SIGNS[anchor],
"selected_by": "stronger_of_lagna_and_seventh",
"lagna_strength": float(lagna_strength),
"seventh_strength": float(seventh_strength),
},
"candidates": candidates,
"selected": selected,
"source_rule": (
"Goel/Rao short method: strongest of 6th, 8th, and 12th lords from "
"the stronger Lagna/7th anchor, excluding Saturn."
),
"unresolved": [
"odd_sign_and_back_rising_filter_not_applied",
"BPHS_verse_172_karaka_and_saturn_node_override_not_applied",
"strength_source_is_declared_input_not_recomputed_here",
],
}
+166
View File
@@ -0,0 +1,166 @@
"""Separate Trikona profiles: Raghava Bhatta MD and Raman confirmed-parent AD."""
import argparse
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import json
import math
from pathlib import Path
import sys
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY
PROFILE = 'trikona_raghava_bhatta_explicit_seed_md_v1'
SOURCE = 'https://saptarishisastrology.com/trikona-dasa-a-critical-review-by-iranganti-rangacharya/'
RAMAN_AD_PROFILE = 'trikona_raman_94_confirmed_full_md_ad_v1'
RAMAN_SOURCE = 'https://www.scribd.com/doc/127930441/Studies-in-Jaimini-Astrology-by-b-v-Raman'
def build_trikona_raman_antardasa_report(*, md_sign_idx, full_md_years,
full_md_start, parent_source, parent_profile, year_days, profile):
"""Raman section94 AD only; a confirmed, untruncated parent is input."""
if profile != RAMAN_AD_PROFILE or parent_profile != 'raman_studies_jaimini_92_93':
raise ValueError('explicit Raman AD and Raman sections92/93 parent profiles required')
if type(md_sign_idx) is not int or not 0 <= md_sign_idx < 12:
raise ValueError('md_sign_idx must be integer 0..11')
if not isinstance(parent_source, str) or not parent_source.strip():
raise ValueError('parent_source must identify the confirmed full MD, not birth balance')
for name, value, low, high in [('full_md_years', full_md_years, 0, 12),
('year_days', year_days, 300, 400)]:
if (isinstance(value, bool) or not isinstance(value, (int, float)) or
not math.isfinite(value) or not low <= value <= high or value == 0):
raise ValueError(f'{name} outside supported finite range')
if not isinstance(full_md_start, str):
raise ValueError('full_md_start must be aware ISO datetime')
anchor = datetime.fromisoformat(full_md_start.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('full_md_start must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
# Raman section155 specifies these groups; neither parity nor ninth-from-parent.
direction = 1 if md_sign_idx in (0, 1, 2, 6, 7, 8) else -1
offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
try:
edges = [(anchor + timedelta(days=full_md_years * year_days * i / 12)).isoformat()
for i in range(13)]
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
periods = [dict(sign_idx=(md_sign_idx + direction * offset) % 12,
sign=SIGNS[(md_sign_idx + direction * offset) % 12],
years=full_md_years / 12, start_utc=edges[i], end_utc=edges[i + 1],
start_age=full_md_years * i / 12, end_age=full_md_years * (i + 1) / 12)
for i, offset in enumerate(offsets)]
result = dict(status='parameter_sensitive', variant_profile_id=profile,
tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
source_locator='section94 pp61-62; Savya groups section155 p127',
parent=dict(sign_idx=md_sign_idx, sign=SIGNS[md_sign_idx], years=full_md_years,
start_utc=edges[0], end_utc=edges[-1], source=parent_source,
profile=parent_profile, status='supplied_confirmed_full_parent'),
year_days=year_days, direction=direction, periods=periods,
child_periods=dict(level='AD', status='executed', count=12),
pratyantardasa=dict(status='blocked', reason='section94_does_not_establish_PD'),
limits=['Parent seed, duration and full start supplied, not calculated or independently verified',
'No birth-balance clipping; do not supply a residual MD as a full parent',
'Fixed year_days is explicit caller convention, not a source date oracle',
'Raman AD only; never attach to Raghava Bhatta or PyJHora parents automatically',
'No PD, event inference or default customer export'])
lines = ['# Trikona — Raman confirmed full MD / AD', '', f'Profile: {profile}',
f'Source: {RAMAN_SOURCE} — {result["source_locator"]}', '',
'| AD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False,
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
ai_evidence_bundle=deepcopy(result)))
def build_trikona_native_report(*, lagna_sign_idx, seed_sign_idx, seed_source,
planet_longitudes, epoch, year_days, profile):
if profile != PROFILE:
raise ValueError('explicit Raghava Bhatta profile required')
if any(type(v) is not int or not 0 <= v < 12 for v in (lagna_sign_idx, seed_sign_idx)):
raise ValueError('sign indices must be integers 0..11')
if (seed_sign_idx - lagna_sign_idx) % 12 not in (0, 4, 8):
raise ValueError('seed must be Lagna, fifth or ninth')
if not isinstance(seed_source, str) or not seed_source.strip():
raise ValueError('seed_source must explain the supplied strength decision')
required = set(SIGN_LORDS.values())
if not isinstance(planet_longitudes, dict) or set(planet_longitudes) != required:
raise ValueError('exactly seven classical planetary longitudes required; no node co-lords')
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or
not math.isfinite(v) or not 0 <= v < 360 for v in planet_longitudes.values()):
raise ValueError('planetary longitudes must be finite in [0,360)')
if isinstance(year_days, bool) or not isinstance(year_days, (int, float)) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
raise ValueError('explicit year_days 300..400 required')
if not isinstance(epoch, str):
raise ValueError('aware ISO epoch required')
anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('epoch must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
direction = 1 if seed_sign_idx % 2 == 0 else -1
if seed_sign_idx % 3 == 0:
offsets = list(range(12))
elif seed_sign_idx % 3 == 1:
offsets = [5 * i for i in range(12)]
else:
offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
periods = []
age = 0
for offset in offsets:
sign = (seed_sign_idx + direction * offset) % 12
lord = SIGN_LORDS[SIGNS[sign]]
lord_sign = int(planet_longitudes[lord] // 30)
count_direction = 1 if sign % 2 == 0 else -1
count = ((lord_sign - sign) * count_direction) % 12 + 1
dignity = _NARAYANA_DIGNITY[lord]
adjustment = int(lord_sign in dignity['exalted']) - int(lord_sign in dignity['debilitated'])
years = 12 if lord_sign == sign else min(12, count + adjustment)
end = age + years
try:
start_utc = (anchor + timedelta(days=age * year_days)).isoformat()
end_utc = (anchor + timedelta(days=end * year_days)).isoformat()
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
periods.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
lord_sign_idx=lord_sign, inclusive_count=count, dignity_adjustment=adjustment,
years=years, start_age=age, end_age=end, start_utc=start_utc, end_utc=end_utc))
age = end
result = dict(status='parameter_sensitive', variant_profile_id=PROFILE,
tradition_profile='raghava_bhatta_as_presented_by_rangacharya', source=SOURCE,
lagna_sign_idx=lagna_sign_idx, seed_sign_idx=seed_sign_idx,
seed_selection=dict(status='supplied_not_calculated', source=seed_source),
epoch_utc=anchor.isoformat(), year_days=year_days, periods=periods,
child_periods=dict(status='blocked', reason='method_specific_AD_PD_rules_not_established'),
limits=['Supplied strongest trine; strength selection is not calculated',
'Full first MD begins at explicit epoch; no fractional birth balance inferred',
'Fixed-day calendar is caller convention, not verified source dates',
'First cycle only; no automatic customer export or event certainty'])
lines = ['# Trikona — Raghava Bhatta / Rangacharya method 2', '',
'Status: parameter_sensitive', f'Source: {SOURCE}', f'Profile: {PROFILE}', '',
'| Sign | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
lines += ['', *result['limits'], 'AD/PD: blocked — method-specific rules not established',
'', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False,
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--request', type=Path, required=True)
parser.add_argument('--mode', choices=('method2-md', 'raman-confirmed-md-ad', 'raman-native'), default='method2-md')
parser.add_argument('--format', choices=('json', 'markdown'), default='json')
args = parser.parse_args()
builder = (build_trikona_raman_antardasa_report if args.mode == 'raman-confirmed-md-ad'
else build_trikona_native_report)
if args.mode == 'raman-native':
from scripts.trikona_raman import build_trikona_raman_report
builder = build_trikona_raman_report
report = builder(**json.loads(args.request.read_text()))
print(report['exports']['markdown'] if args.format == 'markdown' else json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+233
View File
@@ -0,0 +1,233 @@
"""Raman's numerical Rasi strength and Trikona MD/AD, explicit conventions.
Studies in Jaimini Astrology sections62-72,79-86,92-94. This is Raman's
adaptation, not a universal Jaimini comparator or the Raghava Bhatta method.
"""
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import json
import math
from scripts.jaimini import FRIENDSHIP
from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY, _rasi_aspects
from scripts.trikona_dasha import RAMAN_AD_PROFILE, RAMAN_SOURCE, build_trikona_raman_antardasa_report
PROFILE = 'trikona_raman_numeric_seed_md_ad_candidate_v1'
STRENGTH_PROFILE = 'raman_studies_62_72_numeric_v1'
DIGNITY_PROFILE = 'raman_manual_degree_mt_exaltation_first_v1'
CLASSICAL = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
SAVYA = {0, 1, 2, 6, 7, 8}
AMSA = (60, 45, 30, 22.5, 15, 7.5, 3.75)
# Raman, A Manual of Hindu Astrology, section29 printed p12. These differ
# from the repository Shadbala table; never silently substitute that table.
MT = {'Sun': (4, 0, 20), 'Moon': (1, 4, 30), 'Mars': (0, 0, 18),
'Mercury': (5, 16, 20), 'Jupiter': (8, 0, 13), 'Venus': (6, 0, 10),
'Saturn': (10, 0, 20)}
def _validate_positions(positions):
if not isinstance(positions, dict) or set(positions) != set(CLASSICAL) | {'Rahu', 'Ketu'}:
raise ValueError('exactly seven classical planets plus Rahu and Ketu required')
if any(type(v) not in (int, float) or not math.isfinite(v) or not 0 <= v < 360
for v in positions.values()):
raise ValueError('longitudes must be finite numbers in [0,360)')
if not math.isclose((positions['Rahu'] - positions['Ketu']) % 360, 180, abs_tol=1e-7, rel_tol=0):
raise ValueError('Rahu and Ketu must be opposite in the same position profile')
def _dignity(planet, longitude):
sign, degree = int(longitude // 30), longitude % 30
if sign in _NARAYANA_DIGNITY[planet]['exalted']:
return 'exalted', 60
if sign in _NARAYANA_DIGNITY[planet]['debilitated']:
return 'debilitated', 3.75
mt_sign, lo, hi = MT[planet]
if sign == mt_sign and lo <= degree < hi:
return 'moolatrikona', 45
owner = SIGN_LORDS[SIGNS[sign]]
if owner == planet:
return 'own', 30
if owner in FRIENDSHIP[planet]['friend']:
return 'friend', 22.5
if owner in FRIENDSHIP[planet]['enemy']:
return 'enemy', 7.5
return 'neutral', 15
def calculate_raman_rasi_strength(*, planet_longitudes, strength_profile,
dignity_profile, druk_role_policy):
if strength_profile != STRENGTH_PROFILE or dignity_profile != DIGNITY_PROFILE:
raise ValueError('explicit Raman strength and dignity profiles required')
if druk_role_policy not in ('distinct_planets', 'distinct_roles'):
raise ValueError('druk_role_policy must be distinct_planets or distinct_roles')
_validate_positions(planet_longitudes)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
ties = [[p for p in CLASSICAL if degrees[p] == value] for value in set(degrees.values())]
ties = sorted((group for group in ties if len(group) > 1), key=lambda group: degrees[group[0]], reverse=True)
result = dict(profile=strength_profile, dignity_profile=dignity_profile,
druk_role_policy=druk_role_policy, source=RAMAN_SOURCE, locator='sections62-72',
status='blocked' if ties else 'executed', rank_ties=ties, planet_strengths={}, rasi_strengths=[])
# Section65 does not specify equal-rank scores. Preserve own-sign strengths
# below, but never fabricate a total/rank/Atmakaraka when a degree tie exists.
ak = None if ties else ranked[0]
result['atmakaraka'] = ak
if not ties:
for rank, planet in enumerate(ranked):
label, dignity = _dignity(planet, planet_longitudes[planet])
kendra = (60, 30, 15)[(signs[planet] - signs[ak]) % 3]
result['planet_strengths'][planet] = dict(dignity_label=label,
moolatrikona_bala=dignity, amsa_bala=AMSA[rank], kendra_bala=kendra,
total=dignity + AMSA[rank] + kendra)
for sign in range(12):
lord = SIGN_LORDS[SIGNS[sign]]
role_planets = ['Jupiter', 'Mercury', lord]
if druk_role_policy == 'distinct_planets':
role_planets = list(dict.fromkeys(role_planets))
hits = [p for p in role_planets if signs[p] == sign or _rasi_aspects(signs[p], sign)]
occupants = [p for p, position in signs.items() if position == sign]
chara = (15, 30, 60)[sign % 3]
sthira = 45 + 15 * len(occupants) if occupants else 0
own = chara + 60 * len(hits) + sthira
special = 60 if ak and signs[ak] == sign else (None if ties else 0)
lord_strength = None if ties else result['planet_strengths'][lord]['total']
result['rasi_strengths'].append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
chara_bala=chara, druk_bala=60 * len(hits), druk_contributors=hits,
sthira_bala=sthira, occupants=occupants, own_strength=own,
lord_strength=lord_strength, special_strength=special,
total=None if ties else own + lord_strength + special))
if ties:
result['reason'] = 'equal_degree_AMSA_rank_rule_not_established; provide finer positions or a sourced tie profile'
return result
def _duration(sign, signs, *, node_dignity_profile):
"""Sections79-86, retaining the selected lord and decision trace."""
direction = 1 if sign in SAVYA else -1
classical = SIGN_LORDS[SIGNS[sign]]
lords = [classical] + ({7: ['Ketu'], 10: ['Rahu']}.get(sign, []))
base = {p: ((signs[p] - sign) * direction) % 12 or 12 for p in lords}
adjustment = {p: (int(signs[p] in _NARAYANA_DIGNITY[p]['exalted']) -
int(signs[p] in _NARAYANA_DIGNITY[p]['debilitated']))
if p in CLASSICAL else 0 for p in lords}
trace = ['section79_savya_count_minus_one']
selected = lords[:]
if len(lords) == 2:
if signs[lords[0]] == signs[lords[1]]:
trace.append('section82_same_own' if signs[lords[0]] == sign else 'sections83_84_conjunction')
# Conjunction outside own sign explicitly refers to section79 only.
years = base[lords[0]]
return dict(status='executed', years=years, lords=selected, trace=trace,
count_direction=direction, candidates=base, node_dignity_profile=node_dignity_profile)
away = [p for p in lords if signs[p] != sign]
if len(away) == 1:
selected = away
trace.append('section85_prefer_lord_away_from_own')
else:
associates = {p: sum(s == signs[p] for name, s in signs.items() if name != p) for p in lords}
selected = [p for p in selected if associates[p] == max(associates.values())]
trace.append('section86_associate_count')
if len(selected) > 1:
modes = {p: signs[p] % 3 for p in selected}
selected = [p for p in selected if modes[p] == max(modes.values())]
trace.append('section86_dual_fixed_movable')
if len(selected) > 1:
selected = [p for p in selected if base[p] == max(base[q] for q in selected)]
trace.append('section86_larger_span')
if classical in selected and adjustment[classical] == -1:
return dict(status='blocked', reason='section86_over_debilitated_and_node_exaltation_exception_unresolved',
lords=selected, candidates=base, trace=trace)
years_by_lord = {p: (12 if signs[p] == sign else base[p] + adjustment[p]) for p in selected}
if len(set(years_by_lord.values())) != 1:
return dict(status='blocked', reason='equal_colord_rank_with_different_adjusted_years',
lords=selected, candidates=years_by_lord, trace=trace)
years = next(iter(years_by_lord.values()))
return dict(status='executed' if years > 0 else 'blocked', years=years,
reason=None if years > 0 else 'nonpositive_adjusted_span_no_clipping_rule',
lords=selected, trace=trace, candidates=base, dignity_adjustments=adjustment,
count_direction=direction, node_dignity_profile=node_dignity_profile)
def build_trikona_raman_report(*, lagna_sign_idx, planet_longitudes, epoch, year_days,
profile, strength_profile, dignity_profile, druk_role_policy, node_dignity_profile):
if profile != PROFILE:
raise ValueError('explicit Raman native profile required')
if type(lagna_sign_idx) is not int or not 0 <= lagna_sign_idx < 12:
raise ValueError('lagna_sign_idx must be integer 0..11')
if node_dignity_profile != 'unassigned_no_adjustment':
raise ValueError('node_dignity_profile must explicitly select unassigned_no_adjustment')
if type(year_days) not in (int, float) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
raise ValueError('year_days must be finite in 300..400')
if not isinstance(epoch, str):
raise ValueError('aware ISO epoch required')
anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
if anchor.tzinfo is None or anchor.utcoffset() is None:
raise ValueError('epoch must include UTC offset')
anchor = anchor.astimezone(timezone.utc)
strength = calculate_raman_rasi_strength(planet_longitudes=planet_longitudes,
strength_profile=strength_profile, dignity_profile=dignity_profile, druk_role_policy=druk_role_policy)
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
trines = [(lagna_sign_idx + offset) % 12 for offset in (0, 4, 8)]
candidates = [strength['rasi_strengths'][s] for s in trines]
seeds = [] if strength['status'] == 'blocked' else [r['sign_idx'] for r in candidates
if r['total'] == max(c['total'] for c in candidates)]
durations = [dict(sign_idx=s, sign=SIGNS[s], **_duration(s, signs,
node_dignity_profile=node_dignity_profile)) for s in range(12)]
branches = []
for seed in seeds:
age = 0
dated = True
periods = []
direction = 1 if seed in SAVYA else -1
for offset in [group + trine for group in range(4) for trine in (0, 4, 8)]:
sign = (seed + direction * offset) % 12
row = deepcopy(durations[sign])
if row['status'] != 'executed':
dated = False
if dated:
try:
start = (anchor + timedelta(days=age * year_days)).isoformat()
end = (anchor + timedelta(days=(age + row['years']) * year_days)).isoformat()
except OverflowError as exc:
raise ValueError('calendar boundary outside supported datetime range') from exc
row.update(start_age=age, end_age=age + row['years'], start_utc=start, end_utc=end,
calendar_status='calculated_with_caller_fixed_year')
child = build_trikona_raman_antardasa_report(md_sign_idx=sign,
full_md_years=row['years'], full_md_start=start,
parent_source='Native Raman sections79-86/92-93; see duration trace',
parent_profile='raman_studies_jaimini_92_93', year_days=year_days, profile=RAMAN_AD_PROFILE)
row['antardasas'] = child['evidence_appendix'][0]['periods']
# Parent and child formulas can round sub-microsecond floating
# days differently; share the exact already-calculated edge.
row['antardasas'][-1]['end_utc'] = end
age += row['years']
else:
row.update(calendar_status='blocked_by_unresolved_prior_or_current_duration', antardasas=[])
periods.append(row)
branches.append(dict(seed_sign_idx=seed, direction=direction,
status='parameter_sensitive' if dated else 'partial', periods=periods))
result = dict(status='blocked' if not seeds else 'parameter_sensitive', variant_profile_id=profile,
tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
strength=strength, seed_selection=dict(status='blocked' if not seeds else 'parallel_tie' if len(seeds)>1 else 'calculated',
candidates=candidates, selected_signs=seeds), duration_evidence=durations, branches=branches,
epoch_utc=anchor.isoformat(), year_days=year_days, node_dignity_profile=node_dignity_profile,
pratyantardasa=dict(status='blocked', reason='Raman_section94_does_not_establish_PD'),
limits=['Raman numerical adaptation, not universal Jaimini strength',
'Druk role deduplication is explicit; source does not resolve duplicate roles',
'Degree MT follows Raman Manual section29; exaltation checked first by explicit profile',
'Node dignity left unassigned by caller convention, not a universal no-exaltation doctrine',
'Full first MD starts at caller epoch; no fractional birth-balance inferred',
'Fixed year_days not source-date verified; first cycle only; no customer admission'])
lines = ['# Trikona — Raman numerical seed / MD / AD', '', f'Profile: {profile}',
f'Source: {RAMAN_SOURCE}', '', '## Strength and seed',
'| Trine sign | Strength |', '| --- | --- |']
lines += [f"| {r['sign']} | {r['total']} |" for r in candidates]
for branch in branches:
lines += ['', f"## Seed {SIGNS[branch['seed_sign_idx']]}",
'| MD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
lines += [f"| {r['sign']} | {r.get('years', 'blocked')} | {r.get('start_utc', 'blocked')} | {r.get('end_utc', 'blocked')} |"
for r in branch['periods']]
lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
return dict(status=result['status'], customer_export_admitted=False, evidence_appendix=[result],
exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
+9
View File
@@ -63,3 +63,12 @@ def test_varga_cli_d9_uses_same_navamsa_mapping_as_varga_full() -> None:
assert short["ascendant"] == full["Ascendant"]["sign"]
for planet in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]:
assert short[planet]["sign"] == full[planet]["sign"]
def test_full_reading_exposes_goel_jaimini_strength() -> None:
report = _run_engine("full-reading", *BASE_BIRTH_ARGS)
goel = report["modules"]["jaimini"]
assert goel["goel_full_strength"]["status"] == "executed"
assert len(goel["goel_full_strength"]["rashi_strengths"]) == 12
assert set(goel["goel_special_points"]) == {"Brahma", "Maheshwara", "Rudra"}
assert goel["niryaana_shoola_strength"]["deprecated"] is True
+209
View File
@@ -0,0 +1,209 @@
"""Hand-checked synthetic positions, plus Goel's printed p15 worked table.
No private birth data; these fixtures exercise supplied-position arithmetic.
"""
from copy import deepcopy
import importlib
import importlib.util
import math
import pytest
SYNTHETIC = dict(Sun=29., Moon=58., Mars=87., Mercury=116.,
Jupiter=145., Venus=174., Saturn=203., Rahu=245., Ketu=65.)
def module():
assert importlib.util.find_spec('scripts.goel_jaimini_strength'), 'Goel producer must be published'
return importlib.import_module('scripts.goel_jaimini_strength')
def test_synthetic_five_component_totals_and_special_points():
original = deepcopy(SYNTHETIC)
result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
strength = result['strength']
assert strength['atmakaraka'] == 'Sun'
assert {p: row['total'] for p, row in strength['planets'].items()} == dict(
Sun=180, Moon=135, Mars=172.5, Mercury=90, Jupiter=187.5,
Venus=86.25, Saturn=123.75)
assert {p: row['value'] for p, row in result['special_points'].items()} == dict(
Brahma='Jupiter', Maheshwara='Mars', Rudra='Mars')
assert strength['rashi_strengths'][0]['total'] == 367.5
assert strength['rashi_strengths'][6]['total'] == 225
assert SYNTHETIC == original
def test_node_policy_only_changes_sthana_and_sign_total():
m = module()
text = m.calculate_goel_strength(SYNTHETIC)
example = m.calculate_goel_strength(SYNTHETIC, node_occupancy_policy='exclude_nodes_worked_example')
assert text['planets'] == example['planets']
# Gemini has Mars+Ketu; Sagittarius has only Rahu.
assert (text['rashi_strengths'][2]['sthana'], example['rashi_strengths'][2]['sthana']) == (75, 60)
assert (text['rashi_strengths'][8]['sthana'], example['rashi_strengths'][8]['sthana']) == (60, 0)
assert text['rashi_strengths'][2]['total'] - example['rashi_strengths'][2]['total'] == 15
assert text['rashi_strengths'][8]['total'] - example['rashi_strengths'][8]['total'] == 60
def test_goel_printed_p15_numeric_example_without_birth_identity():
positions = dict(Sun=20+18/60, Moon=210+20+15/60, Mars=210+23+2/60,
Mercury=30+11+12/60, Jupiter=150+3+13/60,
Venus=330+17+28/60, Saturn=7+7/60,
Rahu=330+5+58/60, Ketu=150+5+58/60)
result = module().calculate_goel_strength(positions, node_occupancy_policy='exclude_nodes_worked_example')
assert {p: r['total'] for p, r in result['planets'].items()} == dict(
Sun=240, Moon=93.75, Mars=210, Mercury=97.5, Jupiter=101.25,
Venus=172.5, Saturn=146.25)
assert [r['total'] for r in result['rashi_strengths']] == [
412.5, 337.5, 217.5, 228.75, 330, 281.25, 247.5, 465,
281.25, 221.25, 236.25, 363.75]
def test_equal_karaka_degrees_block_all_special_point_choices():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Moon=59.), 0.)
assert result['strength']['status'] == 'blocked'
assert all(result['special_points'][p]['status'] == 'blocked'
for p in ('Brahma', 'Maheshwara', 'Rudra'))
@pytest.mark.parametrize('bad', [math.nan, math.inf, -1., 360., True, '0'])
def test_invalid_ascendant_rejected(bad):
with pytest.raises(ValueError):
module().build_goel_chart_evidence(SYNTHETIC, bad)
def test_legacy_lagna_seventh_alias_does_not_replace_pvr_second_eighth():
result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
legacy = result['niryaana_shoola_strength']
assert (legacy['lagna'], legacy['seventh']) == (367.5, 225)
assert legacy['deprecated'] is True
assert legacy['comparison'] == 'lagna_vs_seventh'
from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign
pvr = select_niryaana_shoola_start_sign(ascendant_longitude=0., planet_longitudes=SYNTHETIC)
assert pvr['selected_sign_idx'] == 1
assert pvr['profile'] == 'pvr_niryaana_shoola_ayur_rasi_strength_v1'
def test_brahma_ak_eighth_occupancy_overrides_lagna_selector():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=239., Mercury=86.), 0.)
assert result['special_points']['Brahma']['value'] == 'Mercury'
assert result['special_points']['Brahma']['rule'] == 'eighth_lord_from_AK_occupies_eighth_from_AK'
def test_exalted_ak_eighth_lord_compares_twelfth_for_maheshwara():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Mars=297.), 0.)
assert result['special_points']['Maheshwara']['value'] == 'Jupiter'
assert result['special_points']['Maheshwara']['candidates'] == {'Mars': 150, 'Jupiter': 187.5}
def test_rudra_weaker_malefic_contact_exception():
result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=329., Rahu=125., Ketu=305.), 0.)
planets = result['strength']['planets']
assert planets['Mars']['total'] == 127.5
assert planets['Mars']['malefic_contacts'] == []
assert planets['Venus']['total'] == 101.25
assert planets['Venus']['malefic_contacts'] == ['Mars']
assert result['special_points']['Rudra']['value'] == 'Venus'
def test_incomplete_chart_entry_preserves_blocked_points():
from scripts.jyotish_engine import _goel_jaimini_evidence
result = _goel_jaimini_evidence({}, 0.)
assert result['goel_full_strength']['status'] == 'blocked'
assert all(r['status'] == 'blocked' for r in result['goel_special_points'].values())
assert 'niryaana_shoola_strength' not in result
@pytest.mark.parametrize('bad', [dict(SYNTHETIC, Sun=math.nan),
dict(SYNTHETIC, Ketu=66.), {p: v for p, v in SYNTHETIC.items() if p != 'Saturn'}])
def test_incomplete_or_inconsistent_positions_rejected(bad):
with pytest.raises(ValueError):
module().calculate_goel_strength(bad)
def test_jaimini_special_entry_exposes_real_goel_results(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
chart = {'planets': {p: {'degree': v} for p, v in SYNTHETIC.items()},
'ascendant': {'degree': 0.}}
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
assert result.get('goel_full_strength', {}).get('atmakaraka') == 'Sun'
assert result['goel_special_points']['Brahma']['value'] == 'Jupiter'
assert result['niryaana_shoola_strength']['deprecated'] is True
def test_wired_special_entry_keeps_both_node_policies(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
chart = {'planets': {p: {'degree': round(v, 4), 'degree_raw': v} for p, v in SYNTHETIC.items()},
'ascendant': {'degree': 0., 'lon': 0.}}
chart['planets']['Sun']['degree'] = 1.0
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
strength = result['goel_full_strength']
assert strength['status'] == 'executed'
assert strength['node_occupancy_policy'] == 'include_nodes_text'
exclusion = strength['worked_example_node_exclusion']
assert exclusion['node_occupancy_policy'] == 'exclude_nodes_worked_example'
assert strength['atmakaraka'] == 'Sun'
assert abs(strength['planets']['Sun']['degree_in_sign'] - (SYNTHETIC['Sun'] % 30)) <= 1e-6
def test_wired_equal_degrees_block_without_atmakaraka_or_rank(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine as engine
positions = dict(SYNTHETIC, Moon=59.)
chart = {'planets': {p: {'degree': v} for p, v in positions.items()},
'ascendant': {'degree': 0.}}
monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
strength = result['goel_full_strength']
assert strength['status'] == 'blocked'
assert strength['reason'] == 'equal_degree_seven_karaka_rank_requires_finer_positions'
assert 'atmakaraka' not in strength
assert 'planets' not in strength
for name in ('Brahma', 'Maheshwara', 'Rudra'):
point = result['goel_special_points'][name]
assert point['status'] == 'blocked'
assert 'value' not in point
assert 'candidates' not in point
def test_full_reading_feeds_goel_degree_raw(monkeypatch):
import scripts.goel_jaimini_strength as goel
from scripts.jyotish_engine import cmd_full_reading
captured = []
real = goel.build_goel_chart_evidence
def wrapped(longitudes, ascendant):
captured.append(dict(longitudes))
return real(longitudes, ascendant)
monkeypatch.setattr(goel, 'build_goel_chart_evidence', wrapped)
report = cmd_full_reading(type('Args', (), {
'name': 'fictional',
'year': 1990, 'month': 1, 'day': 1, 'hour': 12, 'minute': 0, 'second': 0,
'lat': 39.9, 'lon': 116.4, 'tz': 8.0,
'node_mode': 'mean', 'ayanamsa': 'lahiri',
'today': '2026-10-22', 'transit_date': '2026-10-22',
'target_year': 2026, 'age': 36,
'birth_time_accuracy': 'confirmed',
})())
assert captured, 'full-reading did not call Goel'
planets = report['modules']['chart']['planets']
expected = {
name: pdata['degree_raw']
for name, pdata in planets.items()
if isinstance(pdata, dict) and 'degree_raw' in pdata
}
assert expected
for longitudes in captured:
assert set(longitudes) == set(expected)
for name, raw in expected.items():
assert abs(float(longitudes[name]) - float(raw)) <= 1e-6
strength = report['modules']['jaimini']['goel_full_strength']
assert strength['status'] == 'executed'
assert strength['node_occupancy_policy'] == 'include_nodes_text'
assert strength['worked_example_node_exclusion']['node_occupancy_policy'] == 'exclude_nodes_worked_example'
+6 -5
View File
@@ -18,11 +18,12 @@ SCRIPTS = ROOT / "scripts"
# 本地新写的静默处理仍不得出现;允许的命中只有这一处。
SILENT_EXCEPT_BUDGET = 1
UPSTREAM_SILENT_EXCEPT = "scripts/classical_astrology.py:12558"
# 原值:446
# 新值:448
# 原因:产品 2026-10-18(issue #81 选 A):上游原样文件按上游路径放在 scripts/ 根目录,
# classical_astrology.py、unified_western_reading.py 两个;本地新写的文件仍不得增加。
ROOT_PY_BUDGET = 448
# 原值:448
# 新值:452
# 原因:产品 2026-10-18 #81 决策 A 先例,issue #94:上游原样文件 goel_jaimini_strength、
# trikona_raman、sthira_brahma_goel_short_method、trikona_dasha 按上游路径放根目录;
# 本地新写的文件仍不得增加。
ROOT_PY_BUDGET = 452
def _silent_handlers() -> list[str]: