Files
Jyotisha/scripts/rectification/scoring_service.py
T
Jesse_Chen 814c924e4a
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s
fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 09:10:37 +08:00

373 lines
15 KiB
Python

from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from collections.abc import Callable, Sequence
from datetime import date, timedelta
from functools import lru_cache
from typing import Any
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
from scripts.active_rectification_events import CandidateScoreRow
from scripts.rectification.dasha_transition_proximity import merge_transition_proximity
from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event
from scripts.rectification.case_holdout import holdout_event_ids
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-7"
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
PRECISION_WEIGHTS = {
"day": 1.0,
"month": 0.8,
"quarter": 0.65,
"year": 0.5,
"range": 0.35,
}
_ENGINE_KIND_BY_NATIVE_KIND: dict[str, tuple[str, str]] = {
"education_start": ("education", "education_milestone"),
"education_completion": ("education", "education_milestone"),
"education_interruption": ("education", "education_milestone"),
"education_change": ("education", "education_milestone"),
"education_milestone": ("education", "education_milestone"),
"career_entry": ("career", "career_change"),
"career_change": ("career", "career_change"),
"promotion": ("career", "career_change"),
"career_pressure": ("career", "career_change"),
"career_exit": ("career", "career_change"),
"business_start": ("career", "career_change"),
"relationship_start": ("relationship", "relationship_start"),
"relationship_commitment": ("relationship", "relationship_start"),
"relationship_separation": ("relationship", "relationship_change"),
"relationship_end": ("relationship", "relationship_change"),
"relationship_change": ("relationship", "relationship_change"),
"relocation": ("relocation", "relocation"),
"foreign_move": ("relocation", "relocation"),
"return": ("relocation", "relocation"),
"home_change": ("relocation", "relocation"),
"finance_gain": ("finance", "finance_change"),
"finance_loss": ("finance", "finance_change"),
"income_change": ("finance", "finance_change"),
"asset_change": ("finance", "finance_change"),
"finance_change": ("finance", "finance_change"),
"self_health_event": ("health_pressure", "self_health_event"),
"pressure_period": ("health_pressure", "self_health_event"),
"family_event": ("family", "family_event"),
"appearance_note": ("appearance", "appearance_note"),
"birthmark_or_scar": ("appearance", "birthmark_or_scar"),
"occupation_note": ("occupation", "occupation_note"),
}
def _parse(value: str) -> date:
return date.fromisoformat(value)
def _iso(value: date) -> str:
return value.isoformat()
def _month_end(value: date) -> date:
next_month = value.replace(day=28) + timedelta(days=4)
return next_month - timedelta(days=next_month.day)
def _even_dates(start: date, end: date, count: int) -> list[date]:
if count <= 1 or start == end:
return [start]
span = (end - start).days
return sorted({start + timedelta(days=round(span * index / (count - 1))) for index in range(count)})
def sample_event_dates(event: LifeEvent) -> list[str]:
start, end = _parse(event["date_start"]), _parse(event["date_end"])
precision = event["precision"]
if start > end:
raise ValueError("invalid_event_date_range")
if precision == "day" or start == end:
return [_iso(start)]
if precision == "month":
middle = start.replace(day=min(15, _month_end(start).day))
return sorted({_iso(start), _iso(middle), _iso(end)})
if precision == "quarter":
values: list[date] = []
cursor = start.replace(day=15)
while cursor <= end and len(values) < 3:
values.append(cursor)
cursor = (cursor.replace(day=28) + timedelta(days=4)).replace(day=15)
return [_iso(item) for item in values] or [_iso(start)]
if precision == "year":
return [_iso(start.replace(month=month, day=15)) for month in range(1, 13)]
return [_iso(item) for item in _even_dates(start, end, 12)]
def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]:
engine_domain, _ = _ENGINE_KIND_BY_NATIVE_KIND[event["event_kind"]]
legacy_request = {
"birth_date": request["birth_date"],
"start_time": request["start_time"],
"end_time": request["end_time"],
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"events": [{
"id": event["id"], "domain": engine_domain,
"event_kind": event["event_kind"],
"date": sampled_date, "precision": "day", "summary": event.get("summary", ""),
}],
}
for key in ("ayanamsa", "node_mode", "minute_step"):
if key in request:
legacy_request[key] = request[key]
return legacy_request
def _canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
@lru_cache(maxsize=4096)
def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
return tuple(compute_event_candidate_rows(json.loads(serialized)))
_SUPPORT_RULES = (
"functional_benefic_auxiliary",
"arudha_auxiliary",
"ashtakavarga_target_house_support_auxiliary",
"shadbala_sthana_drik_naisargika_support_auxiliary",
"controlled_transit_jupiter_domain_house",
)
_PRESSURE_RULES = (
"functional_malefic_auxiliary",
"ashtakavarga_target_house_pressure_auxiliary",
"shadbala_sthana_drik_naisargika_pressure_auxiliary",
"controlled_transit_saturn_domain_house",
)
_KIND_SEMANTICS: dict[str, tuple[int, float]] = {
"education_start": (1, 1.0),
"education_completion": (1, 1.2),
"education_interruption": (-1, 1.0),
"education_change": (0, 1.0),
"career_entry": (1, 1.0),
"career_change": (0, 1.0),
"promotion": (1, 1.2),
"career_pressure": (-1, 1.0),
"career_exit": (-1, 1.2),
"business_start": (1, 1.2),
"relationship_start": (1, 1.0),
"relationship_commitment": (1, 1.2),
"relationship_separation": (-1, 1.0),
"relationship_end": (-1, 1.2),
"relationship_change": (-1, 1.0),
"relocation": (0, 1.0),
"foreign_move": (1, 1.0),
"return": (1, 0.8),
"home_change": (0, 1.0),
"finance_gain": (1, 1.0),
"finance_loss": (-1, 1.0),
"income_change": (0, 1.0),
"asset_change": (0, 1.0),
"self_health_event": (-1, 1.0),
"pressure_period": (-1, 1.2),
"family_event": (0, 1.0),
"appearance_note": (0, 0.8),
"birthmark_or_scar": (-1, 0.8),
"occupation_note": (0, 0.8),
}
def precision_weight(precision: str) -> float:
return PRECISION_WEIGHTS[precision]
def public_technique_layers(domain: str, rule_ids: Sequence[str]) -> list[str]:
"""Public methods actually computed for this event. Career always lists D1-10 and D10."""
layers: set[str] = set()
for rule in rule_ids:
if rule.startswith(("event_kind:", "event_kind_profile:")):
continue
if "transition_proximity" in rule:
layers.add("dasha-transition-proximity")
continue
layers.add(rule.split(":", 1)[0])
if domain == "career":
layers.update({"d1-rashi", "d10-dashamsa"})
elif domain == "family":
layers.update({"d1-rashi", "d12-dwadashamsha", "d7-saptamsha", "d3-drekkana"})
elif domain == "education":
layers.update({"d1-rashi", "d24-chaturvimshamsha", "d5-panchamsha"})
elif domain == "relocation":
layers.update({"d1-rashi", "d4-chaturthamsha"})
elif domain == "finance":
layers.update({"d1-rashi", "d2-hora", "d11-labhamsha"})
elif domain == "health_pressure":
layers.update({"d1-rashi", "d30-trimshamsha"})
elif domain in {"appearance", "marks"}:
layers.add("d1-rashi")
elif domain == "occupation":
layers.update({"d1-rashi", "d10-dashamsa"})
return sorted(layers)
def _event_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float:
direction, intensity = _KIND_SEMANTICS.get(event_kind, (0, 1.0))
support = sum(any(rule.endswith(marker) for marker in _SUPPORT_RULES) for rule in rule_ids)
pressure = sum(any(rule.endswith(marker) for marker in _PRESSURE_RULES) for rule in rule_ids)
semantic_signal = direction * (support - pressure) * intensity
return max(0.8, min(1.2, 1 + 0.08 * semantic_signal))
def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]:
event_kind = event["event_kind"]
rules = list(evidence["rule_ids"])
direction, _ = _KIND_SEMANTICS.get(event_kind, (0, 1.0))
semantic_label = "support" if direction > 0 else "pressure" if direction < 0 else "change"
return {
**evidence,
"rule_ids": [*rules, f"event_kind_profile:{event_kind}:{semantic_label}"],
"points": round(
float(evidence["points"])
* _event_kind_factor(event_kind, rules)
* precision_weight(event["precision"]),
4,
),
}
def scoreable_request(request: RectificationRequest) -> RectificationRequest:
return {**request, "events": [event for event in request["events"] if is_scoreable_event(event)]}
def build_event_contribution_matrix(
request: RectificationRequest,
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
static_contexts: Sequence[dict[str, Any]] | None = None,
) -> dict[str, Any]:
scoring_request = scoreable_request(request)
if not scoring_request["events"]:
return {
"candidate_times": [], "matrix": {}, "date_sensitivity": [],
"missing_layers": [], "static_contexts": None,
}
if static_contexts is None and row_provider is None:
static_contexts = compute_candidate_static_contexts(scoring_request)
provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts))
matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
missing_layers: set[str] = set()
date_sensitivity: list[dict[str, Any]] = []
candidate_grid: list[str] | None = None
for event in scoring_request["events"]:
samples = sample_event_dates(event)
sample_rows = []
for sampled in samples:
rows = list(provider(_legacy_request(scoring_request, event, sampled)))
sample_rows.append([
{**row, "score": adjusted["points"], "evidence": [adjusted]}
for row in rows
for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0])]
])
grids = [[row["time"] for row in rows] for rows in sample_rows]
if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid):
raise ValueError("candidate_grid_mismatch")
candidate_grid = grids[0]
winners = []
for rows in sample_rows:
winners.append(max(rows, key=lambda row: row["score"])["time"])
missing_layers.update(layer for row in rows for layer in row["missing_layers"])
for index, candidate_time in enumerate(candidate_grid):
evidences = [rows[index]["evidence"][0] for rows in sample_rows]
points = [float(item["points"]) for item in evidences]
matrix[event["id"]][candidate_time] = {
"points": round(sum(points) / len(points), 4),
"rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}),
"technique_layers": public_technique_layers(
event["domain"],
[rule for item in evidences for rule in item["rule_ids"]],
),
}
winner = max(set(winners), key=winners.count)
mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid)
variance = sum((matrix[event["id"]][time]["points"] - mean) ** 2 for time in candidate_grid) / len(candidate_grid)
date_sensitivity.append({
"event_id": event["id"],
"declared_date_range": {"start": event["date_start"], "end": event["date_end"], "precision": event["precision"]},
"sample_dates": samples,
"winner_retention_rate": winners.count(winner) / len(winners),
"score_variance": round(variance, 6),
"sample_winners": winners,
})
matrix_payload = dict(matrix)
if static_contexts:
merge_transition_proximity(
matrix_payload,
scoring_request["events"],
static_contexts,
scoring_request["birth_date"],
public_technique_layers=public_technique_layers,
)
return {
"candidate_times": candidate_grid or [],
"matrix": matrix_payload,
"date_sensitivity": date_sensitivity,
"missing_layers": sorted(missing_layers),
"static_contexts": static_contexts,
}
def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> list[CandidateScoreRow]:
holdout = holdout_event_ids(request["events"])
rows: list[CandidateScoreRow] = []
for candidate_time in built["candidate_times"]:
evidence = []
for event in request["events"]:
if not is_scoreable_event(event):
continue
if event["id"] in holdout:
continue
contribution = (built.get("matrix") or {}).get(event["id"], {}).get(candidate_time)
if not isinstance(contribution, dict):
continue
evidence.append({
"event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time,
"rule_ids": contribution["rule_ids"], "points": contribution["points"],
})
rows.append({
"time": candidate_time,
"score": round(sum(item["points"] for item in evidence), 4),
"evidence": evidence,
"missing_layers": built["missing_layers"],
})
return rows
def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
def json_number(value: float) -> int | float:
return int(value) if value.is_integer() else value
spec = {
"version": INPUT_CONTRACT_VERSION,
"birthDate": request["birth_date"],
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
"latitude": json_number(request["lat"]),
"longitude": json_number(request["lon"]),
"timezoneOffsetHours": json_number(request["tz"]),
"ayanamsa": request.get("ayanamsa", "raman"),
"nodeMode": request.get("node_mode", "mean"),
"minuteStep": int(request.get("minute_step") or 1),
}
for source, target in (
("birth_time_source", "birthTimeSource"),
("timezone_id", "timezoneId"),
("timezone_source", "timezoneSource"),
("local_time_status", "localTimeStatus"),
):
if source in request:
spec[target] = request[source] # type: ignore[literal-required]
return spec
def sha256(value: Any) -> str:
return hashlib.sha256(_canonical(value).encode()).hexdigest()