Files
Jyotisha/scripts/yoga_engine.py
T
ClaudeandClaude Opus 5.5 5d64f71b81 fix: astrologer round-5 rulings 2/3/4/5/10/13 (BUG-1746..1752)
- Functional default = PVR Table 30 (functional_profile=pvr_table_30); the former
  product table is bphs_exception_profile, scored only when named (rectification
  names it, scores unchanged). profile_divergence shows both roles side by side;
  movable-lagna Moon by the table's paksha note; dual-lordship note; prior rulings
  BUG-1570/1590/#166 Q4 checked against Table 30.
- Shadbala: six open items labelled parameter_sensitive; every option combination
  computed; only planets whose pass/fail flips get minimum_check=parameter_sensitive.
- Vimshottari AD/PD rows carry parent_period_start / visible_period_start; the
  consult card cuts the balance MD from its full parent and clips at birth.
- KP DBA: full first parent, clipped at birth (no balance rescale).
- Mudda: balance fields + balance_method (angle default, time comparison apart),
  nominal_year_days, her crossing wording; Harsha combined blocked reason lists items.
- D30 _meta describes the unequal parts (as her r40).
- Golden recaptured with TTL=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-08 11:57:22 +08:00

2887 lines
142 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Yoga 规则引擎 v1.0 (数据驱动架构)
从 JSON 规则配置中读取 Yoga 定义,通用条件解释器执行检测。
设计原则:
- 规则完全数据驱动,加新 Yoga 只需改 JSON
- 条件类型可扩展,覆盖 BPHS/PyJHora 主流 Yoga 模式
- 与原 cmd_yoga() API 兼容
"""
import ast
import functools
import inspect
import json
import os
import textwrap
from typing import Dict, List, Any, Optional
try: # scripts/ on sys.path
from main_yoga_detectors import main_detector_rows
except ImportError: # package import (scripts.yoga_engine)
from scripts.main_yoga_detectors import main_detector_rows
# ============================================================================
# 基础常量(自包含,避免循环导入)
# ============================================================================
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'
}
EXALTATION = {
'Sun': 'Aries', 'Moon': 'Taurus', 'Mars': 'Capricorn', 'Mercury': 'Virgo',
'Jupiter': 'Cancer', 'Venus': 'Pisces', 'Saturn': 'Libra'
}
DEBILITATION = {
'Sun': 'Libra', 'Moon': 'Scorpio', 'Mars': 'Cancer', 'Mercury': 'Pisces',
'Jupiter': 'Capricorn', 'Venus': 'Virgo', 'Saturn': 'Aries'
}
MOOLATRIKONA_SIGN = {
'Sun': 'Leo', 'Moon': 'Taurus', 'Mars': 'Aries', 'Mercury': 'Virgo',
'Jupiter': 'Sagittarius', 'Venus': 'Libra', 'Saturn': 'Aquarius'
}
# PyJHora const.friendly_planets mapped from planet ids to names.
FRIENDLY_PLANETS = {
'Sun': ['Moon', 'Mars', 'Jupiter'],
'Moon': ['Sun', 'Mercury'],
'Mars': ['Sun', 'Moon', 'Jupiter'],
'Mercury': ['Sun', 'Venus'],
'Jupiter': ['Sun', 'Moon', 'Mars'],
'Venus': ['Mercury', 'Saturn', 'Rahu'],
'Saturn': ['Mercury', 'Venus', 'Rahu'],
'Rahu': ['Venus', 'Saturn'],
'Ketu': ['Sun', 'Mars'],
}
# #166 Q5 (BUG-1596): natural (naisargika) enemies, BPHS 3.55; used for the
# strict Gajakesari "not in an enemy sign" check (PVR textbook definition).
NATURAL_ENEMIES = {
'Sun': ['Venus', 'Saturn'], 'Moon': [], 'Mars': ['Mercury'],
'Mercury': ['Moon'], 'Jupiter': ['Mercury', 'Venus'],
'Venus': ['Sun', 'Moon'], 'Saturn': ['Sun', 'Moon', 'Mars'],
}
# Combustion orbs (direct motion), same values as jyotish_engine.COMBUSTION_ORBS
# direct column; used only when the chart row carries no `combust` flag.
COMBUSTION_ORBS_DIRECT = {'Moon': 12.0, 'Mars': 17.0, 'Mercury': 14.0, 'Jupiter': 11.0, 'Venus': 10.0, 'Saturn': 15.0}
# Short Chinese planet names for bilingual rule triggers (#166 Q5 / Q6).
PLANET_ZH = {'Sun': '太阳', 'Moon': '月亮', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': '木星',
'Venus': '金星', 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都'}
PLANET_CN = {
"Ketu": "南交点Ketu", "Venus": "金星Venus", "Sun": "太阳Sun",
"Moon": "月亮Moon", "Mars": "火星Mars", "Rahu": "北交点Rahu",
"Jupiter": "木星Jupiter", "Saturn": "土星Saturn", "Mercury": "水星Mercury"
}
BENEFICS = ['Jupiter', 'Venus', 'Mercury', 'Moon']
MALEFICS = ['Mars', 'Saturn', 'Sun', 'Rahu', 'Ketu']
MOVABLE_SIGNS = ['Aries', 'Cancer', 'Libra', 'Capricorn']
FIXED_SIGNS = ['Taurus', 'Leo', 'Scorpio', 'Aquarius']
DUAL_SIGNS = ['Gemini', 'Virgo', 'Sagittarius', 'Pisces']
ALL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']
PLANET_INDEX = {planet: idx for idx, planet in enumerate(ALL_PLANETS)}
# Jaimini co-lord comparison only (_stronger_co_lord); not a traditional own sign (BUG-1211).
RAHU_KETU_OWNED_SIGN_INDEX = {'Rahu': 10, 'Ketu': 7}
# Planets that form the solar / lunar neighbour yogas (BUG-1187, BUG-1206).
FIVE_TRUE_PLANETS = ('Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
# PyJHora const.house_strengths_of_planets, copied to make Yoga rule alignment deterministic.
# Strength scale: 5 own/lord, 4 exalted, 3 friend, 2 neutral, 1 enemy, 0 debilitated.
def badhaka_house(ascendant_sign: str) -> Optional[int]:
"""Badhaka 宫的唯一定义:变动 11、固定 9、双体 7;上升未知返回 None。"""
if ascendant_sign in MOVABLE_SIGNS:
return 11
if ascendant_sign in FIXED_SIGNS:
return 9
if ascendant_sign in DUAL_SIGNS:
return 7
return None
HOUSE_STRENGTHS = {
'Sun': [4, 1, 2, 2, 5, 2, 0, 3, 3, 1, 1, 3],
'Moon': [2, 4, 3, 5, 3, 3, 2, 0, 2, 2, 2, 2],
'Mars': [5, 2, 1, 0, 3, 1, 2, 5, 3, 4, 2, 3],
'Mercury': [2, 3, 5, 1, 3, 5, 3, 2, 2, 2, 2, 0],
'Jupiter': [3, 1, 1, 4, 3, 3, 1, 3, 5, 0, 2, 5],
'Venus': [2, 5, 3, 1, 1, 0, 5, 2, 3, 3, 3, 4],
'Saturn': [0, 3, 3, 1, 1, 3, 4, 1, 2, 5, 5, 2],
'Rahu': [1, 4, 4, 1, 1, 3, 3, 0, 0, 3, 1, 3],
'Ketu': [1, 0, 0, 1, 1, 3, 3, 4, 4, 3, 1, 3],
}
# ── English twin for combination text (TASK-report-english-edition-20260929) ──
# The Chinese combination strings stay exactly as before; each one also carries
# an English rendering so the reader's English edition never has to translate
# Chinese back. `BiText` compares, hashes and formats as its Chinese text.
class BiText(str):
"""A Chinese string that also carries its English twin in ``.en``."""
en: str
def __new__(cls, zh, en=None):
obj = super().__new__(cls, zh)
obj.en = str(zh) if en is None else str(en)
return obj
def _en_text(value) -> str:
return value.en if isinstance(value, BiText) else str(value)
def _planet(p) -> BiText:
return BiText(PLANET_CN.get(p, p), p)
def _planets(ps) -> BiText:
ps = list(ps)
return BiText("、".join(PLANET_CN.get(p, p) for p in ps), ", ".join(str(p) for p in ps))
def _bijoin(sep: str, parts) -> BiText:
kept = [part for part in parts if part]
return BiText(sep.join(str(part) for part in kept), sep.join(_en_text(part) for part in kept))
DEFAULT_COMBO_EN = {
"{a}与{b}同在第{h}宫": "{a} and {b} together in house {h}",
"{p}在第{h}宫": "{p} in house {h}",
"{p}在第{h}宫(从{fp}的{rel})": "{p} in house {h} ({rel} from {fp})",
"{p}{dignity}在{sign}": "{p} {dignity} in {sign}",
"{count}颗行星满足条件": "{count} planets meet the condition",
"行星分布在{count}个宫位": "Planets spread over {count} houses",
"第{target}宫被夹击": "House {target} hemmed in",
"所有指定行星均落入{houses}": "All listed planets fall in houses {houses}",
"所有指定行星均落入候选宫位集合{houses}": "All listed planets fall in the house set {houses}",
"指定行星只占据{houses}": "Listed planets occupy only houses {houses}",
"指定行星只占据候选宫位集合{houses}": "Listed planets occupy only the house set {houses}",
"第{house}宫有{planets}": "House {house} holds {planets}",
"{planets}在{fp}的第{rel}宫": "{planets} in house {rel} from {fp}",
"{count}个目标宫位有指定行星": "{count} target houses hold listed planets",
"{a}与{b}在{sign}内相距{gap:.2f}°": "{a} and {b} within {gap:.2f}° in {sign}",
"{la}与{lb}互换星座": "{la} and {lb} exchange signs",
"{house}宫主{lord}在第{lh}宫": "Lord of house {house}, {lord}, in house {lh}",
"{la}与{lb}同在第{h}宫": "{la} and {lb} together in house {h}",
"自定义条件满足": "Custom condition met",
}
OFFSET_NAME_EN = {0: "same house", 1: "2nd", 2: "3rd", 3: "4th (Kendra)", 4: "5th (Trine)",
5: "6th", 6: "7th (Kendra)", 7: "8th", 8: "9th (Trine)",
9: "10th (Kendra)", 10: "11th", 11: "12th"}
STRENGTH_EN = {
"强": "Strong", "中": "Moderate", "弱": "Weak", "待评估": "To be assessed", "观察": "Watch",
"部分减弱": "partly weakened",
# 产品 2026-11-07 同步占星师 Yoga 规则批次:structural_observation 的强度栏(上游 733a931b)。
"observation": "Observation",
}
# Chinese wording for the English ``note`` of semantic (BVR / PyJHora batch)
# conditions. The note stays the English twin. One note maps to one Chinese
# sentence, so the dedup key keeps the same equality classes (BUG-1418).
_SEMANTIC_NOTES_ZH_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "references", "yoga_semantic_notes_zh.json")
# A condition without a note used to print its type key. Keep one fixed
# sentence per type so two matches of the same type still dedupe together.
_SEMANTIC_TYPE_ZH = {
"planet_in_house": "行星落在规则指定的宫位",
"planet_in_lagna": "行星落在上升宫",
}
@functools.lru_cache(maxsize=1)
def _semantic_notes_zh() -> Dict[str, str]:
try:
with open(_SEMANTIC_NOTES_ZH_PATH, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
return {str(key): str(value) for key, value in data.items()} if isinstance(data, dict) else {}
def _semantic_text(cond: Dict, ctype: str):
"""``cond.get("note", ctype)`` with Chinese text; the old value stays as ``.en``."""
if "note" in cond:
note = cond.get("note")
zh = _semantic_notes_zh().get(str(note)) if note else None
return BiText(zh, note) if zh else note
zh = _SEMANTIC_TYPE_ZH.get(str(ctype))
return BiText(zh, ctype) if zh else ctype
def _template_text(zh: str, en) -> BiText:
"""``BiText(zh, en)``; a rule template written only in English gets its Chinese sentence (BUG-1418)."""
if not any("\u4e00" <= char <= "\u9fff" for char in zh):
chinese = _semantic_notes_zh().get(zh)
if chinese:
return BiText(chinese, zh if en is None else en)
return BiText(zh, en)
def _combo(cond: Dict, default_zh: str, **values) -> BiText:
"""Format the rule's combination template in Chinese (unchanged) and in English."""
zh = cond.get("combo_template", default_zh).format(**values)
en_template = cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN.get(default_zh)
en = None
if isinstance(en_template, str):
try:
en = en_template.format(**{key: (value.en if isinstance(value, BiText) else value) for key, value in values.items()})
except (KeyError, IndexError, ValueError):
en = None
return _template_text(zh, en)
def _get_dignity_level(planet: str, sign: str) -> str:
"""判断行星在某星座的尊严等级"""
if EXALTATION.get(planet) == sign:
return 'EXALTED'
if SIGN_LORDS.get(sign) == planet:
return 'OWN_SIGN'
if MOOLATRIKONA_SIGN.get(planet) == sign:
return 'MOOLATRIKONA'
if DEBILITATION.get(planet) == sign:
return 'DEBILITATED'
return 'NEUTRAL'
# ============================================================================
# Yoga formation contracts (upstream yinduzhanxing 1307bf69, her
# scripts/yoga_output_guardrails.py, latest handoff). 产品 2026-11-07 同步占星师
# Yoga 规则批次. Kept in this module: the scripts/ root .py budget does not grow.
# In this product the Pancha Mahapurusha and Neecha Bhanga JSON rules are
# reference_only and run on the main detectors (BUG-1207, BUG-1205), so only
# Mahabhagya reaches a contract today.
# ============================================================================
def yoga_formation_contract(rule_id: str) -> dict[str, Any] | None:
"""Reviewed definitions, not outcome admission or a second detector.
Exact logic matching prevents an edited or broader local predicate from
inheriting a source's authority merely by retaining its rule id.
"""
for planet, name, page, line in (
('Mars', 'Ruchaka Yoga', 129, 2610),
('Mercury', 'Bhadra Yoga', 129, 2616),
('Saturn', 'Sasa Yoga', 129, 2622),
('Venus', 'Malavya Yoga', 130, 2632),
('Jupiter', 'Hamsa Yoga', 130, 2638),
):
if rule_id in {f'mahapurusha_{planet.lower()}', f'mahapurusha_{planet.lower()}_own'}:
return {
'profile': 'pvr_11_4_d1_lagna', 'name': name, 'planet': planet,
'source': {'title': 'PVR, Vedic Astrology: An Integrated Approach',
'page': page, 'line': line, 'section': '11.4',
'path': 'references/book_corpus/books/PVR_Integrated_Approach.md'},
'definition': {'type': 'and', 'conditions': [
{'type': 'in_houses', 'planet': planet, 'houses': [1, 4, 7, 10]},
{'type': 'dignity', 'planet': planet,
'dignity': 'own' if rule_id.endswith('_own') else 'exalted'}]},
'required': ['d1_scope', 'consistent_geometry', 'kendra', 'dignity'],
'variant_boundary': 'PVR 的 D1 上升角宫定义;不代替从月亮或三方宫起算的其他版本。'
+ (' Hamsa 段落正文误写 Ruchaka,名称按 11.4.5 标题与木星条件识别,原文不改写。' if planet == 'Jupiter' else ''),
}
if rule_id == 'mahabhagya_yoga':
return {
'profile': 'light_on_life_p294_295', 'name': 'Maha Bhagya Yoga',
'source': {'title': 'Light on Life', 'page': '294–295', 'line': 11010,
'path': 'references/book_corpus/books/Light on Life An Introduction to the Astrology of India (Hart de Fouw, Robert Svoboda) (Z-Library).md'},
'definition': {'type': 'mahabhagya'},
'required': ['d1_scope', 'consistent_geometry', 'sign_parity', 'gender', 'birth_is_daytime'],
'variant_boundary': '保留来源中的男女/昼夜分支;性别或昼夜缺失时不推定。',
}
if rule_id in {'neechabhanga_basic', 'neechabhanga_kendra'}:
royal = rule_id == 'neechabhanga_kendra'
return {
'profile': 'light_on_life_p295',
# 原值:'Neecha Bhanga Raja Yoga' if royal else 'Neecha Bhanga'
# 新值:royal → 'Neecha Bhanga Cancellation (Kendra/Trikona Placement)',否则 'Neecha Bhanga'
# 原因:产品 2026-11-07 同步占星师 Yoga 规则批次;BUG-1205 裁定落陷取消不得称 Raja Yoga;
# 交接 263092b3(rulings.md A4)给角宫/三方宫一支的同名称,不含 Raja。
'name': 'Neecha Bhanga Cancellation (Kendra/Trikona Placement)' if royal else 'Neecha Bhanga',
'source': {'title': 'Light on Life', 'page': 295, 'line': 11018,
'path': 'references/book_corpus/books/Light on Life An Introduction to the Astrology of India (Hart de Fouw, Robert Svoboda) (Z-Library).md'},
'definition': {'type': 'neecha_bhanga', 'require_royal_placement': royal},
'required': ['d1_scope', 'consistent_geometry', 'debilitation', 'cancellation'] +
(['royal_placement'] if royal else []),
'variant_boundary': '只采用此书四项化解条件及落陷星角/三方宫附加条件;交换变体不混入,化解不等于擢升。',
}
return None
def _formation_logic(value: Any) -> Any:
if isinstance(value, dict):
# 原值:key != 'combo_template'
# 新值:也去掉 'combo_template_en'
# 原因:产品 2026-11-07 同步占星师 Yoga 规则批次;本仓规则带英文模板孪生字段,只是显示文本,不是成立条件。
return {key: _formation_logic(item) for key, item in value.items()
if key not in ('combo_template', 'combo_template_en')}
if isinstance(value, list):
return [_formation_logic(item) for item in value]
return value
def assess_yoga_formation(row: dict[str, Any]) -> dict[str, Any]:
"""Re-evaluate permissions at the presentation boundary; never trust a flag."""
contract = yoga_formation_contract(str(row.get('rule_id', '')))
evidence = row.get('condition_evidence') or {}
checks = row.get('formation_checks') or []
checks = [check for check in checks if isinstance(check, dict)]
by_id = {check.get('id'): check for check in checks}
required = contract['required'] if contract else []
satisfied = [key for key in required if by_id.get(key, {}).get('passed') is True
and by_id[key].get('witness') not in (None, '', [], {})]
failed = [key for key in required if by_id.get(key, {}).get('passed') is False]
missing = [key for key in required if key not in satisfied and key not in failed]
issues = []
if not contract:
issues.append('source_definition_not_reviewed')
elif (row.get('formation_source_profile') != contract['profile'] or
_formation_logic(evidence.get('definition')) != contract['definition']):
issues.append('source_definition_mismatch')
if len(by_id) != len(checks):
issues.append('duplicate_condition_evidence')
if row.get('claim_kind') == 'structural_observation':
issues.append('structural_observation_not_named_yoga')
statuses = {str(row.get(key) or '') for key in
('condition_status', 'formation_status', 'evidence_status', 'claim_status')}
# 交接 263092b3:not_implemented 与 evaluation_error 同样不得放行。
restricted = statuses & {'blocked', 'evaluation_error', 'not_implemented', 'incomplete_conditions', 'combustion_requires_review', 'method_requires_review'}
if (row.get('method_review') or {}).get('status') == 'pending':
restricted.add('method_requires_review')
if restricted or row.get('missing_inputs'):
issues.extend(sorted(restricted))
issues.extend(row.get('missing_inputs') or [])
formed = bool(contract and not issues and not missing and not failed)
return {
'status': 'formed_under_source' if formed else 'conditions_not_met' if failed else 'candidate',
'name': contract['name'] if contract else row.get('name'),
'source_profile': contract['profile'] if contract else None,
'source': dict(contract['source']) if contract else {},
'required_conditions': list(required), 'satisfied_conditions': satisfied,
'missing_conditions': missing, 'failed_conditions': failed,
'review_issues': issues, 'checks': checks,
'variant_boundary': contract['variant_boundary'] if contract else
'原始规则保留,来源定义与必要条件尚未逐项核对;不等于格局不存在。',
'outcome_admitted': False,
}
# ============================================================================
# YogaContext: 封装星盘数据,提供查询接口
# ============================================================================
class YogaContext:
"""Yoga 检测上下文。封装行星数据并提供各类查询方法。"""
def __init__(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None):
self.planets = planets # name -> {sign, house, degree, ...}
self.ascendant = ascendant
self.asc_idx = SIGNS.index(ascendant) if ascendant in SIGNS else 0
self.context = context or {}
self.d9 = self.context.get("d9", {}) if isinstance(self.context, dict) else {}
self.d60 = self.context.get("d60", {}) if isinstance(self.context, dict) else {}
self.panchanga = self.context.get("panchanga", {}) if isinstance(self.context, dict) else {}
self.upagraha = self.context.get("upagraha", {}) if isinstance(self.context, dict) else {}
# 预计算 D1 宫主星。BUG-1211 (ruling 甲12): the main detection path uses the
# fixed traditional lords (Scorpio -> Mars, Aquarius -> Saturn), as the lord
# chain, functional roles and the frontend do. The v6.0.39 PyJHora "stronger
# co-lord" resolver (_stronger_co_lord) is kept for Jaimini variants only and
# runs only when a caller asks for dynamic_co_lords=True.
self._house_lords: Dict[int, str] = {}
for h in range(1, 13):
sign = SIGNS[(self.asc_idx + h - 1) % 12]
self._house_lords[h] = self._resolve_sign_lord(sign)
# 预计算 D9 宫主星(用于依赖 Navamsa 的 B.V. Raman Yoga)
d9_asc = self.d9.get("ascendant")
d9_asc_idx = SIGNS.index(d9_asc) if d9_asc in SIGNS else None
self._d9_house_lords: Dict[int, str] = {}
if d9_asc_idx is not None:
for h in range(1, 13):
sign = SIGNS[(d9_asc_idx + h - 1) % 12]
self._d9_house_lords[h] = self._resolve_sign_lord(sign)
# 预计算 D60 宫主星(用于依赖 Shashtiamsa 的 B.V. Raman Yoga)
d60_asc = self.d60.get("ascendant")
d60_asc_idx = SIGNS.index(d60_asc) if d60_asc in SIGNS else None
self._d60_house_lords: Dict[int, str] = {}
if d60_asc_idx is not None:
for h in range(1, 13):
sign = SIGNS[(d60_asc_idx + h - 1) % 12]
self._d60_house_lords[h] = self._resolve_sign_lord(sign)
def _sign_index_of_planet(self, planet: str) -> Optional[int]:
sign = self.sign_of(planet)
return SIGNS.index(sign) if sign in SIGNS else None
def _rasi_drishti_sign_indices_from(self, sign_idx: int) -> List[int]:
sign_name = SIGNS[sign_idx]
if sign_name in MOVABLE_SIGNS:
return [i for i in [1, 4, 7, 10] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
if sign_name in FIXED_SIGNS:
return [i for i in [0, 3, 6, 9] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
return [SIGNS.index(s) for s in DUAL_SIGNS if SIGNS.index(s) != sign_idx]
def _stronger_co_lord(self, planet1: str, planet2: str) -> str:
"""Approximate PyJHora stronger_planet_from_planet_positions() for Sc/Aq co-lords.
Jaimini-variant only (BUG-1211): reached only through
`_resolve_sign_lord(..., dynamic_co_lords=True)`; no main-path caller.
RAHU_KETU_OWNED_SIGN_INDEX is this comparison's internal convention, not a
traditional own sign.
"""
h1 = self._sign_index_of_planet(planet1)
h2 = self._sign_index_of_planet(planet2)
if h1 is None:
return planet2
if h2 is None:
return planet1
node_owned_sign = RAHU_KETU_OWNED_SIGN_INDEX.get(planet1) or RAHU_KETU_OWNED_SIGN_INDEX.get(planet2)
if node_owned_sign is not None:
if h1 == node_owned_sign and h2 != node_owned_sign:
return planet2
if h2 == node_owned_sign and h1 != node_owned_sign:
return planet1
planet1_co_count = sum(1 for p in self.planets if self._sign_index_of_planet(p) == h1) - 1
planet2_co_count = sum(1 for p in self.planets if self._sign_index_of_planet(p) == h2) - 1
if planet1_co_count > planet2_co_count:
return planet1
if planet2_co_count > planet1_co_count:
return planet2
dispositor1 = self._resolve_sign_lord(SIGNS[h1], dynamic_co_lords=False)
dispositor2 = self._resolve_sign_lord(SIGNS[h2], dynamic_co_lords=False)
support1 = sum(
self._sign_index_of_planet(p) == h1
for p in ['Mercury', 'Jupiter', dispositor1]
if p in self.planets
)
support1 += sum(
p in self.planets and self._sign_index_of_planet(p) in self._rasi_drishti_sign_indices_from(h1)
for p in ['Mercury', 'Jupiter', dispositor1]
)
support2 = sum(
self._sign_index_of_planet(p) == h2
for p in ['Mercury', 'Jupiter', dispositor2]
if p in self.planets
)
support2 += sum(
p in self.planets and self._sign_index_of_planet(p) in self._rasi_drishti_sign_indices_from(h2)
for p in ['Mercury', 'Jupiter', dispositor2]
)
if support1 > support2:
return planet1
if support2 > support1:
return planet2
strength1 = HOUSE_STRENGTHS.get(planet1, [None] * 12)[h1]
strength2 = HOUSE_STRENGTHS.get(planet2, [None] * 12)[h2]
if strength1 == 4 and strength2 is not None and strength1 > strength2:
return planet1
if strength2 == 4 and strength1 is not None and strength2 > strength1:
return planet2
def modality_rank(sign_idx: int) -> int:
sign_name = SIGNS[sign_idx]
if sign_name in DUAL_SIGNS:
return 3
if sign_name in FIXED_SIGNS:
return 2
return 1
rank1 = modality_rank(h1)
rank2 = modality_rank(h2)
if rank1 > rank2:
return planet1
if rank2 > rank1:
return planet2
degree1 = self.degree_of(planet1)
degree2 = self.degree_of(planet2)
if degree1 is not None and degree2 is not None:
return planet1 if degree1 > degree2 else planet2
return planet1
def _resolve_sign_lord(self, sign: str, dynamic_co_lords: bool = False) -> Optional[str]:
if not dynamic_co_lords:
return SIGN_LORDS.get(sign)
if sign == 'Scorpio':
return self._stronger_co_lord('Mars', 'Ketu')
if sign == 'Aquarius':
return self._stronger_co_lord('Saturn', 'Rahu')
return SIGN_LORDS.get(sign)
# --- 基础查询 ---
def house_of(self, planet: str) -> Optional[int]:
return self.planets.get(planet, {}).get("house")
def sign_of(self, planet: str) -> Optional[str]:
return self.planets.get(planet, {}).get("sign")
def degree_of(self, planet: str) -> Optional[float]:
return self.planets.get(planet, {}).get("degree")
def lord_of_house(self, house: int) -> Optional[str]:
return self._house_lords.get(house)
def planets_in_house(self, house: int) -> List[str]:
return [p for p, info in self.planets.items() if info.get("house") == house]
# --- D9 / Panchanga / Upagraha 扩展上下文 ---
def d9_house_of(self, planet: str) -> Optional[int]:
return self.d9.get("planets", {}).get(planet, {}).get("house")
def d9_sign_of(self, planet: str) -> Optional[str]:
return self.d9.get("planets", {}).get(planet, {}).get("sign")
def d9_lord_of_house(self, house: int) -> Optional[str]:
return self._d9_house_lords.get(house)
def navamsa_dispositor(self, planet: str) -> Optional[str]:
sign = self.d9_sign_of(planet)
return self._resolve_sign_lord(sign) if sign else None
# --- D60 / Shashtiamsa 扩展上下文 ---
def d60_house_of(self, planet: str) -> Optional[int]:
return self.d60.get("planets", {}).get(planet, {}).get("house")
def d60_sign_of(self, planet: str) -> Optional[str]:
return self.d60.get("planets", {}).get(planet, {}).get("sign")
def d60_lord_of_house(self, house: int) -> Optional[str]:
return self._d60_house_lords.get(house)
def shashtiamsa_dispositor(self, planet: str) -> Optional[str]:
sign = self.d60_sign_of(planet)
return self._resolve_sign_lord(sign) if sign else None
# --- D9 尊严查询(用于跨分盘 Yoga 判断) ---
def is_exalted_in_d9(self, planet: str) -> bool:
s = self.d9_sign_of(planet)
return s is not None and EXALTATION.get(planet) == s
def is_debilitated_in_d9(self, planet: str) -> bool:
s = self.d9_sign_of(planet)
return s is not None and DEBILITATION.get(planet) == s
def is_own_sign_in_d9(self, planet: str) -> bool:
s = self.d9_sign_of(planet)
return s is not None and self._resolve_sign_lord(s) == planet
def is_moolatrikona_in_d9(self, planet: str) -> bool:
s = self.d9_sign_of(planet)
return s is not None and MOOLATRIKONA_SIGN.get(planet) == s
def dignity_in_d9(self, planet: str) -> str:
s = self.d9_sign_of(planet)
return _get_dignity_level(planet, s) if s else 'NEUTRAL'
def is_friendly_navamsa(self, planet: str) -> bool:
"""Check if planet is in friendly sign in D9 (used by dharidhra method1)."""
s = self.d9_sign_of(planet)
if not s:
return False
lord = self._resolve_sign_lord(s)
return lord in FRIENDLY_PLANETS.get(planet, [])
def is_unfriendly_navamsa(self, planet: str) -> bool:
"""Check if planet is in unfriendly/enemy sign in D9."""
s = self.d9_sign_of(planet)
if not s:
return False
lord = self._resolve_sign_lord(s)
# Enemy if not friendly and not self
if lord == planet:
return False
return lord not in FRIENDLY_PLANETS.get(planet, [])
# --- Functional Malefic / Benefic(基于月亮盈亏和宫主关系) ---
def functional_malefics(self) -> List[str]:
"""Ownership roles only; natural nature is a separate rule predicate."""
try:
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
except ImportError:
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30。
return derive_functional_benefic_malefic(
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_malefics']
def functional_benefics(self) -> List[str]:
"""Use the same named ownership profile as the API and report."""
try:
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
except ImportError:
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
benefics = derive_functional_benefic_malefic(
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_benefics']
return [p for p in benefics if p in self.planets]
# --- Shashtiamsa / Vaiseshikamsa 简化评分 ---
def is_shashtiamsa_evil(self, planet: str) -> bool:
"""
Simplified evil shashtiamsa check.
In D60, if planet is debilitated, in enemy sign, or in dusthana house → evil.
"""
s = self.d60_sign_of(planet)
h = self.d60_house_of(planet)
if s is None or h is None:
return False
debil = DEBILITATION.get(planet) == s
lord = self._resolve_sign_lord(s)
enemy = lord not in FRIENDLY_PLANETS.get(planet, []) and lord != planet
dusthana = h in [6, 8, 12]
return debil or enemy or dusthana
def is_shashtiamsa_good(self, planet: str) -> bool:
"""Good shashtiamsa: exalted, own sign, or moolatrikona in D60."""
s = self.d60_sign_of(planet)
if s is None:
return False
return EXALTATION.get(planet) == s or self._resolve_sign_lord(s) == planet or MOOLATRIKONA_SIGN.get(planet) == s
def vaiseshikamsa_score(self, planet: str) -> int:
"""
Simplified Vaiseshikamsa scoring (0-20 scale, higher = better).
Combines D1 dignity + D9 dignity + D60 dignity.
"""
score = 0
# D1 dignity (0-8)
d1_dignity = self.dignity(planet)
d1_scores = {'EXALTED': 8, 'OWN_SIGN': 7, 'MOOLATRIKONA': 6, 'NEUTRAL': 3, 'DEBILITATED': 0}
score += d1_scores.get(d1_dignity, 3)
# D9 dignity (0-7)
d9_dignity = self.dignity_in_d9(planet)
score += d1_scores.get(d9_dignity, 3) - 1 # slightly lower weight
# D60 dignity (0-5)
s60 = self.d60_sign_of(planet)
if s60:
d60_dignity = _get_dignity_level(planet, s60)
d60_scores = {'EXALTED': 5, 'OWN_SIGN': 4, 'MOOLATRIKONA': 3, 'NEUTRAL': 2, 'DEBILITATED': 0}
score += d60_scores.get(d60_dignity, 2)
return score
def tithi(self) -> Optional[int]:
return self.panchanga.get("tithi")
def is_waning_moon(self) -> bool:
return bool(self.panchanga.get("is_waning_moon"))
def upagraha_house(self, name: str) -> Optional[int]:
payload = self.upagraha.get(str(name).lower(), {})
return payload.get("house")
def upagraha_sign(self, name: str) -> Optional[str]:
payload = self.upagraha.get(str(name).lower(), {})
return payload.get("sign")
# --- 尊严 ---
def is_exalted(self, planet: str) -> bool:
s = self.sign_of(planet)
return s is not None and EXALTATION.get(planet) == s
def is_own_sign(self, planet: str) -> bool:
s = self.sign_of(planet)
return s is not None and self._resolve_sign_lord(s) == planet
def is_moolatrikona(self, planet: str) -> bool:
s = self.sign_of(planet)
return s is not None and MOOLATRIKONA_SIGN.get(planet) == s
def is_debilitated(self, planet: str) -> bool:
s = self.sign_of(planet)
return s is not None and DEBILITATION.get(planet) == s
def dignity(self, planet: str) -> str:
s = self.sign_of(planet)
return _get_dignity_level(planet, s) if s else 'NEUTRAL'
# --- 宫位性质 ---
def is_kendra(self, house: int) -> bool:
return house in [1, 4, 7, 10]
def is_trikona(self, house: int) -> bool:
return house in [1, 5, 9]
def is_dusthana(self, house: int) -> bool:
return house in [6, 8, 12]
def is_upachaya(self, house: int) -> bool:
return house in [3, 6, 10, 11]
def is_maraka(self, house: int) -> bool:
return house in [2, 7]
def is_badhaka(self, house: int) -> bool:
"""Badhaka 宫:变动上升 11 宫,固定上升 9 宫,双体上升 7 宫。"""
return badhaka_house(self.ascendant) == house
# --- 相对宫位关系 ---
def house_offset(self, from_house: int, to_house: int) -> int:
"""从 from_house 到 to_house 的宫位偏移 (0=同宫, 1=下一宫, ...)"""
return (to_house - from_house) % 12
def is_kendra_from(self, planet: str, from_planet: str) -> bool:
h1 = self.house_of(from_planet)
h2 = self.house_of(planet)
if h1 is None or h2 is None:
return False
return self.house_offset(h1, h2) in [0, 3, 6, 9]
def is_trikona_from(self, planet: str, from_planet: str) -> bool:
h1 = self.house_of(from_planet)
h2 = self.house_of(planet)
if h1 is None or h2 is None:
return False
return self.house_offset(h1, h2) in [0, 4, 8]
def is_2nd_or_12th_from(self, planet: str, from_planet: str) -> bool:
h1 = self.house_of(from_planet)
h2 = self.house_of(planet)
if h1 is None or h2 is None:
return False
return self.house_offset(h1, h2) in [1, 11]
# --- 角色解析 ---
def resolve(self, ref: Any) -> List[str]:
"""
解析行星引用为具体行星名称列表。
支持: 字符串行星名、角色对象、角色字符串。
"""
if isinstance(ref, str):
if ref in self.planets:
return [ref]
if ref == "benefics":
return [p for p in BENEFICS if p in self.planets]
if ref == "malefics":
return [p for p in MALEFICS if p in self.planets]
if ref == "all":
return list(self.planets.keys())
if ref in ("visible", "sun_to_saturn"):
return [p for p in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] if p in self.planets]
if ref == "kendra_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [1, 4, 7, 10]]))
if ref == "trikona_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [1, 5, 9]]))
if ref == "dusthana_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [6, 8, 12]]))
if ref == "upachaya_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [3, 6, 10, 11]]))
if ref.startswith("lord:"):
house = int(ref.split(":")[1])
lord = self._house_lords.get(house)
return [lord] if lord else []
return []
if isinstance(ref, dict):
role = ref.get("role")
if role == "lord":
house = ref.get("house")
lord = self._house_lords.get(house)
return [lord] if lord else []
if role == "kendra_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [1, 4, 7, 10]]))
if role == "trikona_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [1, 5, 9]]))
if role == "dusthana_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [6, 8, 12]]))
if role == "upachaya_lords":
return list(dict.fromkeys([self._house_lords[h] for h in [3, 6, 10, 11]]))
if role == "benefics":
return [p for p in BENEFICS if p in self.planets]
if role == "malefics":
return [p for p in MALEFICS if p in self.planets]
if role == "all":
return list(self.planets.keys())
if role in ("visible", "sun_to_saturn"):
return [p for p in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] if p in self.planets]
if role == "set":
return [p for p in ref.get("planets", []) if p in self.planets]
return []
def resolve_single(self, ref: Any) -> Optional[str]:
"""解析为单个行星,返回 None 或行星名"""
lst = self.resolve(ref)
return lst[0] if lst else None
# ============================================================================
# custom 规则表达式的编译缓存(BUG-735)
# ============================================================================
# `expr` 只来自仓库内的静态规则表 references/yoga_rules.json,进程生命周期内不变,
# 改前却每个请求重新编译一遍(本机实测 386 次/次检测:182 次 eval 字符串编译 +
# 204 次 ast.parse/compile)。这里只缓存 code object。
#
# 硬约束:求值命名空间 exec_globals 依赖每次不同的盘上下文(ctx),必须每次新建;
# 绝不缓存求值结果——缓存结果会让所有人拿到同一张盘的 yoga 判定。
#
# maxsize 512:规则表去重后 192 条表达式,留出增长余量,同时给缓存一个硬上界。
_CUSTOM_EXPR_CACHE_MAXSIZE = 512
def _capture_tail_expr(block):
"""把代码块末尾表达式改写为 __result__ = <expr>;递归处理 if/for/while 分支。"""
if not block:
return block
last = block[-1]
if isinstance(last, ast.Expr):
block[-1] = ast.Assign(targets=[ast.Name(id="__result__", ctx=ast.Store())], value=last.value)
elif isinstance(last, ast.If):
last.body = _capture_tail_expr(last.body)
last.orelse = _capture_tail_expr(last.orelse)
elif isinstance(last, (ast.For, ast.While)):
last.body = _capture_tail_expr(last.body)
last.orelse = _capture_tail_expr(last.orelse)
elif isinstance(last, ast.Try):
last.body = _capture_tail_expr(last.body)
last.orelse = _capture_tail_expr(last.orelse)
last.finalbody = _capture_tail_expr(last.finalbody)
for handler in last.handlers:
handler.body = _capture_tail_expr(handler.body)
return block
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
def _compiled_eval_code(expr):
"""表达式模式的 code object;不是单个 expression 时返回 None(改前由 SyntaxError 表达)。"""
try:
return compile(expr, "<string>", "eval")
except SyntaxError:
return None
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
def _compiled_exec_code(expr):
"""多语句模式的 code object:先按 AST 捕获末尾表达式,再编译。
v6.0.32: 支持多行语句(if/else/for 等)+ 末尾表达式求值模式。
不能简单 split 最后一行:多行 if/else 的最后一行经常只是 else 分支内部表达式,
直接 exec 前半段会产生缩进不完整。这里用 AST 捕获“实际执行分支”的末尾表达式。
"""
try:
tree = ast.parse(expr.strip(), mode="exec")
tree.body = _capture_tail_expr(tree.body)
ast.fix_missing_locations(tree)
return compile(tree, "<yoga_custom>", "exec")
except Exception: # noqa: BLE001 - compile failure; _run_custom_expr raises YogaRuleEvaluationError
return None
class YogaRuleEvaluationError(Exception):
"""A rule could not be evaluated (占星师第三轮审查 2026-11-07, item 1).
Distinct from "condition false": the caller records the rule as
`evaluation_failed` with the error class, never as not formed.
"""
def __init__(self, error_class: str, message: str = ""):
super().__init__(f"{error_class}: {message}" if message else error_class)
self.error_class = error_class
self.error_message = message
class UnsupportedYogaCondition(YogaRuleEvaluationError):
"""A rule names condition types no evaluator implements (rulings.md A3).
Raised by the preflight in `YogaEngine._eval_rule` before any branch is
evaluated, so AND short-circuits, a computable OR branch or a NOT inversion
cannot turn a missing implementation into "formed" or "not formed".
"""
def __init__(self, conditions):
self.conditions = sorted(set(conditions))
super().__init__("UnsupportedConditionType", ", ".join(self.conditions))
def evaluation_failure_row(rule: Dict, exc: BaseException) -> Dict:
"""The record of one rule that could not be evaluated on this chart."""
error_class = getattr(exc, "error_class", None) or type(exc).__name__
message = getattr(exc, "error_message", None)
if message is None:
message = str(exc)
return {
"rule_id": rule.get("id", ""),
"name": rule.get("name", ""),
"name_cn": rule.get("name_cn", ""),
# 交接 8c775cd0(r40):诊断行保留规则分类
"category": rule.get("category") or "unclassified_rule",
"condition_status": "evaluation_failed",
"error_class": error_class,
"error_message": str(message)[:160],
"claim_boundary": "Evaluation failed; this is not evidence that the yoga is absent.",
"claim_boundary_zh": "规则求值失败,不能当作该格局不成立。",
}
@functools.lru_cache(maxsize=1)
def _shadbala_mercury_rule():
"""shadbala.mercury_malefic_companions, imported on first use.
#166 (BUG-1624): the strict Gajakesari supporter check reuses the one
implementation of the Mercury-by-association rule (BPHS 3.10-11 as
labelled by shadbala.MERCURY_NATURE_CONVENTION) instead of re-deciding it.
Imported lazily because shadbala pulls in swisseph; no import cycle
(shadbala imports only varga / ayanamsa_utils).
"""
try: # scripts/ on sys.path
from shadbala import mercury_malefic_companions
except ImportError: # package import (scripts.yoga_engine)
from scripts.shadbala import mercury_malefic_companions
return mercury_malefic_companions
def _run_custom_expr(expr, exec_globals):
"""按改前语义求值 custom 表达式:先表达式模式,SyntaxError 退到多语句 exec 模式。
只有 code object 来自缓存;`exec_globals` 由调用方每次新建。
求值或编译失败抛 YogaRuleEvaluationError(第三轮审查 2026-11-07:改前返回 None,
失败被当成「条件不成立」)。
"""
result = None
code = _compiled_eval_code(expr)
if code is not None:
try:
# 同一命名空间同时作为 globals/locals,保证 list/dict comprehension 能访问 BENEFICS 等名称。
result = eval(code, exec_globals, exec_globals)
except SyntaxError:
code = None # 运行期 SyntaxError:与改前一样退到多语句分支
except Exception as exc: # noqa: BLE001 - surfaced as evaluation_failed, never as "not formed"
raise YogaRuleEvaluationError(type(exc).__name__, str(exc)) from exc
if code is None:
exec_code = _compiled_exec_code(expr)
if exec_code is None:
raise YogaRuleEvaluationError("SyntaxError", "custom expression does not compile")
try:
exec(exec_code, exec_globals, exec_globals)
result = exec_globals.get("__result__")
except Exception as exc: # noqa: BLE001 - surfaced as evaluation_failed, never as "not formed"
raise YogaRuleEvaluationError(type(exc).__name__, str(exc)) from exc
return result
# ============================================================================
# YogaEngine: 规则加载与检测
# ============================================================================
# Lunar / solar duplicates of the BVR precise rules (bvr_002..004, bvr_016..018),
# retired so a chart is not judged twice by two definitions (sync4 T5; same
# six ids as upstream yinduzhanxing 2dd6062a RETIRED_RULE_IDS). The other four
# (Chakra / Vajra Nabhasa, Kalanidhi, Dala) were removed upstream in e628118d /
# 8787d9ea: the Nabhasa pair counted occupied kendras / dusthanas instead of the
# classical sign pattern, Kalanidhi tested aspects as house differences, and
# Dala counted dusthana lords in kendras (product 2026-11-07 engine sync).
RETIRED_RULE_IDS = frozenset({
'voshi_yoga', 'veshi_yoga', 'ubhayachari_yoga', 'anapha_yoga',
'sunapha_yoga', 'durudhura_yoga', 'chakra_nabhasa', 'vajra_nabhasa',
'kalanidhi_yoga', 'dala_yoga',
})
_STRUCTURAL_CONDITION_TYPES = frozenset({"compound", "compound_conditions"})
_NOT_AN_IMPLEMENTATION = frozenset({"placeholder"})
def _rule_condition_types(node) -> set:
"""Every condition `type` (or legacy `op`) named in a rule's logic tree."""
found = set()
if isinstance(node, dict):
ctype = node.get("type") or node.get("op")
if isinstance(ctype, str) and ctype:
found.add(ctype)
for value in node.values():
if isinstance(value, (dict, list)):
found |= _rule_condition_types(value)
elif isinstance(node, list):
for value in node:
found |= _rule_condition_types(value)
return found
@functools.lru_cache(maxsize=1)
def _supported_condition_types() -> Optional[frozenset]:
"""Condition types some evaluator handles: `_eval_<type>` methods plus the
literal `ctype == "..."` / `ctype in (...)` branches of
`_eval_semantic_condition`. Read from the code itself so the list cannot
drift; None when the source is unavailable (runtime check still applies).
"""
handlers = {name[len("_eval_"):] for name in dir(YogaEngine) if name.startswith("_eval_")}
try:
tree = ast.parse(textwrap.dedent(inspect.getsource(YogaEngine._eval_semantic_condition)))
except (OSError, TypeError, SyntaxError):
return None
semantic = set()
for node in ast.walk(tree):
if isinstance(node, ast.Compare) and isinstance(node.left, ast.Name) and node.left.id == "ctype":
for op, comparator in zip(node.ops, node.comparators):
if isinstance(op, ast.Eq) and isinstance(comparator, ast.Constant):
semantic.add(comparator.value)
elif isinstance(op, ast.In) and isinstance(comparator, (ast.Tuple, ast.List, ast.Set)):
semantic |= {e.value for e in comparator.elts if isinstance(e, ast.Constant)}
# 原值:`ctype == "placeholder"`(直接 return [])也算已实现
# 新值:placeholder 不算实现,引用它的规则列为 not_implemented
# 原因:占星师 2026-11-07 rulings.md A3 / C7(交接 263092b3):71 条含未实现条件,
# 占位分支返回空等于把「没写」当成「不成立」(bvr_apakeerthi_yoga)
return frozenset((handlers | semantic | _STRUCTURAL_CONDITION_TYPES) - _NOT_AN_IMPLEMENTATION)
def _not_implemented_row(rule: Dict, missing) -> Dict:
return {
"rule_id": rule.get("id", ""),
"name": rule.get("name", ""),
"name_cn": rule.get("name_cn", ""),
# 交接 8c775cd0(r40):诊断行保留规则分类
"category": rule.get("category") or "unclassified_rule",
"condition_status": "not_implemented",
"formation_status": "not_implemented",
"error_class": "UnsupportedConditionType",
"missing_condition_types": sorted(set(missing)),
"unsupported_conditions": sorted(set(missing)),
"effects": [], "card_eligible": False, "card_status": "blocked",
"claim_boundary": "No evaluator exists for this rule; it is neither formed nor ruled out.",
"claim_boundary_zh": "该规则的条件类型尚无求值实现,既不算成立也不算不成立。",
}
class YogaEngine:
"""Yoga 检测引擎。从 JSON 加载规则,对星盘执行条件匹配。"""
def __init__(self, rules_path: str):
with open(rules_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.schema_version = data.get("schema_version", "1.0")
self.rules = [r for r in data.get("rules", []) if r.get("enabled", True)
and r.get("id") not in RETIRED_RULE_IDS
and not r.get("reference_only")]
# `reference_only` rules stay in the registry with their source and
# logic but are never evaluated: the yoga they name has one main
# detector (scripts/main_yoga_detectors.py; astrologer rulings
# 2026-10-03), whose rows `detect` appends below.
self.reference_only_rule_ids = sorted(
r.get("id") for r in data.get("rules", []) if r.get("reference_only"))
# Rules the last `detect` call could not evaluate (第三轮审查 2026-11-07).
self.last_evaluation_failures: List[Dict] = []
# Rules naming a condition type no evaluator implements. Before
# 2026-11-07 they silently returned "not formed" on every chart; they
# are now listed as `not_implemented` and never evaluated.
self.unimplemented_rules: List[Dict] = []
# Rules found unsupported at evaluation time by the last `detect` call
# (e.g. a rule list replaced after loading); same row shape.
self.last_unimplemented: List[Dict] = []
if _supported_condition_types() is not None:
kept = []
for rule in self.rules:
missing = self.unsupported_conditions(rule.get("logic", {}))
if missing:
self.unimplemented_rules.append(_not_implemented_row(rule, missing))
else:
kept.append(rule)
self.rules = kept
# 原值:reference_only 规则不扫描(69 条)
# 新值:也扫描并列出(带 reference_only=true),仍不求值;主检测器结果不替它成立
# 原因:占星师 2026-11-07 rulings.md A3 / C5:bvr_maalavya_yoga 计入 71 条未实现
reference_only = [r for r in data.get("rules", []) if r.get("enabled", True)
and r.get("id") not in RETIRED_RULE_IDS and r.get("reference_only")]
for rule in reference_only:
missing = self.unsupported_conditions(rule.get("logic", {}))
if missing:
self.unimplemented_rules.append(dict(_not_implemented_row(rule, missing), reference_only=True))
def unsupported_conditions(self, node) -> List[str]:
"""Condition types in any branch of `node` that no evaluator implements.
Walks the logical children only (`conditions`, `condition`), as the
astrologer's `YogaEngine.unsupported_conditions` (263092b3) does, so a
binding object such as `{"type": "resolve_single"}` is not a condition.
"""
supported = _supported_condition_types()
if supported is None:
return []
if not isinstance(node, dict):
return ['invalid_condition_node']
ctype = node.get('type') or node.get('op')
missing = []
if ctype not in supported:
missing.append(str(ctype or 'missing_condition_type'))
for child in node.get('conditions', []) or []:
missing.extend(self.unsupported_conditions(child))
if 'condition' in node:
missing.extend(self.unsupported_conditions(node['condition']))
return sorted(set(missing))
def _attach_interpretive_evidence(self, entry, ctx):
"""Expose executed conditions and partial strength facts, not a score.
Upstream yinduzhanxing 1307bf69 (产品 2026-11-07 同步占星师 Yoga 规则批次):
`formation_checks` are the executed source conditions of a reviewed
contract (empty when the rule has none), `formation_assessment`
re-evaluates them, and `strength_factors` lists D1/D9 dignity,
lordship, combustion and retrograde facts without composing a score.
"""
planets_bound = entry.get('planets', [])
contract = yoga_formation_contract(entry['rule_id'])
checks = []
def check(key, passed, witness):
checks.append({'id': key, 'passed': passed, 'witness': witness})
if contract:
entry['formation_source_profile'] = contract['profile']
chart_type = ctx.context.get('chart_type')
check('d1_scope', chart_type == 'D1' if chart_type else None,
{'chart_type': chart_type, 'reference': 'Lagna'})
positions = {p: {'sign': ctx.sign_of(p), 'house': ctx.house_of(p)} for p in planets_bound}
valid = ctx.ascendant in SIGNS and bool(positions)
geometry = None
if valid and all(v['sign'] in SIGNS and type(v['house']) is int for v in positions.values()):
geometry = all((SIGNS.index(v['sign']) - ctx.asc_idx) % 12 + 1 == v['house']
for v in positions.values())
check('consistent_geometry', geometry, {'ascendant': ctx.ascendant, 'positions': positions})
if contract.get('planet'):
planet = contract['planet']
check('kendra', ctx.house_of(planet) in (1, 4, 7, 10), positions.get(planet))
check('dignity', ctx.is_own_sign(planet) if entry['rule_id'].endswith('_own') else
ctx.is_exalted(planet), {'planet': planet, 'sign': ctx.sign_of(planet)})
elif entry['rule_id'] == 'mahabhagya_yoga':
values = entry.get('condition_checks') or {}
parity = values.get('sign_parity')
check('sign_parity', parity in ('odd', 'even'),
{'parity': parity, 'ascendant': ctx.ascendant, 'positions': positions})
gender, day = values.get('gender'), values.get('birth_is_daytime')
check('gender', None if gender is None else gender == ('male' if parity == 'odd' else 'female'),
{'gender': gender})
check('birth_is_daytime', None if type(day) is not bool else day == (parity == 'odd'),
{'birth_is_daytime': day})
else:
evidence = entry.get('formation_evidence') or {}
planet = evidence.get('planet')
check('debilitation', bool(planet) and ctx.is_debilitated(planet),
{'planet': planet, 'sign': ctx.sign_of(planet)})
check('cancellation', bool(evidence.get('cancellation_reasons')),
evidence.get('cancellation_reasons'))
if entry['rule_id'] == 'neechabhanga_kendra':
check('royal_placement', ctx.house_of(planet) in (1, 4, 5, 7, 9, 10),
{'planet': planet, 'house': ctx.house_of(planet)})
entry['formation_checks'] = checks
entry['formation_assessment'] = assess_yoga_formation(entry)
supporting, limiting, contextual = [], [], []
unassessed = {'functional_benefic_malefic', 'Shadbala', 'Vimsopaka',
'aspect_strength', 'event_timing', 'composite_yoga_strength'}
for planet in planets_bound:
data = ctx.planets.get(planet) or {}
sign = ctx.sign_of(planet)
if sign in SIGNS:
dignity = _get_dignity_level(planet, sign)
target = supporting if dignity in {'OWN_SIGN', 'EXALTED', 'MOOLATRIKONA'} else limiting if dignity == 'DEBILITATED' else contextual
target.append({'planet': planet, 'kind': 'd1_dignity', 'value': dignity,
'evidence': {'sign': sign, 'house': ctx.house_of(planet)},
'text': f'{planet}:D1 {sign},尊贵状态 {dignity}。'})
contextual.append({'planet': planet, 'kind': 'house_lordships',
'value': [h for h in range(1, 13) if ctx.lord_of_house(h) == planet],
'text': f'{planet} 的宫主职责须与自然属性分开判断。'})
if type(data.get('combust')) is bool:
(limiting if data['combust'] else contextual).append({
'planet': planet, 'kind': 'combustion', 'value': data['combust'],
'text': f'{planet}:输入标记为燃烧,效力需复核。' if data['combust'] else f'{planet}:输入标记为未燃烧。'})
else:
unassessed.add(f'{planet}.combustion')
if type(data.get('retrograde')) is bool:
contextual.append({'planet': planet, 'kind': 'retrograde', 'value': data['retrograde'],
'text': f'{planet}:逆行标记 {data["retrograde"]},不自动判作增强或削弱。'})
d9_sign = ctx.d9_sign_of(planet)
if d9_sign in SIGNS:
dignity = _get_dignity_level(planet, d9_sign)
target = supporting if dignity in {'OWN_SIGN', 'EXALTED', 'MOOLATRIKONA'} else limiting if dignity == 'DEBILITATED' else contextual
target.append({'planet': planet, 'kind': 'd9_dignity', 'value': dignity,
'text': f'{planet}:D9 {d9_sign},尊贵状态 {dignity}。'})
else:
unassessed.add(f'{planet}.D9')
entry['strength_factors'] = {
'status': 'partial_factors' if planets_bound else 'not_assessed',
'supporting_factors': supporting, 'limiting_factors': limiting,
'contextual_factors': contextual, 'unassessed_layers': sorted(unassessed),
'composite_strength_assessed': False,
}
def detect(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None) -> List[Dict]:
"""
检测 Yoga。
返回: [{name, name_cn, combination, effects, strength, source, bvr_id, category}, ...]
"""
ctx = YogaContext(planets, ascendant, context=context)
results = []
seen_dedup = set()
failures: List[Dict] = []
self.last_evaluation_failures = failures
unimplemented: List[Dict] = []
self.last_unimplemented = unimplemented
# 规则匹配。一条规则求值失败(异常)记为 evaluation_failed 并带错误类,
# 不得静默当成不成立(占星师第三轮审查 2026-11-07)。
for rule in self.rules:
try:
matches = self._eval_rule(rule, ctx)
except UnsupportedYogaCondition as exc:
unimplemented.append(_not_implemented_row(rule, exc.conditions))
continue
except Exception as exc: # noqa: BLE001 - recorded per rule, see evaluation_failure_row
failures.append(evaluation_failure_row(rule, exc))
continue
for m in matches:
combination = m.get("combination", "")
strength = m.get("strength", rule.get("strength", "中"))
entry = {
"name": rule["name"],
"name_cn": rule["name_cn"],
"combination": str(combination),
"effects": rule.get("effects", []),
"strength": strength,
"source": rule.get("source", ""),
"bvr_id": rule.get("bvr_id"),
"category": rule.get("category", ""),
"rule_id": rule.get("id", ""),
# BUG-1657: name the planets the rule actually bound, so reports
# can list participants and check current-Dasha overlap.
**({"planets": participants} if (participants := _bound_planets(m)) else {}),
**({"triggers": m["triggers"]} if m.get("triggers") else {}),
**({"triggers_zh": m["triggers_zh"]} if m.get("triggers_zh") else {}),
**({"canonical_mapping": rule["canonical_mapping"]} if rule.get("canonical_mapping") else {}),
# English twins for the reader's English edition; the Chinese
# fields above are unchanged.
"combination_en": _en_text(combination),
"effects_en": list(rule.get("effects_en") or rule.get("effects", [])),
"strength_en": rule.get("strength_en") if strength == rule.get("strength") and rule.get("strength_en") else STRENGTH_EN.get(str(strength), str(strength)),
}
# 产品 2026-11-07 同步占星师 Yoga 规则批次(上游 733a931b):
# a structural observation is a recorded lord association,
# placement or dignity, not a named Yoga; its former effects
# stay under `traditional_effects` and `effects` is the
# rule's own boundary sentence.
entry['source_reference'] = rule.get('source_reference', rule.get('source', ''))
entry['claim_kind'] = rule.get('claim_kind', 'named_yoga_candidate')
for key in ('legacy_name', 'traditional_effects', 'traditional_effects_en'):
if key in rule:
entry[key] = rule[key]
entry['condition_evidence'] = {
'rule_id': rule.get('id'),
'definition': rule.get('logic'),
'source_reference': entry['source_reference'],
'participants': {p: {'sign': ctx.sign_of(p), 'house': ctx.house_of(p)}
for p in entry.get('planets', [])},
}
if m.get('formation_evidence'):
entry['formation_evidence'] = m['formation_evidence']
if entry['claim_kind'] == 'structural_observation':
entry['strength'] = 'observation'
entry['strength_en'] = STRENGTH_EN['observation']
# A rule-library grade is not calculated Shadbala or a
# probability of the promised outcome.
entry['strength_basis'] = 'rule_library_grade_not_chart_strength'
# Budha-Aditya is a conjunction witness, but a combust
# Mercury must not be presented as an unqualified full yoga
# (upstream yinduzhanxing 994638f0; sync5 U7).
if rule.get('id') == 'budhaditya_yoga':
mercury = ctx.planets.get('Mercury') or {}
if mercury.get('combust') is True:
entry['condition_status'] = 'combustion_requires_review'
entry['traditional_effects'] = entry['effects']
entry['effects'] = []
entry['strength'] = 'conditional'
entry['combustion_witness'] = {
'planet': 'Mercury',
'combust': True,
'claim_boundary': 'Do not treat Budha-Aditya as an unqualified benefic result while Mercury is combust.'
}
# Upstream e628118d: an evaluator that cannot check every
# condition (Mahabhagya without gender / day-night, Parihasaka
# without Vaiseshikamsa) returns a labelled candidate only.
if 'condition_status' in m:
entry.update({key: m[key] for key in
('condition_status', 'missing_inputs', 'condition_checks')})
if m['condition_status'] == 'incomplete_conditions':
entry['traditional_effects'] = entry['effects']
entry['effects'] = []
entry['effects_en'] = []
entry['strength'] = 'conditional'
entry['strength_en'] = 'Conditional'
self._attach_interpretive_evidence(entry, ctx)
dedup_key = rule.get("dedup_key") or rule["name"]
dedup_val = f"{dedup_key}:{m.get('combination', '')}"
if dedup_val in seen_dedup:
continue
seen_dedup.add(dedup_val)
results.append(entry)
# The algorithmic Veshi/Voshi/Ubhayachari/Sunapha/Anapha/Durudhura block
# duplicated bvr_002..004 / bvr_016..018 under other spellings; upstream
# e628118d removed it. The precise rules count the same five planets
# (BUG-1187 / BUG-1206), so nothing they ruled is lost.
# ── 主检测器(同名 JSON 规则为 reference_only) ──
results += main_detector_rows(ctx.planets, ctx.ascendant)
return results
# ------------------------------------------------------------------------
# 规则级评估
# ------------------------------------------------------------------------
def _eval_rule(self, rule: Dict, ctx: YogaContext) -> List[Dict]:
"""评估单条规则,返回匹配列表(空表示无匹配)"""
logic = rule.get("logic", {})
# 占星师 2026-11-07 rulings.md A3:先查所有分支的实现能力,再求值。
unsupported = self.unsupported_conditions(logic)
if unsupported:
raise UnsupportedYogaCondition(unsupported)
return self._eval_condition(logic, ctx, rule, {})
def _eval_parihasaka(self, cond, ctx, rule, bindings):
"""Raman no.156; do not substitute the heuristic three-chart score (upstream e628118d)."""
sun_d9 = ctx.d9_sign_of('Sun')
lord = SIGN_LORDS.get(sun_d9)
if lord and ctx.house_of(lord) not in (None, 2):
return []
# Explicit executed distinction is required, not an estimated score.
distinction = (ctx.context.get('vaiseshikamsa_confirmed') or {}).get(lord)
if distinction is False:
return []
missing = []
if not lord:
missing.append('Sun_D9_sign')
elif ctx.house_of(lord) is None:
missing.append('Sun_D9_dispositor_D1_house')
if type(distinction) is not bool:
missing.append('vaiseshikamsa_distinction')
return [{'combination': BiText('太阳Navamsa定位星在本命第2宫;需Vaiseshikamsa确认',
'Sun Navamsa dispositor in natal house 2; Vaiseshikamsa required'),
'condition_status': 'incomplete_conditions' if missing else 'conditions_met',
'missing_inputs': missing,
'condition_checks': {'sun_d9_dispositor': lord, 'vaiseshikamsa': distinction},
**({'participants': [lord]} if lord else {})}]
def _eval_mahabhagya(self, cond, ctx, rule, bindings):
"""Raman, Three Hundred Important Combinations, no. 25 (upstream e628118d).
Missing inputs preserve a labelled candidate only; a known failed
condition cannot be rescued by a different missing condition.
"""
signs = [ctx.ascendant, ctx.sign_of('Sun'), ctx.sign_of('Moon')]
if any(sign not in SIGNS for sign in signs):
return []
parities = {SIGNS.index(sign) % 2 for sign in signs}
if len(parities) != 1:
return []
odd = parities == {0}
required_gender, required_day = ('male', True) if odd else ('female', False)
raw_gender = str(ctx.context.get('gender') or '').strip().lower()
gender = {'m': 'male', 'male': 'male', '男': 'male',
'f': 'female', 'female': 'female', '女': 'female'}.get(raw_gender)
day = ctx.context.get('birth_is_daytime')
if type(day) is not bool:
day = None
if gender is not None and gender != required_gender:
return []
if day is not None and day != required_day:
return []
missing = (['gender'] if gender is None else []) + (['birth_is_daytime'] if day is None else [])
combo = (BiText('上升/太阳/月亮均在奇数星座;男性昼生一支',
'Ascendant/Sun/Moon in odd signs; male daytime branch') if odd else
BiText('上升/太阳/月亮均在偶数星座;女性夜生一支',
'Ascendant/Sun/Moon in even signs; female nighttime branch'))
return [{
'combination': combo,
'condition_status': 'incomplete_conditions' if missing else 'conditions_met',
'missing_inputs': missing,
'condition_checks': {'sign_parity': 'odd' if odd else 'even',
'gender': gender, 'birth_is_daytime': day},
'participants': ['Sun', 'Moon'],
}]
def _eval_condition(self, cond: Dict, ctx: YogaContext, rule: Dict,
bindings: Dict) -> List[Dict]:
"""评估条件节点,返回匹配列表"""
# v6.0.31: 兼容历史规则中的 "op" 字段。
# 早期批量扩展脚本误用 op 而不是 type;如果只读 type,会导致这些规则静默失效。
ctype = cond.get("type") or cond.get("op")
# v6.0.32: 兼容批量生成规则使用的 compound_conditions/compound 结构。
if ctype == "compound_conditions":
cond = {**cond, "type": "and"}
return self._eval_and(cond, ctx, rule, bindings)
if ctype == "compound":
operator = str(cond.get("operator", "AND")).lower()
cond = {**cond, "type": "or" if operator == "or" else "and"}
return self._eval_condition(cond, ctx, rule, bindings)
handler = getattr(self, f"_eval_{ctype}", None)
if handler:
return handler(cond, ctx, rule, bindings)
# v6.0.32: 兼容 PyJHora/BVR 批量规则中的语义型 condition type,
# 避免未知类型静默导致整条 compound rule 永远失效。
if ctype:
return self._eval_semantic_condition(ctype, cond, ctx, rule, bindings)
raise YogaRuleEvaluationError("MissingConditionType", "condition node has no type")
# ------------------------------------------------------------------------
# 复合条件
# ------------------------------------------------------------------------
def _eval_and(self, cond, ctx, rule, bindings):
"""AND: 所有子条件必须同时满足"""
subs = cond.get("conditions", [])
if not subs:
return [{"combination": "", "strength": rule.get("strength", "中")}]
results = self._eval_condition(subs[0], ctx, rule, bindings)
if not results:
return []
for sub in subs[1:]:
new_results = []
for match in results:
b = {**bindings, **match.get("bindings", {})}
sub_matches = self._eval_condition(sub, ctx, rule, b)
for sm in sub_matches:
combo = match.get("combination", "")
sm_combo = sm.get("combination", "")
merged_combo = _bijoin("; ", [combo, sm_combo])
new_results.append({
"combination": merged_combo,
"strength": sm.get("strength", match.get("strength")),
# Upstream e628118d: keep both sides' bindings, so a
# later condition binding the same key does not drop
# the earlier participants.
"bindings": {**match.get("bindings", {}), **sm.get("bindings", {}),
"_participants": [match.get("bindings", {}), sm.get("bindings", {})]},
**({"participants": list(match.get("participants") or []) + list(sm.get("participants") or [])}
if match.get("participants") or sm.get("participants") else {}),
**({"triggers": (match.get("triggers") or []) + (sm.get("triggers") or []),
"triggers_zh": (match.get("triggers_zh") or []) + (sm.get("triggers_zh") or [])}
if match.get("triggers") or sm.get("triggers") else {}),
})
results = new_results
if not results:
return []
return results
def _eval_or(self, cond, ctx, rule, bindings):
"""OR: 任一子条件满足即可"""
all_results = []
for sub in cond.get("conditions", []):
matches = self._eval_condition(sub, ctx, rule, bindings)
all_results.extend(matches)
return all_results
def _eval_not(self, cond, ctx, rule, bindings):
"""NOT: 子条件不满足时返回一个空匹配"""
matches = self._eval_condition(cond.get("condition", {}), ctx, rule, bindings)
return [] if matches else [{"combination": "", "strength": rule.get("strength", "中")}]
# ------------------------------------------------------------------------
# 单条件类型
# ------------------------------------------------------------------------
def _eval_same_house(self, cond, ctx, rule, bindings):
"""两颗行星同宫"""
# 支持两种写法:
# 1) {"a": ..., "b": ...}
# 2) {"planets": ["Sun", "Mercury", ...]}:任意两颗同宫即满足
if "planets" in cond and "a" not in cond and "b" not in cond:
planets = cond.get("planets", [])
pa = self._resolve_binding({"role": "set", "planets": planets}, ctx, bindings)
pb = pa
else:
pa = self._resolve_binding(cond.get("a"), ctx, bindings)
pb = self._resolve_binding(cond.get("b"), ctx, bindings)
results = []
for a in pa:
for b in pb:
if a == b:
continue
h1 = ctx.house_of(a)
h2 = ctx.house_of(b)
if h1 is not None and h1 == h2:
combo = _combo(cond, "{a}与{b}同在第{h}宫", a=_planet(a), b=_planet(b), h=h1, a_raw=a, b_raw=b)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"a": a, "b": b, "house": h1}})
return results
def _eval_in_houses(self, cond, ctx, rule, bindings):
"""行星在指定宫位列表中"""
refs = self._resolve_binding(cond.get("planet", "$planet"), ctx, bindings)
houses = cond.get("houses", [])
exclude = cond.get("exclude", [])
results = []
for p in refs:
if p in exclude:
continue
h = ctx.house_of(p)
if h is not None and h in houses:
combo = _combo(cond, "{p}在第{h}宫", p=_planet(p), h=h, p_raw=p)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "house": h}})
return results
def _eval_not_in_houses(self, cond, ctx, rule, bindings):
"""行星不在指定宫位列表中"""
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
houses = cond.get("houses", [])
results = []
for p in refs:
h = ctx.house_of(p)
if h is not None and h not in houses:
results.append({"combination": "", "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "house": h}})
return results
def _eval_in_houses_from(self, cond, ctx, rule, bindings):
"""行星在指定行星/基准的相对宫位关系中"""
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
from_ref = cond.get("from")
relations = cond.get("relations", [])
results = []
# 解析 relations(支持字符串别名和整数偏移)
valid_offsets = set()
for r in relations:
if r == "kendra":
valid_offsets.update([0, 3, 6, 9])
elif r == "trikona":
valid_offsets.update([0, 4, 8])
elif r == "2nd_12th":
valid_offsets.update([1, 11])
elif r == "3rd_11th":
valid_offsets.update([2, 10])
elif r == "same":
valid_offsets.add(0)
elif isinstance(r, int):
valid_offsets.add(r)
for p in refs:
h1 = ctx.house_of(p)
if h1 is None:
continue
# from 可以是行星或固定宫位
if isinstance(from_ref, int):
h_base = from_ref
else:
from_planets = self._resolve_binding(from_ref, ctx, bindings)
for fp in from_planets:
h_base = ctx.house_of(fp)
if h_base is None:
continue
off = ctx.house_offset(h_base, h1)
if off in valid_offsets:
rel_name = BiText(self._offset_name(off), OFFSET_NAME_EN.get(off, f"{off + 1}th"))
combo = _combo(cond, "{p}在第{h}宫(从{fp}的{rel})", p=_planet(p), h=h1, fp=_planet(fp),
rel=rel_name, off=off, p_raw=p, fp_raw=fp)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "from": fp, "house": h1}})
return results
def _eval_dignity(self, cond, ctx, rule, bindings):
"""行星尊严检查"""
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
dignity_type = cond.get("dignity") # exalted, own, moolatrikona, debilitated
results = []
for p in refs:
ok = False
if dignity_type == "exalted":
ok = ctx.is_exalted(p)
elif dignity_type == "own":
ok = ctx.is_own_sign(p)
elif dignity_type == "moolatrikona":
ok = ctx.is_moolatrikona(p)
elif dignity_type == "debilitated":
ok = ctx.is_debilitated(p)
elif dignity_type == "strong":
ok = ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)
elif dignity_type == "not_debilitated":
ok = not ctx.is_debilitated(p)
elif dignity_type == "not_enemy":
ok = True # placeholder, not used in current rules
# v6.0.31: 兼容历史规则中的 status 字段。
# 批量规则曾使用 {"op":"dignity", "status":"exalted"},而引擎原本只读取 dignity。
if dignity_type is None:
status = cond.get("status")
if status == "exalted":
ok = ctx.is_exalted(p)
dignity_type = status
elif status == "own":
ok = ctx.is_own_sign(p)
dignity_type = status
elif status == "moolatrikona":
ok = ctx.is_moolatrikona(p)
dignity_type = status
elif status == "debilitated":
ok = ctx.is_debilitated(p)
dignity_type = status
elif status == "strong":
ok = ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)
dignity_type = status
if ok:
sign = ctx.sign_of(p)
combo = _combo(cond, "{p}{dignity}在{sign}", p=_planet(p), dignity=dignity_type, sign=sign or "", p_raw=p)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planet": p, "sign": sign}})
return results
def _eval_count(self, cond, ctx, rule, bindings):
"""统计满足条件的行星数量"""
refs = self._resolve_binding(cond.get("items", cond.get("planets")), ctx, bindings)
sub_cond = cond.get("condition", {})
min_count = cond.get("min", 1)
max_count = cond.get("max")
matched = []
for p in refs:
# 临时把 planet 绑定为 p,评估子条件
sub_bindings = {**bindings, "planet": p}
matches = self._eval_condition(sub_cond, ctx, rule, sub_bindings)
if matches:
matched.append(p)
count = len(matched)
ok = count >= min_count
if max_count is not None:
ok = ok and count <= max_count
if ok:
combo = _combo(cond, "{count}颗行星满足条件", count=count, planets=_planets(matched))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"count": count, "matched": matched}}]
return []
def _eval_houses_occupied(self, cond, ctx, rule, bindings):
"""行星占据的宫位数量"""
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
occupied = set()
for p in refs:
h = ctx.house_of(p)
if h is not None:
occupied.add(h)
count = len(occupied)
target = cond.get("count")
min_count = cond.get("min")
max_count = cond.get("max")
ok = False
if target is not None:
ok = count == target
else:
ok = True
if min_count is not None:
ok = ok and count >= min_count
if max_count is not None:
ok = ok and count <= max_count
if ok:
combo = _combo(cond, "行星分布在{count}个宫位", count=count)
return [{"combination": combo, "strength": rule.get("strength", "中"), "participants": list(refs)}]
return []
def _eval_adjacent_houses(self, cond, ctx, rule, bindings):
"""目标宫位的前/后宫位有指定行星"""
target = cond.get("house")
sides = cond.get("sides", ["prev", "next"])
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
require_both = cond.get("both", False)
prev_h = ((target - 2) % 12) + 1
next_h = (target % 12) + 1
prev_ok = any(ctx.house_of(p) == prev_h for p in refs) if "prev" in sides else True
next_ok = any(ctx.house_of(p) == next_h for p in refs) if "next" in sides else True
if require_both:
ok = prev_ok and next_ok
else:
ok = prev_ok or next_ok
if ok:
combo = _combo(cond, "第{target}宫被夹击", target=target, prev=prev_h, next=next_h)
flanking = [p for p in refs if ctx.house_of(p) in (prev_h, next_h)]
return [{"combination": combo, "strength": rule.get("strength", "中"), "participants": flanking}]
return []
def _eval_all_in_houses(self, cond, ctx, rule, bindings):
"""所有指定行星都在给定宫位集合中。"""
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
houses = set(cond.get("houses", []))
if not refs or not houses:
return []
occupied = [ctx.house_of(p) for p in refs]
ok = all(h is not None and h in houses for h in occupied) # upstream e628118d: an unplaced planet fails
if ok:
combo = _combo(cond, "所有指定行星均落入{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses)}}]
return []
def _eval_all_in_house_sets(self, cond, ctx, rule, bindings):
"""所有指定行星都在多个候选宫位集合之一中。"""
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
house_sets = cond.get("house_sets", [])
if not refs or not house_sets:
return []
for houses in house_sets:
houses_set = set(houses)
occupied = [ctx.house_of(p) for p in refs]
ok = all(h is not None and h in houses_set for h in occupied) # upstream e628118d
if ok:
combo = _combo(cond, "所有指定行星均落入候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
return []
def _eval_occupied_houses_exact(self, cond, ctx, rule, bindings):
"""指定行星实际占据的宫位集合与目标集合完全一致。"""
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
houses = set(cond.get("houses", []))
occupied = {ctx.house_of(p) for p in refs if ctx.house_of(p) is not None}
if refs and occupied == houses:
combo = _combo(cond, "指定行星只占据{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses)}}]
return []
def _eval_occupied_houses_exact_sets(self, cond, ctx, rule, bindings):
"""指定行星实际占据的宫位集合等于候选集合之一。"""
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
house_sets = cond.get("house_sets", [])
occupied = {ctx.house_of(p) for p in refs if ctx.house_of(p) is not None}
for houses in house_sets:
houses_set = set(houses)
if refs and occupied == houses_set:
combo = _combo(cond, "指定行星只占据候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(refs))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
return []
def _eval_has_planet_in_house(self, cond, ctx, rule, bindings):
"""指定行星集合中至少一颗在某宫。"""
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
house = cond.get("house")
matched = [p for p in refs if ctx.house_of(p) == house]
if matched:
combo = _combo(cond, "第{house}宫有{planets}", house=house, planets=_planets(matched))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"matched": matched, "house": house}}]
return []
def _eval_has_planet_in_house_from(self, cond, ctx, rule, bindings):
"""指定行星集合中至少一颗在某参考行星的相对宫位。houses 使用传统 1-12 口径。"""
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
from_planet = cond.get("from") or cond.get("from_planet")
from_list = self._resolve_binding(from_planet, ctx, bindings)
houses = cond.get("houses", [])
exclude = set(cond.get("exclude", []))
results = []
for fp in from_list:
base = ctx.house_of(fp)
if base is None:
continue
for rel_house in houses:
target = ((base + rel_house - 2) % 12) + 1
matched = [p for p in refs if p not in exclude and p != fp and ctx.house_of(p) == target]
if matched:
combo = _combo(cond, "{planets}在{fp}的第{rel}宫",
planets=_planets(matched),
fp=_planet(fp),
rel=rel_house,
target=target,
)
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"matched": matched, "from": fp, "rel_house": rel_house, "house": target}})
return results
def _eval_houses_with_planets_count(self, cond, ctx, rule, bindings):
"""在指定宫位集合中,有多少个宫位至少包含一颗指定集合行星。"""
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
houses = cond.get("houses", [])
min_count = cond.get("min", 1)
max_count = cond.get("max")
occupied = []
for h in houses:
matched = [p for p in refs if ctx.house_of(p) == h]
if matched:
occupied.append((h, matched))
count = len(occupied)
ok = count >= min_count
if max_count is not None:
ok = ok and count <= max_count
if ok:
detail = _bijoin("; ", [BiText(f"第{h}宫:" + _planets(ps), f"house {h}: " + _planets(ps).en)
for h, ps in occupied])
combo = _combo(cond, "{count}个目标宫位有指定行星", count=count, detail=detail,
houses="/".join(str(h) for h, _ in occupied))
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"occupied": occupied, "count": count}, "participants": list(refs)}]
return []
def _eval_degree_gap(self, cond, ctx, rule, bindings):
"""同星座内两颗行星度数差小于阈值"""
pa = self._resolve_binding(cond.get("a"), ctx, bindings)
pb = self._resolve_binding(cond.get("b"), ctx, bindings)
max_gap = cond.get("max_gap", 1.0)
results = []
for a in pa:
for b in pb:
if a == b:
continue
s1 = ctx.sign_of(a)
s2 = ctx.sign_of(b)
if s1 is None or s1 != s2:
continue
d1 = ctx.degree_of(a)
d2 = ctx.degree_of(b)
if d1 is None or d2 is None:
continue
if abs(d1 - d2) < max_gap:
combo = _combo(cond, "{a}与{b}在{sign}内相距{gap:.2f}°", a=_planet(a), b=_planet(b), sign=s1,
gap=abs(d1 - d2))
results.append({"combination": combo, "strength": rule.get("strength", "中"),
"participants": [a, b]})
return results
def _eval_parivartana(self, cond, ctx, rule, bindings):
"""两颗宫主星互换星座(Parivartana/互落)"""
# BUG-1572: inside any_pair the lords arrive as "$a" / "$b"; ctx.resolve
# knows no bindings, so raja_yoga_parivartana never fired. Only the bare
# "$x" form is bound here.
def single(ref):
if isinstance(ref, str) and ref.startswith("$"):
found = self._resolve_binding(ref, ctx, bindings)
return found[0] if found else None
return ctx.resolve_single(ref)
la = single(cond.get("lord_a"))
lb = single(cond.get("lord_b"))
if la is None or lb is None or la == lb:
return []
sa = ctx.sign_of(la)
sb = ctx.sign_of(lb)
if sa is None or sb is None:
return []
lord_of_sa = SIGN_LORDS.get(sa)
lord_of_sb = SIGN_LORDS.get(sb)
if lord_of_sa == lb and lord_of_sb == la:
combo = _combo(cond, "{la}与{lb}互换星座", la=_planet(la), lb=_planet(lb))
return [{"combination": combo, "strength": rule.get("strength", "强"), "participants": [la, lb]}]
return []
def _eval_any_pair(self, cond, ctx, rule, bindings):
"""从集合 A 和集合 B 中各取一个元素,配对满足条件"""
if "planets" in cond and "from" not in cond and "to" not in cond:
set_a = self._resolve_binding({"role": "set", "planets": cond.get("planets", [])}, ctx, bindings)
set_b = set_a
exclude_self = True
else:
set_a = self._resolve_binding(cond.get("from"), ctx, bindings)
set_b = self._resolve_binding(cond.get("to"), ctx, bindings)
exclude_self = cond.get("exclude_self", False)
sub_cond = cond.get("condition", {})
results = []
for a in set_a:
for b in set_b:
if exclude_self and a == b:
continue
pair_bindings = {**bindings, "a": a, "b": b}
matches = self._eval_condition(sub_cond, ctx, rule, pair_bindings)
for m in matches:
m["bindings"] = {**m.get("bindings", {}), "a": a, "b": b}
results.extend(matches)
return results
def _eval_all_planets(self, cond, ctx, rule, bindings):
"""所有指定行星都满足某个条件"""
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
sub_cond = cond.get("condition", {})
all_match = True
matched_combo_parts = []
for p in refs:
b = {**bindings, "planet": p}
matches = self._eval_condition(sub_cond, ctx, rule, b)
if not matches:
all_match = False
break
matched_combo_parts.append(matches[0].get("combination", ""))
if all_match and refs:
combo = _bijoin("; ", matched_combo_parts)
return [{"combination": combo, "strength": rule.get("strength", "中"), "participants": list(refs)}]
return []
def _eval_any_planet(self, cond, ctx, rule, bindings):
"""任一指定行星满足条件"""
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
sub_cond = cond.get("condition", {})
for p in refs:
b = {**bindings, "planet": p}
matches = self._eval_condition(sub_cond, ctx, rule, b)
if matches:
return matches
return []
def _eval_exists(self, cond, ctx, rule, bindings):
"""存在至少一个行星满足条件"""
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
sub_cond = cond.get("condition", {})
for p in refs:
b = {**bindings, "planet": p}
matches = self._eval_condition(sub_cond, ctx, rule, b)
if matches:
return matches
return []
def _eval_lord_in_house(self, cond, ctx, rule, bindings):
"""某宫主星在指定宫位"""
house = cond.get("house")
target_houses = cond.get("target_houses", [])
lord = ctx.lord_of_house(house)
if lord is None or lord not in ctx.planets:
return []
lh = ctx.house_of(lord)
if lh is not None and lh in target_houses:
combo = _combo(cond, "{house}宫主{lord}在第{lh}宫", house=house, lord=_planet(lord), lh=lh)
return [{"combination": combo, "strength": rule.get("strength", "中"),
"bindings": {"lord": lord, "house": lh}}]
return []
def _eval_lords_relation(self, cond, ctx, rule, bindings):
"""两个宫主星之间的特定关系(同宫/互落等)"""
lord_a = ctx.resolve_single(cond.get("lord_a"))
lord_b = ctx.resolve_single(cond.get("lord_b"))
# Upstream e628118d: one planet ruling both houses is not a relation
# between two lords (Mars alone as 5th + 10th lord for Cancer rising).
if lord_a is None or lord_b is None or lord_a == lord_b:
return []
relation = cond.get("relation", "same_house")
if relation == "same_house":
ha = ctx.house_of(lord_a)
hb = ctx.house_of(lord_b)
if ha is not None and ha == hb:
combo = _combo(cond, "{la}与{lb}同在第{h}宫", la=_planet(lord_a), lb=_planet(lord_b), h=ha)
return [{"combination": combo, "strength": rule.get("strength", "中"),
"participants": [lord_a, lord_b]}]
return []
def _eval_custom(self, cond, ctx, rule, bindings):
"""自定义 Python 表达式(用于复杂规则)"""
expr = cond.get("expr", "")
if not expr:
return []
# BUG-735: exec_globals 每次新建(依赖当前盘 ctx),只有 code object 走进程级缓存。
exec_globals = self._build_custom_exec_globals(ctx, rule, bindings)
result = _run_custom_expr(expr, exec_globals)
if result:
combo = cond.get("combo_template", "自定义条件满足")
if isinstance(combo, str):
# Custom templates were never formatted; keep that, and pair the English text.
combo = _template_text(combo, cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN["自定义条件满足"])
if callable(combo):
combo = combo(ctx, bindings)
strength = cond.get("strength", rule.get("strength", "中"))
if callable(strength):
strength = strength(ctx, bindings)
# #166 Q6 (BUG-1597): a rule may set `triggers` (list of short strings)
# to name what actually fired; they are appended to the combination.
# An item is either one string (used in both editions) or a
# (Chinese, English) pair, so the Chinese report never carries English glue.
# BUG-1657: a custom rule may also set `participants` (planet names).
participants = exec_globals.get("participants")
# 产品 2026-11-07 同步占星师 Yoga 规则批次(上游 733a931b): a rule
# may name its matching witnesses with `participants_expr`
# (never every planet the failed branches queried).
if not (isinstance(participants, (list, tuple)) and participants) and cond.get("participants_expr"):
participants = [p for p in _flatten_planets(
_run_custom_expr(cond["participants_expr"], exec_globals), ctx)]
if not (isinstance(participants, (list, tuple)) and participants):
participants = _static_custom_participants(expr, ctx)
extra = {"participants": list(participants)} if participants else {}
triggers = exec_globals.get("triggers")
if isinstance(triggers, list) and triggers:
pairs = [(item[0], item[1]) if isinstance(item, (list, tuple)) and len(item) == 2 else (item, item) for item in triggers]
zh_items = [str(zh) for zh, _ in pairs]
en_items = [str(en) for _, en in pairs]
combo = BiText(f"{combo}:{';'.join(zh_items)}", f"{_en_text(combo)}: {'; '.join(en_items)}")
return [{"combination": combo, "strength": strength, "triggers": en_items, "triggers_zh": zh_items, **extra}]
return [{"combination": combo, "strength": strength, **extra}]
return []
def _build_custom_exec_globals(self, ctx, rule, bindings):
"""构造 custom 表达式的求值命名空间。每次调用都新建,绑定当前这张盘的 ctx。"""
def house_of(p): return ctx.house_of(p)
def sign_of(p): return ctx.sign_of(p)
def deg_of(p): return ctx.degree_of(p)
def lord(h): return ctx.lord_of_house(h)
def in_houses(p, hs): h = house_of(p); return h is not None and h in hs
def dignity(p): return ctx.dignity(p)
def exalted(p): return ctx.is_exalted(p)
def own(p): return ctx.is_own_sign(p)
def debil(p): return ctx.is_debilitated(p)
def moola(p): return ctx.is_moolatrikona(p)
def kendra(h): return ctx.is_kendra(h)
def trikona(h): return ctx.is_trikona(h)
def dusthana(h): return ctx.is_dusthana(h)
def upachaya(h): return ctx.is_upachaya(h)
def kendra_from(p, fp): return ctx.is_kendra_from(p, fp)
def trikona_from(p, fp): return ctx.is_trikona_from(p, fp)
def offset(fh, th): return ctx.house_offset(fh, th)
def planets_in(h): return ctx.planets_in_house(h)
def resolve(ref): return ctx.resolve(ref)
# v6.0.32: 修复 custom 规则中常用的缺失辅助函数
def same_house(a, b):
"""两颗行星是否同宫(允许自比较:同星恒为 True)"""
ha = ctx.house_of(a)
hb = ctx.house_of(b)
return ha is not None and ha == hb
def aspect(a, b):
"""行星a是否aspect行星b(7th aspect + Mars/Jupiter/Saturn的特殊aspect)"""
if a == b:
return False
# Upstream e628118d: in the shared Parashari profile Rahu / Ketu act
# by conjunction only (same reading as BUG-1207 / BUG-1625 here).
if a in {'Rahu', 'Ketu'}:
return False
ha = ctx.house_of(a)
hb = ctx.house_of(b)
if ha is None or hb is None:
return False
off = ctx.house_offset(ha, hb)
# 7th aspect for all planets
if off == 6:
return True
# Mars: 4th, 8th aspects
if a == 'Mars' and off in (3, 7):
return True
# Jupiter: 5th, 9th aspects
if a == 'Jupiter' and off in (4, 8):
return True
# Saturn: 3rd, 10th aspects
if a == 'Saturn' and off in (2, 9):
return True
return False
def aspects_house(p, h):
if p not in ctx.planets or h is None:
return False
if p in {'Rahu', 'Ketu'}: # upstream e628118d: nodes do not aspect
return False
return h == ctx.house_of(p) or offset(ctx.house_of(p), h) in (6, 3 if p == 'Mars' else -1, 7 if p == 'Mars' else -1, 4 if p == 'Jupiter' else -1, 8 if p == 'Jupiter' else -1, 2 if p == 'Saturn' else -1, 9 if p == 'Saturn' else -1)
def graha_aspects_house(p, h):
if p not in ctx.planets or h is None:
return False
if p in {'Rahu', 'Ketu'}: # upstream e628118d: nodes do not aspect
return False
return offset(ctx.house_of(p), h) in (6, 3 if p == 'Mars' else -1, 7 if p == 'Mars' else -1, 4 if p == 'Jupiter' else -1, 8 if p == 'Jupiter' else -1, 2 if p == 'Saturn' else -1, 9 if p == 'Saturn' else -1)
def external_benchmark_planets_aspecting_raasi(p, h):
"""Replicate PyJHora house.planets_aspecting_the_raasi() behavior for source parity."""
if p not in ctx.planets or h is None:
return False
sign_name = ctx.sign_of(p)
if sign_name not in SIGNS:
return False
target_rasi_idx = (ctx.asc_idx + h - 1) % 12
aspected_signs = rasi_drishti_signs_from(sign_name)
planet_ids_in_aspected_signs = [
PLANET_INDEX[q]
for q in ctx.planets
if q in PLANET_INDEX and ctx.sign_of(q) in aspected_signs
]
return target_rasi_idx in planet_ids_in_aspected_signs
def external_benchmark_aspected_planets_of_raasi(h):
"""Replicate PyJHora house.aspected_planets_of_the_raasi(): planets whose rasi drishti hits a target house."""
if h is None:
return []
target_sign = house_sign(h)
return [
p for p in ctx.planets
if p in PLANET_INDEX and target_sign in rasi_drishti_signs_from(ctx.sign_of(p))
]
def rasi_drishti_signs_from(sign_name):
if sign_name not in SIGNS:
return []
sign_idx = SIGNS.index(sign_name)
if sign_name in MOVABLE_SIGNS:
return [SIGNS[i] for i in [1, 4, 7, 10] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
if sign_name in FIXED_SIGNS:
return [SIGNS[i] for i in [0, 3, 6, 9] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
return [s for s in DUAL_SIGNS if s != sign_name]
def rasi_aspects_house(p, h):
if p not in ctx.planets or h is None:
return False
return house_sign(h) in rasi_drishti_signs_from(ctx.sign_of(p))
def rasi_aspects(a, b):
return a in ctx.planets and b in ctx.planets and rasi_aspects_house(a, ctx.house_of(b))
def rasi_aspected_by_planets(h):
return [p for p in ctx.planets if rasi_aspects_house(p, h)]
def sign_index_of_planet(p):
sign_name = ctx.sign_of(p)
return SIGNS.index(sign_name) if sign_name in SIGNS else None
def house_strength(p):
sign_idx = sign_index_of_planet(p)
if sign_idx is None or p not in HOUSE_STRENGTHS:
return None
strength = HOUSE_STRENGTHS[p][sign_idx]
sign_name = SIGNS[sign_idx]
# v6.0.39: PyJHora dynamic co-lord owner strength for Scorpio/Aquarius.
# Without this, Ketu/Rahu can become house lords but still look non-owner in strength checks.
if sign_name in ('Scorpio', 'Aquarius') and ctx._resolve_sign_lord(sign_name) == p:
return max(strength, 5)
return strength
def strong(p, include_neutral=False):
strength = house_strength(p)
if strength is None:
return False
return strength >= (2 if include_neutral else 3)
def weak(p):
strength = house_strength(p)
return strength is not None and strength <= 2
def combust(p):
"""#166 Q5: the chart's `combust` flag, else the direct-motion orb from the Sun."""
info = ctx.planets.get(p) or {}
if isinstance(info.get('combust'), bool):
return info['combust']
if p not in COMBUSTION_ORBS_DIRECT or 'Sun' not in ctx.planets:
return False
def lon(q):
sign, deg = ctx.sign_of(q), ctx.degree_of(q)
if sign not in SIGNS or deg is None:
return None
return SIGNS.index(sign) * 30.0 + float(deg) % 30.0
a, b = lon(p), lon('Sun')
if a is None or b is None:
return False
return abs(((a - b + 180.0) % 360.0) - 180.0) <= COMBUSTION_ORBS_DIRECT[p]
def in_natural_enemy_sign(p):
"""#166 Q5: the sign lord is a natural enemy of p (BPHS 3.55).
Natural (not five-fold) enmity is a named choice, 待核 (#166
astrologer 2026-11-05: the PVR strict definition does not say which).
"""
sign = ctx.sign_of(p)
return sign in SIGN_LORDS and SIGN_LORDS[sign] in NATURAL_ENEMIES.get(p, [])
def mercury_malefic_companions():
"""BUG-1624: natural malefics sharing Mercury's sign; non-empty = Mercury is a natural malefic.
Same rule and label as Drik Bala (shadbala.MERCURY_NATURE_CONVENTION):
Sun, Mars, Saturn and the waning Moon count, Rahu / Ketu do not.
"""
return list(_shadbala_mercury_rule()(ctx.planets))
def associated(a, b):
return a in ctx.planets and b in ctx.planets and (same_house(a, b) or aspect(a, b) or aspect(b, a))
def temporal_friend(a, b):
if a not in ctx.planets or b not in ctx.planets or a == b:
return False
# PyJHora temporary friends: 2/3/4/10/11/12 from a planet.
return offset(ctx.house_of(a), ctx.house_of(b)) in [1, 2, 3, 9, 10, 11]
def natural_friend(a, b):
return a in FRIENDLY_PLANETS.get(b, []) and b in FRIENDLY_PLANETS.get(a, [])
def occupants(h):
return ctx.planets_in_house(h) if h is not None else []
def only_benefics_in_house(h):
ps = occupants(h)
return bool(ps) and all(p in BENEFICS for p in ps)
def only_malefics_in_house(h):
ps = occupants(h)
return bool(ps) and all(p in MALEFICS for p in ps)
def house_has_benefic(h):
return any(p in BENEFICS for p in occupants(h))
def house_has_malefic(h):
return any(p in MALEFICS for p in occupants(h))
def house_sign(h):
if h is None:
return None
return SIGNS[(ctx.asc_idx + h - 1) % 12]
def movable_house(h):
return house_sign(h) in MOVABLE_SIGNS
def d9_house_of(p): return ctx.d9_house_of(p)
def d9_sign_of(p): return ctx.d9_sign_of(p)
def d9_lord_of_house(h): return ctx.d9_lord_of_house(h)
def navamsa_dispositor(p): return ctx.navamsa_dispositor(p)
def tithi(): return ctx.tithi()
def is_waning_moon(): return ctx.is_waning_moon()
def upagraha_house(name): return ctx.upagraha_house(name)
def upagraha_sign(name): return ctx.upagraha_sign(name)
def gulika_house(): return ctx.upagraha_house("gulika")
def maandi_house(): return ctx.upagraha_house("maandi")
def gulika_sign(): return ctx.upagraha_sign("gulika")
def maandi_sign(): return ctx.upagraha_sign("maandi")
def exal(p): return ctx.is_exalted(p)
def lord_of_house(h): return ctx.lord_of_house(h)
def sign(p): return ctx.sign_of(p)
def check_amala_from(base_house):
"""Amala: 第10宫仅有吉星,支持从Lagna/Moon等基准宫位计算。"""
target = ((base_house - 1 + 9) % 12) + 1
occupants = ctx.planets_in_house(target)
return bool(occupants) and all(p in BENEFICS for p in occupants)
def external_benchmark_natural_benefics():
"""Replicate PyJHora yoga._get_natural_benefics(): Jupiter, Venus, plus benefic Mercury."""
benefics = [p for p in ["Jupiter", "Venus"] if p in ctx.planets]
mercury_house = ctx.house_of("Mercury")
if mercury_house is not None:
mercury_alone = len(ctx.planets_in_house(mercury_house)) == 1
mercury_with_jupiter_or_venus = any(ctx.house_of(p) == mercury_house for p in ["Jupiter", "Venus"] if p in ctx.planets)
if mercury_alone or mercury_with_jupiter_or_venus:
benefics.append("Mercury")
return benefics
# 辅助:获取星盘所有行星名列表
def _planets_list():
return list(ctx.planets.keys())
# 辅助:获取所有宫主星(1-12宫)
def _all_lords():
return [ctx.lord_of_house(h) for h in range(1, 13)]
# 辅助:获取角宫主星列表
def _kendra_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# 辅助:获取三方宫主星列表
def _trikona_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
safe_globals = {
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
"all": all, "any": any, "len": len, "set": set, "tuple": tuple, "sorted": sorted,
"abs": abs, "min": min, "max": max, "sum": sum, "range": range,
"list": list, "bool": bool,
"ctx": ctx, "bindings": bindings, "rule": rule,
"SIGNS": SIGNS, "SIGN_LORDS": SIGN_LORDS,
"FRIENDLY_PLANETS": FRIENDLY_PLANETS,
"RAHU_KETU_OWNED_SIGN_INDEX": RAHU_KETU_OWNED_SIGN_INDEX,
"EXALTATION": EXALTATION, "DEBILITATION": DEBILITATION,
"PLANET_CN": PLANET_CN, "BENEFICS": BENEFICS, "MALEFICS": MALEFICS,
"MOVABLE_SIGNS": MOVABLE_SIGNS, "FIXED_SIGNS": FIXED_SIGNS, "DUAL_SIGNS": DUAL_SIGNS,
"PLANET_INDEX": PLANET_INDEX, "HOUSE_STRENGTHS": HOUSE_STRENGTHS,
"ALL_PLANETS": ALL_PLANETS, "MOOLATRIKONA_SIGN": MOOLATRIKONA_SIGN,
# 基础查询
"house_of": house_of, "sign_of": sign_of, "deg_of": deg_of,
"get_sign_of": sign_of, # 别名
"lord": lord, "lords_of": lord, # 别名(接受宫位数)
# 尊严检查
"dignity": dignity,
"exalted": exalted, "is_exalted": exalted,
"own": own, "is_own_sign": own,
"debil": debil, "is_debilitated": debil,
"moola": moola, "is_moolatrikona": moola,
# 宫位类型
"in_houses": in_houses, "planets_in": planets_in,
"kendra": kendra, "is_kendra": kendra,
"trikona": trikona, "is_trikona": trikona,
"dusthana": dusthana, "is_dusthana": dusthana,
"upachaya": upachaya, "is_upachaya": upachaya,
"kendra_from": kendra_from, "trikona_from": trikona_from,
"offset": offset,
# v6.0.32: 同宫与相位检查(custom规则常用)
"same_house": same_house, "aspect": aspect, "aspects_house": aspects_house,
"graha_aspects_house": graha_aspects_house,
"external_benchmark_planets_aspecting_raasi": external_benchmark_planets_aspecting_raasi,
"external_benchmark_aspected_planets_of_raasi": external_benchmark_aspected_planets_of_raasi,
"rasi_drishti_signs_from": rasi_drishti_signs_from,
"rasi_aspects_house": rasi_aspects_house, "rasi_aspects": rasi_aspects,
"rasi_aspected_by_planets": rasi_aspected_by_planets,
"house_strength": house_strength, "strong": strong, "weak": weak,
"associated": associated, "temporal_friend": temporal_friend, "natural_friend": natural_friend,
"combust": combust, "in_natural_enemy_sign": in_natural_enemy_sign,
"mercury_malefic_companions": mercury_malefic_companions,
"PLANET_ZH": PLANET_ZH,
"occupants": occupants, "only_benefics_in_house": only_benefics_in_house,
"only_malefics_in_house": only_malefics_in_house,
"house_has_benefic": house_has_benefic, "house_has_malefic": house_has_malefic,
"house_sign": house_sign, "movable_house": movable_house,
"external_benchmark_natural_benefics": external_benchmark_natural_benefics,
"d9_house_of": d9_house_of, "d9_sign_of": d9_sign_of,
"d9_lord_of_house": d9_lord_of_house, "navamsa_dispositor": navamsa_dispositor,
"tithi": tithi, "is_waning_moon": is_waning_moon,
"upagraha_house": upagraha_house, "upagraha_sign": upagraha_sign,
"gulika_house": gulika_house, "maandi_house": maandi_house,
"gulika_sign": gulika_sign, "maandi_sign": maandi_sign,
# v6.1.3: D60 / Shashtiamsa 扩展
"d60_house_of": lambda p: ctx.d60_house_of(p),
"d60_sign_of": lambda p: ctx.d60_sign_of(p),
"d60_lord_of_house": lambda h: ctx.d60_lord_of_house(h),
"shashtiamsa_dispositor": lambda p: ctx.shashtiamsa_dispositor(p),
"is_shashtiamsa_evil": lambda p: ctx.is_shashtiamsa_evil(p),
"is_shashtiamsa_good": lambda p: ctx.is_shashtiamsa_good(p),
# v6.1.3: D9 尊严扩展
"is_exalted_in_d9": lambda p: ctx.is_exalted_in_d9(p),
"is_debilitated_in_d9": lambda p: ctx.is_debilitated_in_d9(p),
"is_own_sign_in_d9": lambda p: ctx.is_own_sign_in_d9(p),
"is_moolatrikona_in_d9": lambda p: ctx.is_moolatrikona_in_d9(p),
"dignity_in_d9": lambda p: ctx.dignity_in_d9(p),
"is_friendly_navamsa": lambda p: ctx.is_friendly_navamsa(p),
"is_unfriendly_navamsa": lambda p: ctx.is_unfriendly_navamsa(p),
# v6.1.3: Functional malefic / benefic
"functional_malefics": lambda: ctx.functional_malefics(),
"functional_benefics": lambda: ctx.functional_benefics(),
# v6.1.3: Vaiseshikamsa scoring
"vaiseshikamsa_score": lambda p: ctx.vaiseshikamsa_score(p),
"exal": exal, "lord_of_house": lord_of_house, "sign": sign,
"check_amala_from": check_amala_from,
"Benefics": BENEFICS, "Malefics": MALEFICS,
"planets": ctx.planets,
# 解析
"resolve": resolve,
# 列表辅助
"planets_list": _planets_list,
"all_lords": _all_lords,
"kendra_lords_list": _kendra_lords_list,
"trikona_lords_list": _trikona_lords_list,
}
return {"__builtins__": {}, **safe_globals}
def _eval_semantic_condition(self, ctype, cond, ctx, rule, bindings):
"""兼容批量抽取规则中的语义型条件。返回保守布尔匹配。"""
def success(combo=None):
text = combo or _semantic_text(cond, ctype)
return [{"combination": text, "strength": rule.get("strength", "中")}]
def house(ref):
if isinstance(ref, int):
return ref
if isinstance(ref, str):
if ref in ctx.planets:
return ctx.house_of(ref)
low = ref.lower()
if low in ("lagna", "asc", "ascendant"):
return 1
if low == "moon":
return ctx.house_of("Moon")
return None
def planet_list(default=None):
ps = cond.get("planets", default or [])
if ps == "all":
return [p for p in ALL_PLANETS if p in ctx.planets]
if isinstance(ps, str):
return [ps] if ps in ctx.planets else []
return [p for p in ps if p in ctx.planets]
def aspects(a, b):
# b 可为行星名或宫位整数;支持检查行星对宫位/行星的传统相位。
ha = ctx.house_of(a)
hb = ctx.house_of(b) if isinstance(b, str) else b
if ha is None or hb is None or a == b:
return False
off = ctx.house_offset(ha, hb)
return off == 6 or (a == "Mars" and off in (3, 7)) or (a == "Jupiter" and off in (4, 8)) or (a == "Saturn" and off in (2, 9))
def related(a, b):
return a in ctx.planets and b in ctx.planets and (ctx.house_of(a) == ctx.house_of(b) or aspects(a, b) or aspects(b, a))
def house_lord(h):
return ctx.lord_of_house(h) if isinstance(h, int) else None
def is_benefic(p):
return p in BENEFICS
def is_malefic(p):
return p in MALEFICS
def is_strong(p):
return p in ctx.planets and (ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p) or ctx.is_kendra(ctx.house_of(p)) or ctx.is_trikona(ctx.house_of(p)))
def rel_house(base, offset0):
return ((base - 1 + offset0) % 12) + 1 if base else None
if ctype == "placeholder":
return []
if ctype == "planet_in_lagna":
p = cond.get("planet")
return success() if p in ctx.planets and ctx.house_of(p) == 1 else []
if ctype == "planet_in_house":
p = cond.get("planet")
h = cond.get("house")
return success() if p in ctx.planets and ctx.house_of(p) == h else []
if ctype == "venus_in_house":
h = cond.get("house")
return success() if "Venus" in ctx.planets and ctx.house_of("Venus") == h else []
if ctype == "benefic_in_house":
h = cond.get("house")
return success() if any(ctx.house_of(p) == h for p in BENEFICS if p in ctx.planets) else []
if ctype == "planets_in_house":
h = cond.get("house")
ps = planet_list()
return success() if h and ps and all(ctx.house_of(p) == h for p in ps) else []
if ctype == "planets_conjunct_in_same_house":
ps = planet_list()
return success() if len(ps) >= 2 and len({ctx.house_of(p) for p in ps}) == 1 else []
if ctype == "planets_in_exactly_n_signs":
ps = planet_list(['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'])
n = cond.get("n")
# Upstream e628118d: every listed planet must be placed.
return success() if n and ps and all(p in ctx.planets and ctx.sign_of(p) for p in ps) and len({ctx.sign_of(p) for p in ps}) == n else []
if ctype == "planets_in_kendras":
ps = planet_list()
return success() if ps and all(ctx.is_kendra(ctx.house_of(p)) for p in ps) else []
if ctype == "planets_in_trines":
ps = planet_list()
return success() if ps and all(ctx.is_trikona(ctx.house_of(p)) for p in ps) else []
if ctype == "malefics_in_kendras_and_trines":
ps = [p for p in MALEFICS if p in ctx.planets]
return success() if any(ctx.is_kendra(ctx.house_of(p)) for p in ps) and any(ctx.is_trikona(ctx.house_of(p)) for p in ps) else []
if ctype == "malefics_in_lagna":
return success() if any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets) else []
if ctype == "malefics_in_trines":
return success() if any(ctx.is_trikona(ctx.house_of(p)) for p in MALEFICS if p in ctx.planets) else []
if ctype == "planet_exalted_in_kendra":
p = cond.get("planet")
return success() if p in ctx.planets and ctx.is_exalted(p) and ctx.is_kendra(ctx.house_of(p)) else []
if ctype == "lord_exalted":
p = house_lord(cond.get("lord_of") or cond.get("house"))
return success() if p and ctx.is_exalted(p) else []
if ctype == "lord_in_kendra":
p = house_lord(cond.get("lord_of") or cond.get("house"))
return success() if p and ctx.is_kendra(ctx.house_of(p)) else []
if ctype == "lord_in_exaltation_own_or_mool_trikona":
p = house_lord(cond.get("lord_of") or cond.get("house"))
return success() if p and (ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)) else []
if ctype == "mercury_strong_in_kendra_or_trikona":
return success() if "Mercury" in ctx.planets and is_strong("Mercury") and (ctx.is_kendra(ctx.house_of("Mercury")) or ctx.is_trikona(ctx.house_of("Mercury"))) else []
if ctype == "moon_strong":
return success() if "Moon" in ctx.planets and is_strong("Moon") else []
if ctype == "planet_in_own_or_exalted_sign":
p = cond.get("planet")
return success() if p in ctx.planets and (ctx.is_own_sign(p) or ctx.is_exalted(p)) else []
if ctype == "planet_aspected_by_planet":
p = cond.get("planet") or cond.get("to")
by = cond.get("by") or cond.get("from")
return success() if p in ctx.planets and by in ctx.planets and aspects(by, p) else []
if ctype == "navamsa_lord_of_2_5_11_exalted_joins_9th_lord":
l9 = house_lord(9)
for h in (2, 5, 11):
lord_h = house_lord(h)
disp = ctx.navamsa_dispositor(lord_h) if lord_h else None
if disp and l9 and disp in ctx.planets and ctx.is_exalted(disp) and related(disp, l9):
return success()
return []
if ctype == "navamsa_lord_of_moon_exalted_in_rasi":
disp = ctx.navamsa_dispositor("Moon")
return success() if disp and disp in ctx.planets and ctx.is_exalted(disp) else []
if ctype == "navamsa_dispositor_exalted_in_10th_with_lagna_lord":
ref = cond.get("reference_planet")
if ref == "10th_lord":
ref_planet = house_lord(10)
else:
ref_planet = ref if ref in ctx.planets else None
disp = ctx.navamsa_dispositor(ref_planet) if ref_planet else None
l1 = house_lord(1)
return success() if disp and l1 and disp in ctx.planets and ctx.is_exalted(disp) and ctx.house_of(disp) == 10 and related(disp, l1) else []
if ctype == "navamsa_lord_of_4th_lord_in_12th":
l4 = house_lord(4)
disp = ctx.navamsa_dispositor(l4) if l4 else None
return success() if disp and disp in ctx.planets and ctx.house_of(disp) == 12 else []
if ctype == "moon_with_malefics_in_navamsa_cancer_scorpio":
moon_sign = ctx.d9_sign_of("Moon")
moon_house = ctx.d9_house_of("Moon")
return success() if moon_sign in {"Cancer", "Scorpio"} and moon_house and any(ctx.d9_house_of(p) == moon_house for p in MALEFICS if p in ctx.planets and p != "Moon") else []
if ctype == "sun_and_mandi_in_house":
target = cond.get("house") or cond.get("in_house")
if target is None and cond.get("house_offset") is not None:
target = int(cond.get("house_offset")) + 1
return success() if target and ctx.house_of("Sun") == target and ctx.upagraha_house("maandi") == target else []
if ctype == "planet_conjunct_planet":
a = cond.get("planet") or cond.get("a")
b = cond.get("with") or cond.get("b")
return success() if a in ctx.planets and b in ctx.planets and ctx.house_of(a) == ctx.house_of(b) else []
if ctype == "lord_in_house":
lord = ctx.lord_of_house(cond.get("lord_of") or cond.get("house") or cond.get("lord_house"))
target = cond.get("in_house") or cond.get("target_house")
return success() if lord and ctx.house_of(lord) == target else []
if ctype == "house_lord_in_kendra":
lord = ctx.lord_of_house(cond.get("house"))
return success() if lord and ctx.is_kendra(ctx.house_of(lord)) else []
if ctype == "house_lord_in_trine":
lord = ctx.lord_of_house(cond.get("house"))
return success() if lord and ctx.is_trikona(ctx.house_of(lord)) else []
if ctype in ("lagna_hemmed_by_malefics_not_aspected_by_benefics", "lagna_hemmed_by_benefics_not_aspected_by_malefics"):
group = MALEFICS if "malefics" in ctype else BENEFICS
blockers = BENEFICS if "malefics" in ctype else MALEFICS
has_2 = any(ctx.house_of(p) == 2 for p in group if p in ctx.planets)
has_12 = any(ctx.house_of(p) == 12 for p in group if p in ctx.planets)
aspected = any(aspects(p, 1) for p in blockers if p in ctx.planets)
return success() if has_2 and has_12 and not aspected else []
if ctype == "planets_in_mutual_kendras_or_conjunction":
ps = planet_list()
if len(ps) >= 2:
a, b = ps[0], ps[1]
ha, hb = ctx.house_of(a), ctx.house_of(b)
return success() if ha and hb and (ha == hb or ctx.house_offset(ha, hb) in (0, 3, 6, 9)) else []
if ctype == "lords_in_mutual_conjunction_or_aspect":
houses = cond.get("lords") or []
ps = [house_lord(h) for h in houses]
return success() if len(ps) >= 2 and related(ps[0], ps[1]) else []
if ctype == "aspected_by_benefics":
p = cond.get("planet")
if isinstance(p, str) and p.endswith("_lord"):
p = house_lord(int(p.split("_")[0])) if p.split("_")[0].isdigit() else None
return success() if p and any(aspects(b, p) for b in BENEFICS if b in ctx.planets) else []
if ctype == "aspected_by_malefic":
p = cond.get("planet")
if p == "4th_lord":
p = house_lord(4)
return success() if p and any(aspects(m, p) for m in MALEFICS if m in ctx.planets) else []
if ctype == "5th_lord_afflicted_by_saturn_or_rahu":
p = house_lord(5)
return success() if p and (related(p, "Saturn") or related(p, "Rahu")) else []
if ctype == "malefic_in_lagna_or_gulika_in_trine_OR_gulika_with_kendra_trine_lord_OR_l1_with_rahu_sat_ket":
l1 = house_lord(1)
gh = ctx.upagraha_house("gulika")
# PyJHora: condition 3 stands alone; condition 1 requires BOTH malefic in Lagna AND Gulika in trine.
l1_with_rahu_sat_ket = l1 and any(ctx.house_of(l1) == ctx.house_of(p) for p in ["Rahu", "Saturn", "Ketu"] if p in ctx.planets and p != l1)
if l1_with_rahu_sat_ket:
return success()
if gh is None:
return []
malefic_in_lagna = any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets)
gulika_in_trine = gh in (1, 5, 9)
if malefic_in_lagna and gulika_in_trine:
return success()
kendra_trine_lords = {house_lord(h) for h in (1, 4, 5, 7, 9, 10)}
gulika_with_kendra_trine_lord = any(
lord and lord in ctx.planets and ctx.house_of(lord) == gh for lord in kendra_trine_lords
)
return success() if gulika_with_kendra_trine_lord else []
if ctype == "same_lord_for_1st_and_4th":
return success() if house_lord(1) == house_lord(4) else []
if ctype == "1st_and_4th_lords_are_natural_or_temporal_friends":
# 保守近似:同宫/互相位视作 temporal association。
l1, l4 = house_lord(1), house_lord(4)
return success() if l1 and l4 and related(l1, l4) else []
if ctype == "1st_and_4th_lords_aspected_by_benefics":
l1, l4 = house_lord(1), house_lord(4)
ok1 = l1 and any(aspects(b, l1) for b in BENEFICS if b in ctx.planets)
ok4 = l4 and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets)
return success() if ok1 and ok4 else []
if ctype == "4th_lord_is_benefic_aspected_by_benefic":
l4 = house_lord(4)
return success() if l4 and is_benefic(l4) and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets and b != l4) else []
if ctype == "4th_house_or_lord_with_aspect_of_jupiter":
l4 = house_lord(4)
return success() if "Jupiter" in ctx.planets and (aspects("Jupiter", 4) or (l4 and related("Jupiter", l4))) else []
if ctype == "4th_lord_with_benefics":
l4 = house_lord(4)
return success() if l4 and any(ctx.house_of(l4) == ctx.house_of(b) for b in BENEFICS if b in ctx.planets and b != l4) else []
if ctype == "4th_lord_aspected_by_benefics":
l4 = house_lord(4)
return success() if l4 and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets and b != l4) else []
if ctype == "4th_lord_in_kendra_or_trikona":
l4 = house_lord(4)
return success() if l4 and (ctx.is_kendra(ctx.house_of(l4)) or ctx.is_trikona(ctx.house_of(l4))) else []
if ctype == "lagna_lord_in_dry_sign":
l1 = house_lord(1)
# PyJHora const.dry_signs = [0,1,2,4,5,8] = Aries,Taurus,Gemini,Leo,Virgo,Sagittarius
dry_signs = {"Aries", "Taurus", "Gemini", "Leo", "Virgo", "Sagittarius"}
# PyJHora const.dry_planets = [0,2,6] = Sun,Mars,Saturn
dry_lords = {"Sun", "Mars", "Saturn"}
# PyJHora Y112: (ll_house in dry_signs) OR (ll_house_owner in dry_planets)
# ll_house = sign of lagna lord; ll_house_owner = dynamic lord of that sign
ll_in_dry = l1 and ctx.sign_of(l1) in dry_signs
l1_house = ctx.house_of(l1) if l1 else None
ll_owner_dry = l1_house and ctx.lord_of_house(l1_house) in dry_lords
return success() if l1 and (ll_in_dry or ll_owner_dry) else []
if ctype == "navamsa_lagna_in_dry_planet_sign":
d9_asc = ctx.d9.get("ascendant")
dry_lords = {"Sun", "Mars", "Saturn"}
return success() if d9_asc and SIGN_LORDS.get(d9_asc) in dry_lords else []
if ctype == "lagna_lord_and_navamsa_lord_both_in_watery_signs":
l1 = house_lord(1)
navamsa_lord = ctx.navamsa_dispositor(l1) if l1 else None
watery = {"Cancer", "Scorpio", "Pisces"}
return success() if l1 and navamsa_lord and ctx.sign_of(l1) in watery and ctx.sign_of(navamsa_lord) in watery else []
if ctype == "jupiter_in_lagna_or_aspects_lagna_from_watery":
return success() if "Jupiter" in ctx.planets and (ctx.house_of("Jupiter") == 1 or (ctx.sign_of("Jupiter") in {"Cancer", "Scorpio", "Pisces"} and aspects("Jupiter", 1))) else []
if ctype == "lagna_watery_with_benefics_or_lagna_lord_watery":
l1 = house_lord(1)
asc_watery = ctx.ascendant in {"Cancer", "Scorpio", "Pisces"}
watery_planets = {"Moon", "Venus"}
return success() if (asc_watery and any(ctx.house_of(b) == 1 for b in BENEFICS if b in ctx.planets)) or (l1 in watery_planets) else []
if ctype == "jupiter_in_lagna_mars_in_7th":
return success() if ctx.house_of("Jupiter") == 1 and ctx.house_of("Mars") == 7 else []
if ctype == "saturn_in_lagna_mars_in_5_7_9":
return success() if ctx.house_of("Saturn") == 1 and ctx.house_of("Mars") in (5, 7, 9) else []
if ctype == "saturn_in_12th_with_waning_moon":
return success() if ctx.house_of("Saturn") == 12 and ctx.is_waning_moon() else []
if ctype == "moon_mercury_in_kendra_with_planet":
return success() if ctx.house_of("Moon") == ctx.house_of("Mercury") and ctx.is_kendra(ctx.house_of("Moon")) and len(ctx.planets_in_house(ctx.house_of("Moon"))) >= 3 else []
if ctype == "four_planet_chain_all_in_kendra_trine_or_exaltation":
l1 = house_lord(1)
if not l1:
return []
chain = [l1]
current = l1
for _ in range(2):
sign_current = ctx.sign_of(current)
current = SIGN_LORDS.get(sign_current) if sign_current else None
if not current:
return []
chain.append(current)
d9_disp = ctx.navamsa_dispositor(current)
if not d9_disp:
return []
chain.append(d9_disp)
ok = all(
p in ctx.planets and (ctx.is_kendra(ctx.house_of(p)) or ctx.is_trikona(ctx.house_of(p)) or ctx.is_exalted(p))
for p in chain
)
return success() if ok else []
if ctype == "planets_in_relative_houses":
base = house(cond.get("from", "lagna")) or 1
offsets = cond.get("houses") or cond.get("offsets") or []
ps = planet_list(ALL_PLANETS)
targets = {rel_house(base, int(o) % 12) for o in offsets}
return success() if any(ctx.house_of(p) in targets for p in ps) else []
if ctype == "house_empty":
h = cond.get("house")
return success() if h and not ctx.planets_in_house(h) else []
if ctype == "planet_in_house_with_aspect_or_conjunction":
"""Jupiter in 2nd/5th, conjoined or aspected by Mercury AND Venus (kalaanidhi yoga)"""
planet = cond.get("planet", "")
house_offsets = cond.get("house_offsets", [])
aspecting_or_conjoining = cond.get("aspecting_or_conjoining", [])
if not planet or planet not in ctx.planets:
return []
ph = ctx.house_of(planet)
if ph is None:
return []
# Check planet is in one of the target houses (from lagna)
target_houses = set()
for off in house_offsets:
h = (1 + off) % 12
if h == 0: h = 12
target_houses.add(h)
if ph not in target_houses:
return []
# Check ALL specified planets must conjoin or aspect
for other in aspecting_or_conjoining:
if other not in ctx.planets:
return []
# Check conjunction (same house)
if ctx.house_of(other) == ph:
continue
# Check aspect (graha drishti)
if aspects(other, planet):
continue
# Neither conjoined nor aspecting → fail
return []
return success()
# No branch knows this condition type: the rule cannot be evaluated,
# which is not the same as "condition false" (第三轮审查 2026-11-07).
raise YogaRuleEvaluationError("UnsupportedConditionType", str(ctype))
# ------------------------------------------------------------------------
# 辅助方法
# ------------------------------------------------------------------------
def _resolve_binding(self, ref, ctx, bindings):
"""解析引用,支持变量绑定替换"""
if ref is None:
return []
if isinstance(ref, str) and ref.startswith("$"):
# 绑定变量引用,如 "$a"
val = bindings.get(ref[1:])
if val is None:
return []
return [val] if isinstance(val, str) else (val if isinstance(val, list) else [val])
return ctx.resolve(ref)
@staticmethod
def _offset_name(off: int) -> str:
names = {0: "同宫", 1: "2宫", 2: "3宫", 3: "4宫(Kendra)", 4: "5宫(Trine)",
5: "6宫", 6: "7宫(Kendra)", 7: "8宫", 8: "9宫(Trine)",
9: "10宫(Kendra)", 10: "11宫", 11: "12宫"}
return names.get(off, f"{off+1}宫")
# ============================================================================
# 便捷入口
# ============================================================================
_PARTICIPANT_ORDER = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
_LOOP_NODES = (ast.comprehension, ast.Lambda, ast.For, ast.While)
_LORD_CALLS = {"lord", "lords_of"}
def _expr_planet_refs(node, ctx) -> Optional[set]:
"""Planets one expression node refers to; None when it cannot be decided."""
refs = set()
for sub in ast.walk(node):
if isinstance(sub, _LOOP_NODES):
return None
if isinstance(sub, ast.Constant) and isinstance(sub.value, str) and sub.value in _PARTICIPANT_ORDER:
refs.add(sub.value)
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name) and sub.func.id in _LORD_CALLS:
arg = sub.args[0] if sub.args else None
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, int)):
return None
lord = ctx.lord_of_house(arg.value)
if lord not in _PARTICIPANT_ORDER:
return None
refs.add(lord)
return refs
@functools.lru_cache(maxsize=1024)
def _parsed_custom_expr(expr: str):
try:
return ast.parse(expr, mode="eval")
except SyntaxError:
return None
def _flatten_planets(values, ctx) -> List[str]:
"""Planet names in a `participants_expr` result, in order, once each (upstream 733a931b)."""
out: List[str] = []
def walk(value) -> None:
if isinstance(value, (list, tuple)):
for item in value:
walk(item)
elif isinstance(value, str) and value in ctx.planets and value not in out:
out.append(value)
walk(values)
return out
def _static_custom_participants(expr: str, ctx) -> List[str]:
"""BUG-1657: participants of a loop-free custom rule.
The planets an expression checks are its literal planet names plus the lords
of literal house numbers. An `or` whose branches check different planets is
ambiguous (only one branch may have held), so nothing is returned for it.
"""
tree = _parsed_custom_expr(expr or "")
if tree is None:
return []
for node in ast.walk(tree):
if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
branch_refs = [_expr_planet_refs(value, ctx) for value in node.values]
if any(refs is None for refs in branch_refs):
return []
non_empty = [refs for refs in branch_refs if refs]
if any(refs != non_empty[0] for refs in non_empty[1:]):
return []
refs = _expr_planet_refs(tree, ctx)
return _ordered(refs) if refs else []
def _ordered(names) -> List[str]:
return [name for name in _PARTICIPANT_ORDER if name in set(names)]
def _bound_planets(match: Dict) -> List[str]:
"""Planets named in a match's bindings or set by a custom rule.
Only values the evaluators bound are used; combination text is never parsed.
"""
found = set()
def take(value) -> None:
if isinstance(value, str) and value in _PARTICIPANT_ORDER:
found.add(value)
elif isinstance(value, (list, tuple, set)):
for item in value:
take(item)
elif isinstance(value, dict):
for item in value.values():
take(item)
for value in (match.get("bindings") or {}).values():
take(value)
take(match.get("participants"))
return [name for name in _PARTICIPANT_ORDER if name in found]
def detect_yogas(planets: Dict[str, Dict], ascendant: str,
rules_path: Optional[str] = None,
context: Optional[Dict] = None) -> List[Dict]:
"""便捷函数:检测 Yoga"""
if rules_path is None:
# 自动查找规则文件
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_dir = os.path.dirname(script_dir)
rules_path = os.path.join(skill_dir, "references", "yoga_rules.json")
engine = YogaEngine(rules_path)
return engine.detect(planets, ascendant, context=context)
def detect_yogas_with_status(planets: Dict[str, Dict], ascendant: str,
rules_path: Optional[str] = None,
context: Optional[Dict] = None) -> Dict:
"""Formed yogas plus the rules that could not be evaluated.
`evaluation_failures` rows carry `condition_status: evaluation_failed` and
the error class; a rule listed there was neither formed nor ruled out.
"""
if rules_path is None:
script_dir = os.path.dirname(os.path.abspath(__file__))
rules_path = os.path.join(os.path.dirname(script_dir), "references", "yoga_rules.json")
engine = YogaEngine(rules_path)
yogas = engine.detect(planets, ascendant, context=context)
failures = list(engine.last_evaluation_failures)
unimplemented = list(engine.unimplemented_rules) + list(engine.last_unimplemented)
return {
"yogas": yogas,
"rules_evaluated": len(engine.rules),
"evaluation_failures": failures,
"unimplemented_rules": unimplemented,
"evaluation_status": ("evaluation_failed_present" if failures
else "unimplemented_rules_present" if unimplemented else "complete"),
}
def detect_yogas_from_json(chart_json: Dict, rules_path: Optional[str] = None) -> List[Dict]:
"""从 chart JSON 结构中提取 planets + ascendant 并检测"""
planets = chart_json.get("planets", {})
asc = chart_json.get("ascendant", chart_json.get("ascendant_sign", "Aries"))
return detect_yogas(planets, asc, rules_path, context=chart_json.get("context"))
if __name__ == "__main__":
# 简单自测
test_planets = {
"Sun": {"sign": "Aries", "house": 1, "degree": 10.5},
"Moon": {"sign": "Cancer", "house": 4, "degree": 15.0},
"Jupiter": {"sign": "Libra", "house": 7, "degree": 20.0},
}
print("YogaEngine loaded. Run with a rules file to test.")