Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
932 lines
39 KiB
Python
932 lines
39 KiB
Python
# /// script
|
||
# requires-python = ">=3.11"
|
||
# dependencies = []
|
||
# ///
|
||
# ─── How to run ───
|
||
# PYTHONPATH=scripts .venv/bin/python scripts/active_rectification_event_engine.py
|
||
"""Local chart and dual-Dasha computation for birth-time event scoring."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import sys
|
||
from datetime import date, datetime, time, timedelta
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from collections.abc import Sequence
|
||
from typing import Any, Final, assert_never
|
||
|
||
from scripts.active_rectification_events import (
|
||
CandidateEvidence,
|
||
CandidateResult,
|
||
CandidateScoreRow,
|
||
EventDomain,
|
||
LifeEvent,
|
||
RectificationEventRequest,
|
||
adjudicate_candidate_rows,
|
||
independent_events,
|
||
precision_weight,
|
||
)
|
||
|
||
SCRIPTS: Final = Path(__file__).resolve().parent
|
||
if str(SCRIPTS) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPTS))
|
||
|
||
import dasha_analyzer # noqa: E402
|
||
import domain_calculation_service # noqa: E402
|
||
import ashtakavarga # noqa: E402
|
||
import divisional_charts_extended # noqa: E402
|
||
import functional_benefics # noqa: E402
|
||
import jaimini # noqa: E402
|
||
import shadbala # noqa: E402
|
||
import narayana_dasha # noqa: E402
|
||
import narayana_rath # noqa: E402
|
||
from narayana_legacy_label import narayana_age # noqa: E402
|
||
import saham_daynight # noqa: E402
|
||
import special_lagnas # noqa: E402
|
||
import varga # noqa: E402
|
||
from chart_dasha import birth_moment # noqa: E402
|
||
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME # noqa: E402
|
||
from scripts.rectification.refinement_packet import NAKSHATRA_SPAN # noqa: E402
|
||
from scripts.rectification.kp_cusp_observation import observe_kp_cusps # noqa: E402
|
||
|
||
AYANAMSA: Final = DEFAULT_AYANAMSA_NAME
|
||
NODE_MODE: Final = "mean"
|
||
INPUT_CONTRACT_VERSION: Final = "rectification-candidate-input-v2"
|
||
|
||
DomainConfig = tuple[tuple[str, ...], tuple[int, ...]]
|
||
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
|
||
"education": (("D24", "D5"), (4, 5, 9)),
|
||
"relocation": (("D4",), (4, 12)),
|
||
"relationship": (("D9",), (7,)),
|
||
"career": (("D10",), (10,)),
|
||
"finance": (("D2", "D11"), (2, 11)),
|
||
"health_pressure": (("D30",), (6, 8, 12)),
|
||
# 六亲: D12 parents, D7 children/spouse detail, D3 siblings, plus D1 houses 3/4/5/9.
|
||
"family": (("D12", "D7", "D3"), (3, 4, 5, 9)),
|
||
# Dated appearance/marks: D1 lagna / 1st house only. Not a primary formula.
|
||
"appearance": ((), (1,)),
|
||
# Occupation notes: D10 + D1 10th house, auxiliary, no type labels.
|
||
"occupation": (("D10",), (10,)),
|
||
}
|
||
AUXILIARY_DOMAINS: Final[frozenset[str]] = frozenset({"appearance", "occupation"})
|
||
AUXILIARY_SCORE_FACTOR: Final = 0.4
|
||
OBSERVATION_ONLY_LAYERS: Final[frozenset[str]] = frozenset({"KP_cusps"})
|
||
|
||
|
||
class RectificationEventCalculationError(RuntimeError):
|
||
"""Raised when stored rectification evidence cannot be calculated safely."""
|
||
|
||
|
||
def _event_datetime(event: LifeEvent) -> datetime:
|
||
match event["precision"]:
|
||
case "day":
|
||
return datetime.strptime(event["date"], "%Y-%m-%d")
|
||
case "month":
|
||
return datetime.strptime(f"{event['date']}-15", "%Y-%m-%d")
|
||
case "year":
|
||
return datetime.strptime(f"{event['date']}-07-01", "%Y-%m-%d")
|
||
case unreachable:
|
||
assert_never(unreachable)
|
||
|
||
|
||
def _candidate_datetimes(request: RectificationEventRequest) -> list[datetime]:
|
||
from scripts.rectification.candidate_window import enumerate_candidate_window
|
||
|
||
try:
|
||
return enumerate_candidate_window(request)
|
||
except ValueError as exc:
|
||
raise RectificationEventCalculationError(str(exc)) from exc
|
||
|
||
|
||
def _vimshottari_timeline(birth_at: datetime, moon_longitude: float) -> list[dict[str, Any]]:
|
||
"""Mahadashas from the candidate's birth moment, as ``cmd_dasha`` / the chart page (BUG-1720).
|
||
|
||
The frozen ``dasha_analyzer.build_dasha_timeline`` took only the birth date
|
||
and started every period from midnight of that day.
|
||
"""
|
||
if not isinstance(birth_at, datetime):
|
||
raise TypeError("birth_at must be the candidate birth datetime")
|
||
timeline, _, _, _ = birth_moment.mahadashas(birth_at, float(moon_longitude))
|
||
return timeline
|
||
|
||
|
||
def _active_vimshottari(
|
||
birth_at: datetime,
|
||
moon_longitude: float,
|
||
event_at: datetime,
|
||
timeline: list[dict[str, Any]] | None = None,
|
||
) -> tuple[str, str, str]:
|
||
"""MD / AD / PD lords at ``event_at`` with float sub-period splits (BUG-1720).
|
||
|
||
Product approved 2026-11-08 replacing the integer-day
|
||
``dasha_analyzer.build_antardasha`` (its last child ended up to a day early)
|
||
with ``birth_moment.split_period`` at both levels.
|
||
"""
|
||
if not isinstance(birth_at, datetime):
|
||
raise TypeError("birth_at must be the candidate birth datetime")
|
||
if timeline is None:
|
||
timeline = _vimshottari_timeline(birth_at, moon_longitude)
|
||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||
minor = dasha_analyzer.find_current_sub(birth_moment.split_period(major), event_at)
|
||
pratyantar = dasha_analyzer.find_current_sub(birth_moment.split_period(minor), event_at)
|
||
return str(major["lord"]), str(minor["lord"]), str(pratyantar["lord"])
|
||
|
||
|
||
# 原值:校正打分调用 narayana_dasha.calc_narayana_mahadasha / calc_narayana_antardasha /
|
||
# get_current_narayana_dasha(旧算法:从上升起、一律顺数,固定宫主),年龄按 365.2425 天。
|
||
# 新值:与报告、咨询卡同一个来源版 Narayana(narayana_rath.calc_narayana_rath,两轮 24 行;
|
||
# 小运 narayana_rath.antardasha),年龄按 narayana_legacy_label.narayana_age(出生时刻起、365.25 天)。
|
||
# 来源版定不出(blocked / parameter_sensitive)时这一层不可用,返回 None,不回退旧算法。
|
||
# 原因:产品 2026-11-08 批准;占星师第五轮裁定 1、3;与报告/卡口径一致。
|
||
def _narayana_lons(planet_longitudes: dict[str, Any]) -> dict[str, float]:
|
||
return {
|
||
str(name): float(lon) for name, lon in planet_longitudes.items()
|
||
if isinstance(lon, (int, float)) and not isinstance(lon, bool)
|
||
}
|
||
|
||
|
||
def _narayana_periods(
|
||
ascendant_index: int,
|
||
planet_longitudes: dict[str, Any],
|
||
) -> list[dict[str, Any]] | None:
|
||
"""Source-version Narayana MD rows (two cycles), or ``None`` when that layer is blocked."""
|
||
result = narayana_rath.calc_narayana_rath(
|
||
int(ascendant_index) % 12, _narayana_lons(planet_longitudes), cycle_count=2,
|
||
)
|
||
rows = result.get("mahadasha")
|
||
return list(rows) if rows else None
|
||
|
||
|
||
def _narayana_antardashas(
|
||
period: dict[str, Any],
|
||
planet_longitudes: dict[str, Any],
|
||
) -> list[dict[str, Any]] | None:
|
||
"""The twelve source-version ADs of one MD row, or ``None`` when the AD step is blocked."""
|
||
result = narayana_rath.antardasha(period, _narayana_lons(planet_longitudes))
|
||
return list(result["rows"]) if result.get("status") == "selected" and result.get("rows") else None
|
||
|
||
|
||
def _narayana_antardasha_table(
|
||
periods: list[dict[str, Any]] | None,
|
||
planet_longitudes: dict[str, Any],
|
||
) -> dict[str, list[dict[str, Any]] | None] | None:
|
||
"""ADs per MD row, keyed by ``period_key`` (cycle 2 repeats the signs with other lengths)."""
|
||
if not periods:
|
||
return None
|
||
return {str(row["period_key"]): _narayana_antardashas(row, planet_longitudes) for row in periods}
|
||
|
||
|
||
def _running_row(rows: list[dict[str, Any]] | None, age: float) -> dict[str, Any] | None:
|
||
return next(
|
||
(row for row in rows or [] if float(row["start_age"]) <= age < float(row["end_age"])),
|
||
None,
|
||
)
|
||
|
||
|
||
def _active_narayana(
|
||
ascendant_index: int,
|
||
planet_longitudes: dict[str, float],
|
||
birth_at: datetime,
|
||
event_at: datetime,
|
||
periods: list[dict[str, Any]] | None = None,
|
||
antardasha_by_md: dict[str, list[dict[str, Any]] | None] | None = None,
|
||
) -> tuple[int | None, int | None]:
|
||
"""Running source-version Narayana MD / AD signs at ``event_at``.
|
||
|
||
Same rows and age as the product layer (``narayana_legacy_label.rath_narayana_view``
|
||
with ``current_age = narayana_age(birth, event)``). ``None`` marks a blocked
|
||
step; there is no fallback to the old algorithm.
|
||
"""
|
||
if not isinstance(birth_at, datetime):
|
||
raise TypeError("birth_at must be the candidate birth datetime")
|
||
if periods is None:
|
||
periods = _narayana_periods(ascendant_index, planet_longitudes)
|
||
if not periods:
|
||
return None, None
|
||
age = max(narayana_age(birth_at, event_at), 0.0)
|
||
major = _running_row(periods, age)
|
||
if major is None:
|
||
return None, None
|
||
if antardasha_by_md is not None and str(major.get("period_key")) in antardasha_by_md:
|
||
minors = antardasha_by_md[str(major["period_key"])]
|
||
else:
|
||
minors = _narayana_antardashas(major, planet_longitudes)
|
||
minor = _running_row(minors, age)
|
||
return major.get("sign_idx"), None if minor is None else minor.get("sign_idx")
|
||
|
||
|
||
def _varga_chart(charts: dict, prefix: str) -> dict | None:
|
||
return next(
|
||
(chart for name, chart in charts.items() if name.startswith(f"{prefix}_")),
|
||
None,
|
||
)
|
||
|
||
|
||
def _d11_chart(planet_longitudes: dict[str, float], ascendant_longitude: float) -> dict:
|
||
"""Adapt the repository's Rudramsa implementation to the event-score shape."""
|
||
raw = divisional_charts_extended.DivisionalChartsCalculator().calculate_all_vargas(
|
||
planet_longitudes, ascendant_longitude,
|
||
)["Rudramsa"]
|
||
return {
|
||
"Ascendant": {"sign_idx": raw["ascendant"]["sign_index"]},
|
||
**{
|
||
planet: {"sign_idx": value["sign_index"]}
|
||
for planet, value in raw["planets"].items()
|
||
},
|
||
}
|
||
|
||
|
||
def _relative_house(sign_index: int, ascendant_index: int) -> int:
|
||
return (sign_index - ascendant_index) % 12 + 1
|
||
|
||
|
||
def _house_lords(ascendant_index: int, houses: tuple[int, ...]) -> set[str]:
|
||
return {
|
||
narayana_dasha.SIGN_LORDS[
|
||
narayana_dasha.SIGNS[(ascendant_index + house - 1) % 12]
|
||
]
|
||
for house in houses
|
||
}
|
||
|
||
|
||
def _planet_house(chart: dict, planet: str) -> int | None:
|
||
raw = (chart.get("planets", {}).get(planet) or {}).get("house")
|
||
return int(raw) if isinstance(raw, int | float) else None
|
||
|
||
|
||
def _varga_house(chart: dict, planet: str) -> int | None:
|
||
ascendant = chart.get("Ascendant") or {}
|
||
placement = chart.get(planet) or {}
|
||
ascendant_index = ascendant.get("sign_idx")
|
||
planet_index = placement.get("sign_idx")
|
||
if not isinstance(ascendant_index, int) or not isinstance(planet_index, int):
|
||
return None
|
||
return _relative_house(planet_index, ascendant_index)
|
||
|
||
|
||
def _score_event(
|
||
*,
|
||
candidate_time: str,
|
||
event: LifeEvent,
|
||
natal_chart: dict,
|
||
varga_charts: list[dict],
|
||
vimshottari: tuple[str, str, str],
|
||
narayana: tuple[int | None, int | None],
|
||
arudha_padas: dict,
|
||
functional: dict | None = None,
|
||
) -> CandidateEvidence:
|
||
_, target_houses = DOMAIN_CONFIG[event["domain"]]
|
||
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
||
target_lords = _house_lords(ascendant_index, target_houses)
|
||
if functional is None:
|
||
# 原值:明确选中 functional_profile=FUNCTIONAL_PROFILE_BPHS(原产品表)。
|
||
# 新值:默认档(PVR 表 30),并按产品其他调用方传月亮盈亏(动星座上升的月亮)。
|
||
# 原因:产品 2026-11-08 批准;占星师第五轮裁定 1、3;与报告/卡口径一致(BUG-1759)。
|
||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||
natal_chart["ascendant"].get("sign"),
|
||
moon_paksha=functional_benefics.moon_paksha_from_planets(natal_chart.get("planets")),
|
||
)
|
||
functional_benefics_set = set(functional.get("functional_benefics") or [])
|
||
functional_malefics_set = set(functional.get("functional_malefics") or [])
|
||
major_lord, minor_lord, pratyantar_lord = vimshottari
|
||
rules: list[str] = []
|
||
points = 0.0
|
||
|
||
for lord, weight, label in (
|
||
(major_lord, 2.0, "vim_md"),
|
||
(minor_lord, 1.5, "vim_ad"),
|
||
(pratyantar_lord, 0.75, "vim_pd"),
|
||
):
|
||
if _planet_house(natal_chart, lord) in target_houses:
|
||
rules.append(f"{label}_domain_house")
|
||
points += weight
|
||
if lord in target_lords:
|
||
rules.append(f"{label}_domain_lord")
|
||
points += weight
|
||
for varga_chart in varga_charts:
|
||
if _varga_house(varga_chart, lord) in target_houses:
|
||
rules.append(f"{label}_domain_varga")
|
||
points += weight / (2 * len(varga_charts))
|
||
if lord in functional_benefics_set:
|
||
rules.append(f"{label}_functional_benefic_auxiliary")
|
||
points += 0.2
|
||
elif lord in functional_malefics_set:
|
||
rules.append(f"{label}_functional_malefic_auxiliary")
|
||
points -= 0.1
|
||
|
||
for sign_index, weight, label in (
|
||
(narayana[0], 2.0, "narayana_md"),
|
||
(narayana[1], 1.0, "narayana_ad"),
|
||
):
|
||
if sign_index is not None and _relative_house(sign_index, ascendant_index) in target_houses:
|
||
rules.append(f"{label}_domain_house")
|
||
points += weight
|
||
arudha_keys = (
|
||
("A7", "UL") if event["domain"] == "relationship"
|
||
else ("A10",) if event["domain"] in {"career", "occupation"}
|
||
else ()
|
||
)
|
||
arudha_signs = {
|
||
value.get("sign_idx") for key in arudha_keys
|
||
if isinstance((value := arudha_padas.get(key)), dict) and isinstance(value.get("sign_idx"), int)
|
||
}
|
||
if arudha_signs:
|
||
for lord, label in ((major_lord, "vim_md"), (minor_lord, "vim_ad"), (pratyantar_lord, "vim_pd")):
|
||
planet = natal_chart.get("planets", {}).get(lord) or {}
|
||
if isinstance(planet.get("lon"), (int, float)) and int(planet["lon"] // 30) in arudha_signs:
|
||
rules.append(f"{label}_arudha_auxiliary")
|
||
points += 0.35
|
||
|
||
event_kind = event.get("event_kind", event["domain"])
|
||
if event["domain"] in AUXILIARY_DOMAINS:
|
||
points *= AUXILIARY_SCORE_FACTOR
|
||
rules.append(
|
||
"occupation_auxiliary_not_primary"
|
||
if event["domain"] == "occupation"
|
||
else "appearance_auxiliary_not_primary"
|
||
)
|
||
if not rules:
|
||
rules.append("no_domain_activation")
|
||
rules.append(f"event_kind:{event_kind}")
|
||
weighted_points = round(points * precision_weight(event["precision"]), 4)
|
||
return {
|
||
"event_id": event["id"],
|
||
"domain": event["domain"],
|
||
"candidate_time": candidate_time,
|
||
"rule_ids": rules,
|
||
"points": weighted_points,
|
||
}
|
||
|
||
|
||
def _transit_chart_cache_key(
|
||
request: RectificationEventRequest,
|
||
event_at: datetime,
|
||
) -> tuple[Any, ...]:
|
||
return (
|
||
event_at.date().isoformat(),
|
||
float(request["lat"]),
|
||
float(request["lon"]),
|
||
float(request["tz"]),
|
||
request.get("ayanamsa", AYANAMSA),
|
||
request.get("node_mode", NODE_MODE),
|
||
)
|
||
|
||
|
||
def _controlled_transit_rules(
|
||
request: RectificationEventRequest,
|
||
event: LifeEvent,
|
||
natal_ascendant_index: int,
|
||
target_houses: tuple[int, ...],
|
||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] | None = None,
|
||
) -> list[str]:
|
||
"""Use only Jupiter/Saturn and only day/month dated events as a weak check."""
|
||
if event["precision"] == "year":
|
||
return []
|
||
event_at = _event_datetime(event)
|
||
payload = {
|
||
"year": event_at.year, "month": event_at.month, "day": event_at.day,
|
||
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
|
||
"tz": request["tz"],
|
||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||
"node_mode": request.get("node_mode", NODE_MODE),
|
||
}
|
||
cache_key = _transit_chart_cache_key(request, event_at)
|
||
if transit_chart_cache is not None and cache_key in transit_chart_cache:
|
||
transit_chart = transit_chart_cache[cache_key]
|
||
else:
|
||
transit_chart = domain_calculation_service.compute_chart(payload)
|
||
if transit_chart_cache is not None:
|
||
transit_chart_cache[cache_key] = transit_chart
|
||
rules: list[str] = []
|
||
for planet in ("Jupiter", "Saturn"):
|
||
item = transit_chart.get("planets", {}).get(planet) or {}
|
||
if isinstance(item.get("lon"), (int, float)) and _relative_house(int(item["lon"] // 30), natal_ascendant_index) in target_houses:
|
||
rules.append(f"controlled_transit_{planet.lower()}_domain_house")
|
||
return rules
|
||
|
||
|
||
def _ashtakavarga_auxiliary(
|
||
natal_chart: dict,
|
||
ascendant_index: int,
|
||
target_houses: tuple[int, ...],
|
||
ashtakavarga_result: dict[str, Any] | None = None,
|
||
) -> tuple[list[str], float]:
|
||
"""Return a bounded SAV consistency adjustment, never a standalone trigger."""
|
||
result = ashtakavarga_result
|
||
if result is None:
|
||
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
|
||
if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"):
|
||
return [], 0.0
|
||
house_scores = result.get("house_scores_full") or {}
|
||
values = [house_scores.get(f"house_{house}", {}).get("sav_score") for house in target_houses]
|
||
numeric = [float(value) for value in values if isinstance(value, (int, float))]
|
||
if not numeric:
|
||
return [], 0.0
|
||
average = sum(numeric) / len(numeric)
|
||
if average >= 32:
|
||
return ["ashtakavarga_target_house_support_auxiliary"], 0.2
|
||
if average <= 24:
|
||
return ["ashtakavarga_target_house_pressure_auxiliary"], -0.1
|
||
return [], 0.0
|
||
|
||
|
||
def _shadbala_verified_components_auxiliary(
|
||
natal_chart: dict,
|
||
birth_hour: float,
|
||
dasha_lords: tuple[str, str, str],
|
||
shadbala_result: dict[str, Any] | None = None,
|
||
) -> tuple[list[str], float]:
|
||
"""Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched."""
|
||
planets = natal_chart.get("planets", {})
|
||
sun = planets.get("Sun") or {}
|
||
moon = planets.get("Moon") or {}
|
||
if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)):
|
||
return [], 0.0
|
||
result = shadbala_result
|
||
if result is None:
|
||
result = shadbala.calc_shadbala(
|
||
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
|
||
float(sun["lon"]), float(moon["lon"]),
|
||
)
|
||
values = {
|
||
planet: float((row.get("sthana_bala") or {}).get("total", 0)) + float(row.get("drik_bala", 0)) + float(row.get("naisargika_bala", 0))
|
||
for planet, row in (result.get("planets") or {}).items()
|
||
}
|
||
if not values:
|
||
return [], 0.0
|
||
baseline = sum(values.values()) / len(values)
|
||
active = [values[lord] for lord in dasha_lords if lord in values]
|
||
if not active:
|
||
return [], 0.0
|
||
average = sum(active) / len(active)
|
||
if average > baseline:
|
||
return ["shadbala_sthana_drik_naisargika_support_auxiliary"], 0.1
|
||
if average < baseline:
|
||
return ["shadbala_sthana_drik_naisargika_pressure_auxiliary"], -0.05
|
||
return [], 0.0
|
||
|
||
|
||
def _feature_hash(value: Any) -> str:
|
||
normalized = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
|
||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _arudha_sign(arudha_padas: dict, key: str) -> int | None:
|
||
value = arudha_padas.get(key) or {}
|
||
sign_index = value.get("sign_idx")
|
||
return int(sign_index) if isinstance(sign_index, int) and 0 <= sign_index <= 11 else None
|
||
|
||
|
||
def _pada_index(longitude: float) -> int:
|
||
return int((float(longitude) % 360.0) / (NAKSHATRA_SPAN / 4.0)) % 108
|
||
|
||
|
||
@lru_cache(maxsize=64)
|
||
def _sunrise_local_naive(date_iso: str, lat: float, lon: float, tz: float) -> datetime | None:
|
||
"""Real sunrise only. Polar or unavailable locations omit Hora/Ghati; never invent 06:00."""
|
||
try:
|
||
import swisseph as swe
|
||
noon = datetime.fromisoformat(f"{date_iso}T12:00:00")
|
||
row = saham_daynight.determine_daytime(noon, lat=lat, lon=lon, tz=tz)
|
||
year, month, day, ut_hours = swe.revjul(float(row["sunrise_jd_ut"]))
|
||
return datetime(int(year), int(month), int(day)) + timedelta(hours=float(ut_hours) + float(tz))
|
||
except (saham_daynight.SahamDayNightError, TypeError, ValueError, OverflowError, OSError):
|
||
return None
|
||
|
||
|
||
def _fine_minute_indices(
|
||
*,
|
||
ascendant_longitude: float,
|
||
candidate_at: datetime,
|
||
lat: float,
|
||
lon: float,
|
||
tz: float,
|
||
sun_degree: float | None,
|
||
moon_degree: float | None,
|
||
) -> dict[str, int]:
|
||
wrapped = float(ascendant_longitude) % 360.0
|
||
indices: dict[str, int] = {"pada_index": _pada_index(ascendant_longitude)}
|
||
calculator = special_lagnas.SpecialLagnasCalculator()
|
||
if isinstance(sun_degree, (int, float)) and isinstance(moon_degree, (int, float)):
|
||
try:
|
||
bhava = calculator.calculate_bhava_lagna(wrapped, float(sun_degree), float(moon_degree))
|
||
indices["bhava_sign_index"] = int(float(bhava["degree"]) // 30.0) % 12
|
||
except (TypeError, ValueError, KeyError, OverflowError):
|
||
pass
|
||
sunrise = _sunrise_local_naive(candidate_at.date().isoformat(), lat, lon, tz)
|
||
if sunrise is None:
|
||
return indices
|
||
try:
|
||
hora = calculator.calculate_hora_lagna(wrapped, 0.0, candidate_at, sunrise)
|
||
ghati = calculator.calculate_ghati_lagna(wrapped, candidate_at, sunrise)
|
||
hora_degree = float(hora["degree"])
|
||
ghati_degree = float(ghati["degree"])
|
||
except (TypeError, ValueError, KeyError, OverflowError):
|
||
return indices
|
||
hora_index = int(hora_degree // 30.0) % 12
|
||
ghati_index = int(ghati_degree // 30.0) % 12
|
||
indices["hora_sign_index"] = hora_index
|
||
indices["ghati_sign_index"] = ghati_index
|
||
indices["pranapada_sign_index"] = (hora_index + ghati_index) % 12
|
||
return indices
|
||
|
||
|
||
def build_candidate_static_context(
|
||
request: RectificationEventRequest,
|
||
candidate_at: datetime,
|
||
) -> dict[str, Any]:
|
||
"""Compute every candidate-minute natal layer once for scoring and diagnostics."""
|
||
chart = domain_calculation_service.compute_chart({
|
||
"year": candidate_at.year,
|
||
"month": candidate_at.month,
|
||
"day": candidate_at.day,
|
||
"hour": candidate_at.hour,
|
||
"minute": candidate_at.minute,
|
||
"lat": request["lat"],
|
||
"lon": request["lon"],
|
||
"tz": request["tz"],
|
||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||
"node_mode": request.get("node_mode", NODE_MODE),
|
||
})
|
||
planet_longitudes = {
|
||
name: float(data["lon"])
|
||
for name, data in chart.get("planets", {}).items()
|
||
if isinstance(data, dict) and isinstance(data.get("lon"), int | float)
|
||
}
|
||
ascendant_longitude = float(chart["ascendant"]["lon"])
|
||
ascendant_index = int(ascendant_longitude // 30)
|
||
arudha = jaimini.calc_arudha_padas(ascendant_index, planet_longitudes)
|
||
arudha_padas = {**(arudha.get("padas") or {}), "UL": arudha.get("upapada") or {}}
|
||
charts = varga.calc_all_vargas(
|
||
planet_longitudes,
|
||
ascendant_longitude,
|
||
divisions=[2, 3, 4, 5, 7, 9, 10, 12, 24, 30],
|
||
)
|
||
d11_chart = _d11_chart(planet_longitudes, ascendant_longitude)
|
||
varga_charts = {
|
||
prefix: d11_chart if prefix == "D11" else _varga_chart(charts, prefix)
|
||
for prefix in ("D2", "D3", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30")
|
||
}
|
||
available_layers = ["D1"]
|
||
blocked_layers: list[str] = []
|
||
varga_ascendants: dict[str, int] = {}
|
||
for prefix, value in varga_charts.items():
|
||
ascendant = (value or {}).get("Ascendant") or {}
|
||
sign_index = ascendant.get("sign_idx")
|
||
if isinstance(sign_index, int) and 0 <= sign_index <= 11:
|
||
varga_ascendants[prefix] = sign_index
|
||
available_layers.append(prefix)
|
||
else:
|
||
blocked_layers.append(prefix)
|
||
|
||
arudha_signs = {key: _arudha_sign(arudha_padas, key) for key in ("A7", "A10", "UL")}
|
||
for key, sign_index in arudha_signs.items():
|
||
(available_layers if sign_index is not None else blocked_layers).append(key)
|
||
|
||
ashtakavarga_result = None
|
||
try:
|
||
ashtakavarga_result = ashtakavarga.calc_ashtakavarga(chart.get("planets", {}), ascendant_index)
|
||
available_layers.append("Ashtakavarga")
|
||
except (KeyError, TypeError, ValueError):
|
||
blocked_layers.append("Ashtakavarga")
|
||
|
||
shadbala_result = None
|
||
try:
|
||
shadbala_result = shadbala.calc_shadbala(
|
||
chart.get("planets", {}),
|
||
str(chart["ascendant"].get("sign")),
|
||
candidate_at.hour + candidate_at.minute / 60,
|
||
planet_longitudes["Sun"],
|
||
planet_longitudes["Moon"],
|
||
birth_minute=float(candidate_at.minute),
|
||
)
|
||
available_layers.append("Shadbala")
|
||
except (KeyError, TypeError, ValueError):
|
||
blocked_layers.append("Shadbala")
|
||
|
||
vimshottari_timeline = None
|
||
try:
|
||
vimshottari_timeline = _vimshottari_timeline(candidate_at, planet_longitudes["Moon"])
|
||
except (KeyError, TypeError, ValueError):
|
||
vimshottari_timeline = None
|
||
|
||
# BUG-1759: source-version Narayana (as the report / card); blocked -> layer unavailable, no fallback.
|
||
narayana_periods = None
|
||
try:
|
||
narayana_periods = _narayana_periods(ascendant_index, planet_longitudes)
|
||
except (KeyError, TypeError, ValueError):
|
||
narayana_periods = None
|
||
if narayana_periods is None:
|
||
blocked_layers.append("Narayana")
|
||
# 原值:明确选中 functional_profile=FUNCTIONAL_PROFILE_BPHS(原产品表)。
|
||
# 新值:默认档(PVR 表 30),传月亮盈亏。
|
||
# 原因:产品 2026-11-08 批准;占星师第五轮裁定 1、3;与报告/卡口径一致(BUG-1759)。
|
||
functional_profile = functional_benefics.derive_functional_benefic_malefic(
|
||
chart["ascendant"].get("sign"),
|
||
moon_paksha=functional_benefics.moon_paksha_from_planets(chart.get("planets")),
|
||
)
|
||
narayana_antardasha_by_md = _narayana_antardasha_table(narayana_periods, planet_longitudes)
|
||
|
||
birth_info = chart.get("birth_info") if isinstance(chart.get("birth_info"), dict) else {}
|
||
kp_snapshot = observe_kp_cusps(
|
||
birth_info.get("julian_day"),
|
||
float(request["lat"]),
|
||
float(request["lon"]),
|
||
)
|
||
if kp_snapshot.get("status") == "executed":
|
||
available_layers.append("KP_cusps")
|
||
else:
|
||
blocked_layers.append("KP_cusps")
|
||
|
||
feature_payload = {
|
||
"time": candidate_at.strftime("%H:%M"),
|
||
"ascendant_degree": ascendant_longitude,
|
||
"ascendant_sign_index": ascendant_index,
|
||
**_fine_minute_indices(
|
||
ascendant_longitude=ascendant_longitude,
|
||
candidate_at=candidate_at,
|
||
lat=float(request["lat"]),
|
||
lon=float(request["lon"]),
|
||
tz=float(request["tz"]),
|
||
sun_degree=planet_longitudes.get("Sun") if isinstance(planet_longitudes.get("Sun"), (int, float)) else None,
|
||
moon_degree=planet_longitudes.get("Moon") if isinstance(planet_longitudes.get("Moon"), (int, float)) else None,
|
||
),
|
||
**(kp_snapshot.get("indices") or {}),
|
||
"kp_cusps": kp_snapshot,
|
||
"varga_ascendants": varga_ascendants,
|
||
"arudha_signs": arudha_signs,
|
||
"available_layers": sorted(set(available_layers)),
|
||
"blocked_layers": sorted(set(blocked_layers)),
|
||
# 占星师 2026-11-08 第六轮 R1:校时运行标签含 Narayana 全套 profile 与算法版本。
|
||
"narayana_profile_config": dict(narayana_rath.DEFAULT_STEP_PROFILES),
|
||
"fingerprints": {
|
||
"natal": str(chart.get("result_hash") or _feature_hash({"ascendant": chart.get("ascendant"), "planets": chart.get("planets")})),
|
||
"vargas": _feature_hash(varga_ascendants),
|
||
"arudha": _feature_hash(arudha_signs),
|
||
"ashtakavarga": _feature_hash(ashtakavarga_result) if ashtakavarga_result is not None else "blocked",
|
||
"shadbala": _feature_hash(shadbala_result) if shadbala_result is not None else "blocked",
|
||
},
|
||
}
|
||
feature_payload["fingerprints"]["static"] = _feature_hash(feature_payload)
|
||
return {
|
||
"candidate_at": candidate_at,
|
||
"chart": chart,
|
||
"planet_longitudes": planet_longitudes,
|
||
"ascendant_longitude": ascendant_longitude,
|
||
"ascendant_index": ascendant_index,
|
||
"arudha_padas": arudha_padas,
|
||
"varga_charts": varga_charts,
|
||
"feature": feature_payload,
|
||
"ashtakavarga_result": ashtakavarga_result,
|
||
"shadbala_result": shadbala_result,
|
||
"vimshottari_timeline": vimshottari_timeline,
|
||
"narayana_periods": narayana_periods,
|
||
"functional_profile": functional_profile,
|
||
"narayana_antardasha_by_md": narayana_antardasha_by_md,
|
||
}
|
||
|
||
|
||
def compute_candidate_static_contexts(
|
||
request: RectificationEventRequest,
|
||
*,
|
||
candidates: Sequence[datetime] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request)
|
||
from scripts.rectification.candidate_window import candidate_positions
|
||
|
||
positions = candidate_positions(request, candidate_datetimes)
|
||
contexts = [{**build_candidate_static_context(request, candidate), **position}
|
||
for candidate, position in zip(candidate_datetimes, positions)]
|
||
_assert_single_profile_run(contexts)
|
||
return contexts
|
||
|
||
|
||
def _assert_single_profile_run(contexts: Sequence[dict[str, Any]]) -> str | None:
|
||
"""K3 (占星师 2026-11-08 第五轮逐项答复): one scoring run, one functional table."""
|
||
return functional_benefics.assert_single_functional_profile(
|
||
[context.get("functional_profile") for context in contexts], run="rectification")
|
||
|
||
|
||
def _candidate_row(
|
||
request: RectificationEventRequest,
|
||
context: dict[str, Any],
|
||
) -> CandidateScoreRow:
|
||
candidate_at = context["candidate_at"]
|
||
chart = context["chart"]
|
||
planet_longitudes = context["planet_longitudes"]
|
||
ascendant_index = context["ascendant_index"]
|
||
arudha_padas = context["arudha_padas"]
|
||
varga_charts = context["varga_charts"]
|
||
moon_longitude = planet_longitudes["Moon"]
|
||
evidence: list[CandidateEvidence] = []
|
||
missing_layers: list[str] = []
|
||
layer_points: dict[str, dict[str, float]] = {}
|
||
|
||
for event in independent_events(request["events"])[0]:
|
||
event_at = _event_datetime(event)
|
||
prefixes, _ = DOMAIN_CONFIG[event["domain"]]
|
||
domain_vargas = [varga_charts[prefix] for prefix in prefixes]
|
||
if any(chart is None for chart in domain_vargas):
|
||
missing_layers.extend(prefixes)
|
||
continue
|
||
try:
|
||
vimshottari = _active_vimshottari(
|
||
candidate_at,
|
||
moon_longitude,
|
||
event_at,
|
||
context.get("vimshottari_timeline"),
|
||
)
|
||
except (KeyError, TypeError, ValueError):
|
||
missing_layers.append("Vimshottari_MD_AD_PD")
|
||
continue
|
||
try:
|
||
narayana = _active_narayana(
|
||
ascendant_index,
|
||
planet_longitudes,
|
||
candidate_at,
|
||
event_at,
|
||
context.get("narayana_periods"),
|
||
context.get("narayana_antardasha_by_md"),
|
||
)
|
||
except (KeyError, TypeError, ValueError):
|
||
missing_layers.append("Narayana_MD_AD")
|
||
continue
|
||
if narayana[0] is None:
|
||
# BUG-1759: a blocked source-version step lands here (mandatory layer missing), never the old algorithm.
|
||
missing_layers.append("Narayana_MD_AD")
|
||
continue
|
||
if narayana[1] is None:
|
||
# 原值:小运定不出时整条 Narayana 当缺失层,这个事件不计分(连已定的大运一起丢掉)。
|
||
# 新值:只缺小运:大运照常计分,小运那一项不计,记 Narayana_AD。
|
||
# 原因:占星师 2026-11-08 第六轮 R2:某 MD 的小运取强未决只阻塞该 MD 的 AD / PD,已定 MD 保留。
|
||
missing_layers.append("Narayana_AD")
|
||
evidence.append(_score_event(
|
||
candidate_time=candidate_at.strftime("%H:%M"),
|
||
event=event,
|
||
natal_chart=chart,
|
||
varga_charts=[chart for chart in domain_vargas if chart is not None],
|
||
vimshottari=vimshottari,
|
||
narayana=narayana,
|
||
arudha_padas=arudha_padas,
|
||
functional=context.get("functional_profile"),
|
||
))
|
||
# Comparability policy (占星师 2026-11-08 验收清单 §6): points of each layer actually computed.
|
||
event_layers = layer_points.setdefault(event["id"], {"dasha_varga_core": evidence[-1]["points"]})
|
||
transit_rules = _controlled_transit_rules(
|
||
request,
|
||
event,
|
||
ascendant_index,
|
||
DOMAIN_CONFIG[event["domain"]][1],
|
||
context.get("_transit_chart_cache"),
|
||
)
|
||
transit_points = 0.25 * len(transit_rules) * precision_weight(event["precision"])
|
||
event_layers["controlled_transit"] = transit_points
|
||
if transit_rules:
|
||
evidence[-1]["rule_ids"].extend(transit_rules)
|
||
evidence[-1]["points"] = round(evidence[-1]["points"] + transit_points, 4)
|
||
av_rules, av_points = _ashtakavarga_auxiliary(
|
||
chart,
|
||
ascendant_index,
|
||
DOMAIN_CONFIG[event["domain"]][1],
|
||
context.get("ashtakavarga_result"),
|
||
)
|
||
if context.get("ashtakavarga_result") is not None:
|
||
event_layers["Ashtakavarga"] = av_points * precision_weight(event["precision"])
|
||
if av_rules:
|
||
evidence[-1]["rule_ids"].extend(av_rules)
|
||
evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4)
|
||
shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary(
|
||
chart,
|
||
candidate_at.hour + candidate_at.minute / 60,
|
||
vimshottari,
|
||
context.get("shadbala_result"),
|
||
)
|
||
if context.get("shadbala_result") is not None:
|
||
event_layers["Shadbala"] = shadbala_points * precision_weight(event["precision"])
|
||
if shadbala_rules:
|
||
evidence[-1]["rule_ids"].extend(shadbala_rules)
|
||
evidence[-1]["points"] = round(evidence[-1]["points"] + shadbala_points * precision_weight(event["precision"]), 4)
|
||
|
||
return {
|
||
"time": candidate_at.strftime("%H:%M"),
|
||
"score": round(sum(item["points"] for item in evidence), 4),
|
||
"evidence": evidence,
|
||
"missing_layers": sorted(set(
|
||
missing_layers
|
||
+ [layer for layer in context["feature"]["blocked_layers"] if layer not in OBSERVATION_ONLY_LAYERS]
|
||
)),
|
||
"layer_points": layer_points,
|
||
}
|
||
|
||
|
||
def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict, str]:
|
||
payload = {
|
||
"schema_version": INPUT_CONTRACT_VERSION,
|
||
"birth_date": request["birth_date"],
|
||
"candidate_range": {
|
||
"start_time": request["start_time"],
|
||
"end_time": request["end_time"],
|
||
"step_minutes": int(request.get("minute_step") or 1),
|
||
},
|
||
"location": {
|
||
"latitude": float(request["lat"]),
|
||
"longitude": float(request["lon"]),
|
||
"timezone_offset": float(request["tz"]),
|
||
"timezone_source": "explicit_offset",
|
||
},
|
||
"events": [{
|
||
"id": event["id"],
|
||
"domain": event["domain"],
|
||
"event_kind": event.get("event_kind", event["domain"]),
|
||
"date": event["date"],
|
||
"precision": event["precision"],
|
||
"summary": event.get("summary", ""),
|
||
} for event in request["events"]],
|
||
"calculation": {
|
||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||
"node_mode": request.get("node_mode", NODE_MODE),
|
||
"ephemeris_source": "swisseph_calc_ut",
|
||
},
|
||
}
|
||
if "candidate_intervals" in request:
|
||
payload["schema_version"] = "rectification-candidate-input-v3"
|
||
payload["candidate_intervals"] = request["candidate_intervals"]
|
||
normalized = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||
return payload, hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _leave_one_event_out(rows: list[CandidateScoreRow], events: list[LifeEvent]) -> dict:
|
||
if len(events) < 2:
|
||
return {"status": "blocked", "runs": [], "reason": "insufficient_events"}
|
||
original_top = max(row["score"] for row in rows)
|
||
original_times = {row["time"] for row in rows if row["score"] == original_top}
|
||
runs = []
|
||
all_stable = True
|
||
for event in events:
|
||
rescored = []
|
||
for row in rows:
|
||
removed = sum(item["points"] for item in row["evidence"] if item["event_id"] == event["id"])
|
||
rescored.append((row["time"], round(row["score"] - removed, 4)))
|
||
top_score = max(score for _, score in rescored)
|
||
top_times = [candidate_time for candidate_time, score in rescored if score == top_score]
|
||
stable = len(top_times) == 1 and set(top_times) == original_times
|
||
all_stable = all_stable and stable
|
||
runs.append({
|
||
"removed_event_id": event["id"],
|
||
"top_times": top_times,
|
||
"top_score": top_score,
|
||
"original_leader_retained": stable,
|
||
})
|
||
return {
|
||
"status": "pass" if all_stable else "fail",
|
||
"runs": runs,
|
||
"boundary": "Leave-one-event-out must retain the same unique leading minute.",
|
||
}
|
||
|
||
|
||
def compute_event_candidate_result(request: RectificationEventRequest) -> CandidateResult:
|
||
"""Compute actual minute candidates locally and return a guarded result."""
|
||
return adjudicate_event_candidate_rows(request, compute_event_candidate_rows(request))
|
||
|
||
|
||
def adjudicate_event_candidate_rows(
|
||
request: RectificationEventRequest,
|
||
rows: list[CandidateScoreRow],
|
||
) -> CandidateResult:
|
||
"""Adjudicate already-computed rows using the production evidence gates."""
|
||
normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
input_contract, input_hash = _canonical_input_contract(request)
|
||
# A repeated fact (same domain, date and precision) is the same test, not independent support (§6).
|
||
events, duplicate_ids = independent_events(request["events"])
|
||
leave_one_out = _leave_one_event_out(rows, events)
|
||
result = adjudicate_candidate_rows(
|
||
rows,
|
||
event_count=len(events),
|
||
domain_count=len({event["domain"] for event in events}),
|
||
request_fingerprint=fingerprint,
|
||
canonical_input_hash=input_hash,
|
||
calculation_contract=input_contract,
|
||
leave_one_event_out=leave_one_out,
|
||
)
|
||
result["duplicate_event_ids"] = duplicate_ids
|
||
return result
|
||
|
||
|
||
def compute_event_candidate_rows(
|
||
request: RectificationEventRequest,
|
||
*,
|
||
candidates: Sequence[datetime] | None = None,
|
||
static_contexts: Sequence[dict[str, Any]] | None = None,
|
||
) -> list[CandidateScoreRow]:
|
||
"""Return every computed minute row while reusing one static chart scan per candidate."""
|
||
contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates)
|
||
_assert_single_profile_run(contexts)
|
||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||
return [
|
||
_candidate_row(request, {**context, "_transit_chart_cache": transit_chart_cache})
|
||
for context in contexts
|
||
]
|