feat: add agent guided birth time rectification
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# /// 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 pathlib import Path
|
||||
from typing import Final, assert_never
|
||||
|
||||
from scripts.active_rectification_events import (
|
||||
CandidateEvidence,
|
||||
CandidateResult,
|
||||
CandidateScoreRow,
|
||||
EventDomain,
|
||||
LifeEvent,
|
||||
RectificationEventRequest,
|
||||
adjudicate_candidate_rows,
|
||||
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 narayana_dasha # noqa: E402
|
||||
import varga # noqa: E402
|
||||
|
||||
DomainConfig = tuple[str, tuple[int, ...]]
|
||||
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
|
||||
"education": ("D24", (4, 5, 9)),
|
||||
"relocation": ("D4", (4, 12)),
|
||||
"relationship": ("D9", (7,)),
|
||||
"career": ("D10", (10,)),
|
||||
"health_pressure": ("D30", (6, 8, 12)),
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
birth_date = date.fromisoformat(request["birth_date"])
|
||||
start = datetime.combine(birth_date, time.fromisoformat(request["start_time"]))
|
||||
end = datetime.combine(birth_date, time.fromisoformat(request["end_time"]))
|
||||
if end < start:
|
||||
end += timedelta(days=1)
|
||||
minute_count = int((end - start).total_seconds() // 60) + 1
|
||||
if minute_count < 1 or minute_count > 1_440:
|
||||
raise RectificationEventCalculationError("candidate_range_out_of_bounds")
|
||||
return [start + timedelta(minutes=offset) for offset in range(minute_count)]
|
||||
|
||||
|
||||
def _active_vimshottari(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
event_at: datetime,
|
||||
) -> tuple[str, str]:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||||
minor = dasha_analyzer.find_current_sub(
|
||||
dasha_analyzer.build_antardasha(major),
|
||||
event_at,
|
||||
)
|
||||
return str(major["lord"]), str(minor["lord"])
|
||||
|
||||
|
||||
def _active_narayana(
|
||||
ascendant_index: int,
|
||||
planet_longitudes: dict[str, float],
|
||||
birth_at: datetime,
|
||||
event_at: datetime,
|
||||
) -> tuple[int | None, int | None]:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
minor = active.get("ad") or {}
|
||||
return major.get("sign_idx"), 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 _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_chart: dict,
|
||||
vimshottari: tuple[str, str],
|
||||
narayana: tuple[int | None, int | None],
|
||||
) -> CandidateEvidence:
|
||||
_, target_houses = DOMAIN_CONFIG[event["domain"]]
|
||||
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
||||
target_lords = _house_lords(ascendant_index, target_houses)
|
||||
major_lord, minor_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"),
|
||||
):
|
||||
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
|
||||
if _varga_house(varga_chart, lord) in target_houses:
|
||||
rules.append(f"{label}_domain_varga")
|
||||
points += weight / 2
|
||||
|
||||
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
|
||||
|
||||
weighted_points = round(points * precision_weight(event["precision"]), 4)
|
||||
return {
|
||||
"event_id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"candidate_time": candidate_time,
|
||||
"rule_ids": rules or ["no_domain_activation"],
|
||||
"points": weighted_points,
|
||||
}
|
||||
|
||||
|
||||
def _candidate_row(
|
||||
request: RectificationEventRequest,
|
||||
candidate_at: datetime,
|
||||
) -> CandidateScoreRow:
|
||||
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": "lahiri",
|
||||
"node_mode": "true",
|
||||
})
|
||||
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)
|
||||
charts = varga.calc_all_vargas(
|
||||
planet_longitudes,
|
||||
ascendant_longitude,
|
||||
divisions=[4, 9, 10, 24, 30],
|
||||
)
|
||||
moon_longitude = planet_longitudes["Moon"]
|
||||
evidence: list[CandidateEvidence] = []
|
||||
missing_layers: list[str] = []
|
||||
|
||||
for event in request["events"]:
|
||||
event_at = _event_datetime(event)
|
||||
prefix, _ = DOMAIN_CONFIG[event["domain"]]
|
||||
domain_varga = _varga_chart(charts, prefix)
|
||||
if domain_varga is None:
|
||||
missing_layers.append(prefix)
|
||||
continue
|
||||
vimshottari = _active_vimshottari(request["birth_date"], moon_longitude, event_at)
|
||||
narayana = _active_narayana(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
candidate_at,
|
||||
event_at,
|
||||
)
|
||||
evidence.append(_score_event(
|
||||
candidate_time=candidate_at.strftime("%H:%M"),
|
||||
event=event,
|
||||
natal_chart=chart,
|
||||
varga_chart=domain_varga,
|
||||
vimshottari=vimshottari,
|
||||
narayana=narayana,
|
||||
))
|
||||
|
||||
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)),
|
||||
}
|
||||
|
||||
|
||||
def compute_event_candidate_result(request: RectificationEventRequest) -> CandidateResult:
|
||||
"""Compute actual minute candidates locally and return a guarded result."""
|
||||
normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
rows = [_candidate_row(request, candidate) for candidate in _candidate_datetimes(request)]
|
||||
return adjudicate_candidate_rows(
|
||||
rows,
|
||||
event_count=len(request["events"]),
|
||||
domain_count=len({event["domain"] for event in request["events"]}),
|
||||
request_fingerprint=fingerprint,
|
||||
)
|
||||
Reference in New Issue
Block a user