refactor: rebuild birth time rectification agent
This commit is contained in:
@@ -14,7 +14,7 @@ import sys
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from collections.abc import Sequence
|
||||
from typing import Final, assert_never
|
||||
from typing import Any, Final, assert_never
|
||||
|
||||
from scripts.active_rectification_events import (
|
||||
CandidateEvidence,
|
||||
@@ -316,10 +316,22 @@ def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float
|
||||
return [], 0.0
|
||||
|
||||
|
||||
def _candidate_row(
|
||||
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 build_candidate_static_context(
|
||||
request: RectificationEventRequest,
|
||||
candidate_at: datetime,
|
||||
) -> CandidateScoreRow:
|
||||
) -> 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,
|
||||
@@ -340,16 +352,102 @@ def _candidate_row(
|
||||
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 {},
|
||||
}
|
||||
arudha_padas = {**(arudha.get("padas") or {}), "UL": arudha.get("upapada") or {}}
|
||||
charts = varga.calc_all_vargas(
|
||||
planet_longitudes,
|
||||
ascendant_longitude,
|
||||
divisions=[2, 4, 9, 10, 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", "D4", "D9", "D10", "D11", "D24", "D30")
|
||||
}
|
||||
available_layers = ["D1"]
|
||||
blocked_layers = ["KP_cusps"]
|
||||
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")
|
||||
|
||||
feature_payload = {
|
||||
"time": candidate_at.strftime("%H:%M"),
|
||||
"ascendant_degree": ascendant_longitude,
|
||||
"ascendant_sign_index": ascendant_index,
|
||||
"varga_ascendants": varga_ascendants,
|
||||
"arudha_signs": arudha_signs,
|
||||
"available_layers": sorted(set(available_layers)),
|
||||
"blocked_layers": sorted(set(blocked_layers)),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return [build_candidate_static_context(request, candidate) for candidate in candidate_datetimes]
|
||||
|
||||
|
||||
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] = []
|
||||
@@ -357,7 +455,7 @@ def _candidate_row(
|
||||
for event in request["events"]:
|
||||
event_at = _event_datetime(event)
|
||||
prefixes, _ = DOMAIN_CONFIG[event["domain"]]
|
||||
domain_vargas = [d11_chart if prefix == "D11" else _varga_chart(charts, prefix) for prefix in prefixes]
|
||||
domain_vargas = [varga_charts[prefix] for prefix in prefixes]
|
||||
if any(chart is None for chart in domain_vargas):
|
||||
missing_layers.extend(prefixes)
|
||||
continue
|
||||
@@ -407,7 +505,7 @@ def _candidate_row(
|
||||
"time": candidate_at.strftime("%H:%M"),
|
||||
"score": round(sum(item["points"] for item in evidence), 4),
|
||||
"evidence": evidence,
|
||||
"missing_layers": sorted(set(missing_layers)),
|
||||
"missing_layers": sorted(set(missing_layers + context["feature"]["blocked_layers"])),
|
||||
}
|
||||
|
||||
|
||||
@@ -501,7 +599,8 @@ 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 without performing release adjudication."""
|
||||
candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request)
|
||||
return [_candidate_row(request, candidate) for candidate in candidate_datetimes]
|
||||
"""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)
|
||||
return [_candidate_row(request, context) for context in contexts]
|
||||
|
||||
@@ -2,219 +2,16 @@
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Range-preserving event scoring for the asynchronous rectification V4 worker."""
|
||||
|
||||
"""Compatibility entrypoint backed by the formal V5 score service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Final, Literal, NotRequired, TypedDict
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
from typing import Any
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_event_candidate_rows
|
||||
from scripts.active_rectification_events import CandidateEvidence, CandidateScoreRow
|
||||
|
||||
ALGORITHM_VERSION: Final = "rectification-v4-range-scoring-1"
|
||||
INPUT_CONTRACT_VERSION: Final = "rectification-calculation-spec-v4"
|
||||
|
||||
EventDomain = Literal["education", "relocation", "relationship", "career", "finance", "health_pressure"]
|
||||
EventPrecision = Literal["day", "month", "quarter", "year", "range"]
|
||||
from scripts.rectification.api_service import score_candidates
|
||||
|
||||
|
||||
def _json_compatible_numbers(value: Any) -> Any:
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: _json_compatible_numbers(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_compatible_numbers(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class RangeLifeEvent(TypedDict):
|
||||
id: str
|
||||
domain: EventDomain
|
||||
event_kind: str
|
||||
date_start: str
|
||||
date_end: str
|
||||
precision: EventPrecision
|
||||
summary: NotRequired[str]
|
||||
|
||||
|
||||
class RangeRectificationRequest(TypedDict):
|
||||
birth_date: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
lat: float
|
||||
lon: float
|
||||
tz: float
|
||||
events: list[RangeLifeEvent]
|
||||
|
||||
|
||||
def _legacy_request(request: RangeRectificationRequest, boundary: Literal["start", "end"]) -> dict[str, Any]:
|
||||
return {
|
||||
"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": event["domain"],
|
||||
"date": event[f"date_{boundary}"],
|
||||
"precision": "day",
|
||||
"summary": event.get("summary", ""),
|
||||
} for event in request["events"]],
|
||||
}
|
||||
|
||||
|
||||
def _evidence_by_event(row: CandidateScoreRow) -> dict[str, CandidateEvidence]:
|
||||
return {item["event_id"]: item for item in row["evidence"]}
|
||||
|
||||
|
||||
def _average_rows(
|
||||
lower_rows: Sequence[CandidateScoreRow],
|
||||
upper_rows: Sequence[CandidateScoreRow],
|
||||
) -> list[CandidateScoreRow]:
|
||||
if [row["time"] for row in lower_rows] != [row["time"] for row in upper_rows]:
|
||||
raise ValueError("candidate_grid_mismatch")
|
||||
averaged: list[CandidateScoreRow] = []
|
||||
for lower, upper in zip(lower_rows, upper_rows, strict=True):
|
||||
lower_events = _evidence_by_event(lower)
|
||||
upper_events = _evidence_by_event(upper)
|
||||
evidence: list[CandidateEvidence] = []
|
||||
for event_id in sorted(set(lower_events) | set(upper_events)):
|
||||
lower_item = lower_events.get(event_id)
|
||||
upper_item = upper_events.get(event_id)
|
||||
source = lower_item or upper_item
|
||||
if source is None:
|
||||
continue
|
||||
lower_points = lower_item["points"] if lower_item else 0.0
|
||||
upper_points = upper_item["points"] if upper_item else 0.0
|
||||
evidence.append({
|
||||
"event_id": event_id,
|
||||
"domain": source["domain"],
|
||||
"candidate_time": lower["time"],
|
||||
"rule_ids": sorted(set(
|
||||
(lower_item or {}).get("rule_ids", [])
|
||||
+ (upper_item or {}).get("rule_ids", [])
|
||||
+ ["date_range_boundaries_averaged"]
|
||||
)),
|
||||
"points": round((lower_points + upper_points) / 2, 4),
|
||||
})
|
||||
averaged.append({
|
||||
"time": lower["time"],
|
||||
"score": round(sum(item["points"] for item in evidence), 4),
|
||||
"evidence": evidence,
|
||||
"missing_layers": sorted(set(lower["missing_layers"] + upper["missing_layers"])),
|
||||
})
|
||||
return averaged
|
||||
|
||||
|
||||
def _minute_value(value: str) -> int:
|
||||
hour, minute = value.split(":", maxsplit=1)
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _next_minute(previous: str, current: str) -> bool:
|
||||
return (_minute_value(current) - _minute_value(previous)) % 1_440 == 1
|
||||
|
||||
|
||||
def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = 0.97) -> list[str]:
|
||||
if not rows:
|
||||
return []
|
||||
peak = max(row["score"] for row in rows)
|
||||
floor = peak * relative_floor if peak >= 0 else peak / relative_floor
|
||||
viable = sorted((row for row in rows if row["score"] >= floor), key=lambda row: _minute_value(row["time"]))
|
||||
clusters: list[list[CandidateScoreRow]] = []
|
||||
for row in viable:
|
||||
if clusters and _next_minute(clusters[-1][-1]["time"], row["time"]):
|
||||
clusters[-1].append(row)
|
||||
else:
|
||||
clusters.append([row])
|
||||
if not clusters:
|
||||
return []
|
||||
clusters.sort(key=lambda group: (-max(row["score"] for row in group), -sum(max(row["score"], 0) for row in group)))
|
||||
return [row["time"] for row in clusters[0]]
|
||||
|
||||
|
||||
def _top_time(rows: Sequence[CandidateScoreRow]) -> str | None:
|
||||
if not rows:
|
||||
return None
|
||||
top = max(row["score"] for row in rows)
|
||||
return next(row["time"] for row in rows if row["score"] == top)
|
||||
|
||||
|
||||
def _leave_one_out(rows: Sequence[CandidateScoreRow], event_ids: Sequence[str], primary: set[str]) -> dict[str, Any]:
|
||||
runs = []
|
||||
retained = 0
|
||||
for event_id in event_ids:
|
||||
rescored = []
|
||||
for row in rows:
|
||||
removed = sum(item["points"] for item in row["evidence"] if item["event_id"] == event_id)
|
||||
rescored.append({**row, "score": round(row["score"] - removed, 4)})
|
||||
winner = _top_time(rescored)
|
||||
stable = winner in primary
|
||||
retained += int(stable)
|
||||
runs.append({"removed_event_id": event_id, "winner": winner, "primary_cluster_retained": stable})
|
||||
return {
|
||||
"retention_rate": retained / len(event_ids) if event_ids else 0.0,
|
||||
"runs": runs,
|
||||
}
|
||||
|
||||
|
||||
def score_life_events_v4(request: RangeRectificationRequest) -> dict[str, Any]:
|
||||
lower_rows = compute_event_candidate_rows(_legacy_request(request, "start"))
|
||||
upper_rows = compute_event_candidate_rows(_legacy_request(request, "end"))
|
||||
rows = _average_rows(lower_rows, upper_rows)
|
||||
primary = _primary_cluster(rows)
|
||||
primary_set = set(primary)
|
||||
lower_winner = _top_time(lower_rows)
|
||||
upper_winner = _top_time(upper_rows)
|
||||
date_retention = sum(winner in primary_set for winner in (lower_winner, upper_winner)) / 2
|
||||
loo = _leave_one_out(rows, [event["id"] for event in request["events"]], primary_set)
|
||||
normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
spec = {
|
||||
"version": INPUT_CONTRACT_VERSION,
|
||||
"birthDate": request["birth_date"],
|
||||
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
|
||||
"latitude": request["lat"],
|
||||
"longitude": request["lon"],
|
||||
"timezoneOffsetHours": request["tz"],
|
||||
"ayanamsa": "lahiri",
|
||||
"nodeMode": "mean",
|
||||
"minuteStep": 1,
|
||||
}
|
||||
spec_hash = hashlib.sha256(json.dumps(
|
||||
_json_compatible_numbers(spec), sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")).hexdigest()
|
||||
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
|
||||
candidates = [{
|
||||
"time": row["time"],
|
||||
"score": row["score"],
|
||||
"supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0],
|
||||
"conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0],
|
||||
} for row in rows]
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"calculation_spec": spec,
|
||||
"calculation_spec_hash": spec_hash,
|
||||
"candidate_scores": candidates,
|
||||
"primary_cluster_times": primary,
|
||||
"robustness": {
|
||||
"neighbor_support_minutes": len(primary),
|
||||
"leave_one_out_retention_rate": loo["retention_rate"],
|
||||
"date_sensitivity_retention_rate": date_retention,
|
||||
"date_boundary_winners": {"start": lower_winner, "end": upper_winner},
|
||||
"leave_one_out": loo,
|
||||
},
|
||||
"missing_layers": missing_layers,
|
||||
"can_confirm_exact_minute": False,
|
||||
}
|
||||
def score_life_events_v4(request: dict[str, Any]) -> dict[str, Any]:
|
||||
return score_candidates(request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1630,6 +1630,9 @@ API_COMMAND_MAP = {
|
||||
'active-rectification-score': '/api/active_rectification_score',
|
||||
'active-rectification-events': '/api/active_rectification_events',
|
||||
'active-rectification-events-v4': '/api/active_rectification_events_v4',
|
||||
'rectification-v5-candidate-features': '/api/rectification/v5/candidate-features',
|
||||
'rectification-v5-score': '/api/rectification/v5/score',
|
||||
'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics',
|
||||
'case-validation': '/api/case_validation',
|
||||
'divisional-yoga': '/api/divisional_yoga',
|
||||
'deep-varga-avastha': '/api/deep_varga_avastha',
|
||||
@@ -1665,6 +1668,9 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
|
||||
'/api/active_rectification_score',
|
||||
'/api/active_rectification_events',
|
||||
'/api/active_rectification_events_v4',
|
||||
'/api/rectification/v5/candidate-features',
|
||||
'/api/rectification/v5/score',
|
||||
'/api/rectification/v5/diagnostics',
|
||||
'/api/relationship',
|
||||
'/api/remedies',
|
||||
'/api/sade_sati',
|
||||
@@ -2114,6 +2120,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
elif path == '/api/active_rectification_events_v4':
|
||||
result = self._compute_active_rectification_events_v4(body)
|
||||
self._json(result)
|
||||
elif path == '/api/rectification/v5/candidate-features':
|
||||
self._json(self._compute_rectification_v5_candidate_features(body))
|
||||
elif path == '/api/rectification/v5/score':
|
||||
self._json(self._compute_rectification_v5_score(body))
|
||||
elif path == '/api/rectification/v5/diagnostics':
|
||||
self._json(self._compute_rectification_v5_diagnostics(body))
|
||||
elif path == '/api/dynamic_rectification_opportunities':
|
||||
result = self._compute_dynamic_rectification_opportunities(body)
|
||||
self._json(result)
|
||||
@@ -7527,100 +7539,45 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'tz': self._get_float(body, 'tz', 0, -14, 14),
|
||||
}
|
||||
|
||||
def _compute_active_rectification_events_v4(self, body):
|
||||
if not isinstance(body, dict):
|
||||
raise BadRequest('request body must be an object')
|
||||
|
||||
def required_text(name, pattern=None):
|
||||
value = body.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise BadRequest(f'{name} must be a string')
|
||||
value = value.strip()
|
||||
if pattern and not re.fullmatch(pattern, value):
|
||||
raise BadRequest(f'{name} has invalid format')
|
||||
return value
|
||||
|
||||
birth_date = required_text('birth_date', r'\d{4}-\d{2}-\d{2}')
|
||||
start_time = required_text('start_time', r'(?:[01]\d|2[0-3]):[0-5]\d')
|
||||
end_time = required_text('end_time', r'(?:[01]\d|2[0-3]):[0-5]\d')
|
||||
def _rectification_v5_request(self, body):
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
try:
|
||||
birth_day = datetime.strptime(birth_date, '%Y-%m-%d').date()
|
||||
return normalize_rectification_request(body)
|
||||
except ValueError as exc:
|
||||
raise BadRequest('birth_date must be a valid calendar date') from exc
|
||||
if start_time > end_time:
|
||||
raise BadRequest('start_time must not exceed end_time')
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
def bounded_number(name, minimum, maximum):
|
||||
value = body.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise BadRequest(f'{name} must be a finite number')
|
||||
value = float(value)
|
||||
if not minimum <= value <= maximum:
|
||||
raise BadRequest(f'{name} must be between {minimum} and {maximum}')
|
||||
return value
|
||||
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not 1 <= len(events) <= 100:
|
||||
raise BadRequest('events must contain between 1 and 100 items')
|
||||
allowed_kinds = {
|
||||
'education': {'education_milestone'},
|
||||
'relocation': {'relocation'},
|
||||
'relationship': {'relationship_start', 'relationship_end'},
|
||||
'career': {'career_change'},
|
||||
'finance': {'finance_change'},
|
||||
'health_pressure': {'health_event'},
|
||||
def _compute_rectification_v5_candidate_features(self, body):
|
||||
from scripts.rectification.api_service import candidate_features
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'rectification_v5_candidate_features',
|
||||
**candidate_features(self._rectification_v5_request(body)),
|
||||
}
|
||||
|
||||
def _compute_rectification_v5_score(self, body):
|
||||
from scripts.rectification.api_service import score_candidates
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'rectification_v5_score',
|
||||
**score_candidates(self._rectification_v5_request(body)),
|
||||
}
|
||||
|
||||
def _compute_rectification_v5_diagnostics(self, body):
|
||||
from scripts.rectification.api_service import diagnostics
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'rectification_v5_diagnostics',
|
||||
**diagnostics(self._rectification_v5_request(body)),
|
||||
}
|
||||
|
||||
def _compute_active_rectification_events_v4(self, body):
|
||||
"""Compatibility projection; validation and calculations are owned by V5 services."""
|
||||
from scripts.rectification.api_service import score_candidates
|
||||
return {
|
||||
'success': True,
|
||||
'endpoint': 'active_rectification_events_v4',
|
||||
**score_candidates(self._rectification_v5_request(body)),
|
||||
}
|
||||
allowed_precision = {'day', 'month', 'quarter', 'year', 'range'}
|
||||
cleaned_events = []
|
||||
today = datetime.now().date()
|
||||
for index, event in enumerate(events):
|
||||
if not isinstance(event, dict):
|
||||
raise BadRequest(f'events[{index}] must be an object')
|
||||
try:
|
||||
event_id = str(uuid.UUID(str(event.get('id') or '')))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise BadRequest(f'events[{index}].id must be a UUID') from exc
|
||||
domain = event.get('domain')
|
||||
event_kind = event.get('event_kind')
|
||||
precision = event.get('precision')
|
||||
if domain not in allowed_kinds:
|
||||
raise BadRequest(f'events[{index}].domain is not scoreable')
|
||||
if event_kind not in allowed_kinds[domain]:
|
||||
raise BadRequest(f'events[{index}].event_kind does not match domain')
|
||||
if precision not in allowed_precision:
|
||||
raise BadRequest(f'events[{index}].precision is invalid')
|
||||
try:
|
||||
start_day = datetime.strptime(str(event.get('date_start') or ''), '%Y-%m-%d').date()
|
||||
end_day = datetime.strptime(str(event.get('date_end') or ''), '%Y-%m-%d').date()
|
||||
except ValueError as exc:
|
||||
raise BadRequest(f'events[{index}] dates must be valid YYYY-MM-DD values') from exc
|
||||
if start_day > end_day:
|
||||
raise BadRequest(f'events[{index}].date_start must not exceed date_end')
|
||||
if start_day < birth_day or end_day > today:
|
||||
raise BadRequest(f'events[{index}] dates must be between birth_date and today')
|
||||
summary = event.get('summary', '')
|
||||
if not isinstance(summary, str) or len(summary) > 1000:
|
||||
raise BadRequest(f'events[{index}].summary must be a string up to 1000 characters')
|
||||
cleaned_events.append({
|
||||
'id': event_id,
|
||||
'domain': domain,
|
||||
'event_kind': event_kind,
|
||||
'date_start': start_day.isoformat(),
|
||||
'date_end': end_day.isoformat(),
|
||||
'precision': precision,
|
||||
'summary': summary.strip(),
|
||||
})
|
||||
module = _load_local_module('active_rectification_events_v4')
|
||||
result = module.score_life_events_v4({
|
||||
'birth_date': birth_date,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'lat': bounded_number('lat', -90, 90),
|
||||
'lon': bounded_number('lon', -180, 180),
|
||||
'tz': bounded_number('tz', -14, 14),
|
||||
'events': cleaned_events,
|
||||
})
|
||||
return {'success': True, 'endpoint': 'active_rectification_events_v4', **result}
|
||||
|
||||
def _compute_dynamic_rectification_opportunities(self, body):
|
||||
self._require_dynamic_rectification_token()
|
||||
@@ -8485,6 +8442,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'/api/active_rectification_score': self._compute_active_rectification_score,
|
||||
'/api/active_rectification_events': self._compute_active_rectification_events,
|
||||
'/api/active_rectification_events_v4': self._compute_active_rectification_events_v4,
|
||||
'/api/rectification/v5/candidate-features': self._compute_rectification_v5_candidate_features,
|
||||
'/api/rectification/v5/score': self._compute_rectification_v5_score,
|
||||
'/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics,
|
||||
'/api/relationship': self._compute_relationship,
|
||||
'/api/remedies': self._compute_remedies,
|
||||
'/api/sade_sati': self._compute_sade_sati,
|
||||
@@ -8611,6 +8571,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'/api/rectification_gate': 'Evaluate birth-time precision gate',
|
||||
'/api/active_rectification_events': 'Score dated life events against actual birth-time candidates',
|
||||
'/api/active_rectification_events_v4': 'Score immutable dated event ranges for asynchronous V4 rectification',
|
||||
'/api/rectification/v5/candidate-features': 'Scan immutable candidate static features once per calculation specification',
|
||||
'/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges',
|
||||
'/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix',
|
||||
'/api/relationship': 'Compute relationship and spouse-status evidence',
|
||||
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
|
||||
'/api/sade_sati': 'Compute Sade Sati status and phase',
|
||||
@@ -8673,6 +8636,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'/api/pancha_mahapurusha': {'planets': SAMPLE_PLANETS, 'sun_degree': SAMPLE_PLANETS['Sun']['lon']},
|
||||
'/api/prashna': {'planets': SAMPLE_PLANETS, 'question': 'general'},
|
||||
'/api/rectification_gate': {**base, 'declared_accuracy': 'minute', 'time_source': 'family_clear'},
|
||||
'/api/rectification/v5/candidate-features': {
|
||||
'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03',
|
||||
'lat': 36.419, 'lon': 114.213, 'tz': 8,
|
||||
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
|
||||
},
|
||||
'/api/rectification/v5/score': {
|
||||
'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03',
|
||||
'lat': 36.419, 'lon': 114.213, 'tz': 8,
|
||||
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
|
||||
},
|
||||
'/api/rectification/v5/diagnostics': {
|
||||
'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03',
|
||||
'lat': 36.419, 'lon': 114.213, 'tz': 8,
|
||||
'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}],
|
||||
},
|
||||
'/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}},
|
||||
'/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'},
|
||||
'/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Single source of truth for V5 birth-time rectification scoring and diagnostics."""
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
|
||||
from scripts.rectification.contracts import RectificationRequest
|
||||
from scripts.rectification.diagnostics_service import run_diagnostics
|
||||
from scripts.rectification.scoring_service import (
|
||||
ALGORITHM_VERSION,
|
||||
build_event_contribution_matrix,
|
||||
calculation_spec,
|
||||
score_from_matrix,
|
||||
sha256,
|
||||
)
|
||||
|
||||
|
||||
def candidate_features(request: RectificationRequest) -> dict[str, Any]:
|
||||
spec = calculation_spec(request)
|
||||
spec_hash = sha256(spec)
|
||||
return {
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"calculation_spec": spec,
|
||||
"calculation_spec_hash": spec_hash,
|
||||
"candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash),
|
||||
"can_confirm_exact_minute": False,
|
||||
}
|
||||
|
||||
|
||||
def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
built = build_event_contribution_matrix(request)
|
||||
rows = score_from_matrix(request, built)
|
||||
spec = calculation_spec(request)
|
||||
spec_hash = sha256(spec)
|
||||
diagnostics = run_diagnostics(request, rows, built)
|
||||
fingerprint = sha256(request)
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"calculation_spec": spec,
|
||||
"calculation_spec_hash": spec_hash,
|
||||
"candidate_scores": [{
|
||||
"time": row["time"],
|
||||
"score": row["score"],
|
||||
"supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0],
|
||||
"conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0],
|
||||
} for row in rows],
|
||||
"event_contribution_matrix": built["matrix"],
|
||||
"candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash, built.get("static_contexts")),
|
||||
"diagnostics": diagnostics,
|
||||
"robustness": {
|
||||
"neighbor_support_minutes": diagnostics["neighbor_support_minutes"],
|
||||
"leave_one_out_retention_rate": diagnostics["leave_one_event_out_retention_rate"],
|
||||
"leave_one_domain_out_retention_rate": diagnostics["leave_one_domain_out_retention_rate"],
|
||||
"date_sensitivity_retention_rate": diagnostics["date_sensitivity_retention_rate"],
|
||||
},
|
||||
"missing_layers": built["missing_layers"],
|
||||
"can_confirm_exact_minute": False,
|
||||
}
|
||||
|
||||
|
||||
def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
scored = score_candidates(request)
|
||||
return {
|
||||
"result_id": scored["result_id"],
|
||||
"algorithm_version": scored["algorithm_version"],
|
||||
"calculation_spec_hash": scored["calculation_spec_hash"],
|
||||
"diagnostics": scored["diagnostics"],
|
||||
"missing_layers": scored["missing_layers"],
|
||||
"can_confirm_exact_minute": False,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_candidate_static_contexts
|
||||
from scripts.rectification.contracts import RectificationRequest
|
||||
from scripts.rectification.scoring_service import ALGORITHM_VERSION, sha256
|
||||
|
||||
|
||||
def build_candidate_feature_snapshot(
|
||||
request: RectificationRequest,
|
||||
calculation_spec_hash: str,
|
||||
static_contexts: Sequence[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request)
|
||||
features = [context["feature"] for context in contexts]
|
||||
return {
|
||||
"calculation_spec_hash": calculation_spec_hash,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"candidate_count": len(features),
|
||||
"feature_hash": sha256(features),
|
||||
"features": features,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any, Literal, NotRequired, TypedDict, cast
|
||||
|
||||
DatePrecision = Literal["day", "month", "quarter", "year", "range"]
|
||||
|
||||
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"education": frozenset({"education_milestone"}),
|
||||
"relocation": frozenset({"relocation"}),
|
||||
"relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}),
|
||||
"career": frozenset({"career_change"}),
|
||||
"finance": frozenset({"finance_change"}),
|
||||
"health_pressure": frozenset({"self_health_event"}),
|
||||
}
|
||||
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
|
||||
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"})
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"})
|
||||
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
|
||||
|
||||
|
||||
class LifeEvent(TypedDict):
|
||||
id: str
|
||||
domain: str
|
||||
event_kind: str
|
||||
date_start: str
|
||||
date_end: str
|
||||
precision: DatePrecision
|
||||
summary: NotRequired[str]
|
||||
|
||||
|
||||
class RectificationRequest(TypedDict):
|
||||
birth_date: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
lat: float
|
||||
lon: float
|
||||
tz: float
|
||||
events: list[LifeEvent]
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
|
||||
|
||||
def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float:
|
||||
value = body.get(name)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise ValueError(f"{name} must be a finite number")
|
||||
result = float(value)
|
||||
if not minimum <= result <= maximum:
|
||||
raise ValueError(f"{name} must be between {minimum:g} and {maximum:g}")
|
||||
return result
|
||||
|
||||
|
||||
def _calendar_date(value: Any, label: str) -> date:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{label} must be a valid YYYY-MM-DD value")
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc
|
||||
|
||||
|
||||
def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest:
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("request body must be an object")
|
||||
unsupported = sorted(set(body) - _REQUEST_FIELDS)
|
||||
if unsupported:
|
||||
raise ValueError(f"unsupported rectification field: {unsupported[0]}")
|
||||
|
||||
birth_day = _calendar_date(body.get("birth_date"), "birth_date")
|
||||
start_time, end_time = body.get("start_time"), body.get("end_time")
|
||||
if not isinstance(start_time, str) or not _CLOCK.fullmatch(start_time):
|
||||
raise ValueError("start_time must be HH:MM")
|
||||
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
|
||||
raise ValueError("end_time must be HH:MM")
|
||||
if start_time > end_time:
|
||||
raise ValueError("start_time must not exceed end_time")
|
||||
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list) or not 1 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 1 and 100 items")
|
||||
upper_date = today or date.today()
|
||||
cleaned_events: list[LifeEvent] = []
|
||||
for index, raw_event in enumerate(events):
|
||||
if not isinstance(raw_event, dict):
|
||||
raise ValueError(f"events[{index}] must be an object")
|
||||
unsupported_event_fields = sorted(set(raw_event) - _EVENT_FIELDS)
|
||||
if unsupported_event_fields:
|
||||
raise ValueError(f"events[{index}] contains unsupported field: {unsupported_event_fields[0]}")
|
||||
try:
|
||||
event_id = str(uuid.UUID(str(raw_event.get("id") or "")))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"events[{index}].id must be a UUID") from exc
|
||||
domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind")
|
||||
if domain not in SCOREABLE_EVENT_KINDS:
|
||||
raise ValueError(f"events[{index}].domain is not scoreable")
|
||||
if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]:
|
||||
raise ValueError(f"events[{index}].event_kind does not match domain")
|
||||
precision = raw_event.get("precision")
|
||||
if precision not in DATE_PRECISIONS:
|
||||
raise ValueError(f"events[{index}].precision is invalid")
|
||||
start_day = _calendar_date(raw_event.get("date_start"), f"events[{index}].date_start")
|
||||
end_day = _calendar_date(raw_event.get("date_end"), f"events[{index}].date_end")
|
||||
if start_day > end_day:
|
||||
raise ValueError(f"events[{index}].date_start must not exceed date_end")
|
||||
if start_day < birth_day or end_day > upper_date:
|
||||
raise ValueError(f"events[{index}] dates must be between birth_date and today")
|
||||
summary = raw_event.get("summary", "")
|
||||
if not isinstance(summary, str) or len(summary) > 1_000:
|
||||
raise ValueError(f"events[{index}].summary must be a string up to 1000 characters")
|
||||
cleaned_events.append({
|
||||
"id": event_id,
|
||||
"domain": cast(str, domain),
|
||||
"event_kind": cast(str, event_kind),
|
||||
"date_start": start_day.isoformat(),
|
||||
"date_end": end_day.isoformat(),
|
||||
"precision": cast(DatePrecision, precision),
|
||||
"summary": summary.strip(),
|
||||
})
|
||||
|
||||
return {
|
||||
"birth_date": birth_day.isoformat(),
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"lat": _bounded_number(body, "lat", -90, 90),
|
||||
"lon": _bounded_number(body, "lon", -180, 180),
|
||||
"tz": _bounded_number(body, "tz", -14, 14),
|
||||
"events": cleaned_events,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from statistics import variance
|
||||
from typing import Any, Sequence
|
||||
|
||||
from scripts.active_rectification_events import CandidateScoreRow
|
||||
from scripts.rectification.contracts import RectificationRequest
|
||||
|
||||
|
||||
def _winner(rows: Sequence[CandidateScoreRow]) -> str | None:
|
||||
return max(rows, key=lambda row: row["score"])["time"] if rows else None
|
||||
|
||||
|
||||
def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = .97) -> list[str]:
|
||||
if not rows:
|
||||
return []
|
||||
peak = max(row["score"] for row in rows)
|
||||
floor = peak * relative_floor if peak >= 0 else peak / relative_floor
|
||||
selected = [row["time"] for row in rows if row["score"] >= floor]
|
||||
if not selected:
|
||||
return []
|
||||
groups: list[list[str]] = []
|
||||
for current in selected:
|
||||
minute = lambda value: int(value[:2]) * 60 + int(value[3:])
|
||||
if groups and minute(current) - minute(groups[-1][-1]) == 1:
|
||||
groups[-1].append(current)
|
||||
else:
|
||||
groups.append([current])
|
||||
return max(groups, key=lambda group: (max(next(row["score"] for row in rows if row["time"] == time) for time in group), len(group)))
|
||||
|
||||
|
||||
def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[CandidateScoreRow]:
|
||||
return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows]
|
||||
|
||||
|
||||
def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]:
|
||||
primary = set(_primary_cluster(rows))
|
||||
event_runs = []
|
||||
domain_runs = []
|
||||
event_domain = {event["id"]: event["domain"] for event in request["events"]}
|
||||
for event in request["events"]:
|
||||
winner = _winner(_subtract(rows, {event["id"]}))
|
||||
event_runs.append({"removed_event_id": event["id"], "winner": winner, "retained": winner in primary})
|
||||
by_domain: dict[str, set[str]] = defaultdict(set)
|
||||
for event_id, domain in event_domain.items():
|
||||
by_domain[domain].add(event_id)
|
||||
for domain, event_ids in by_domain.items():
|
||||
winner = _winner(_subtract(rows, event_ids))
|
||||
domain_runs.append({"removed_domain": domain, "winner": winner, "retained": winner in primary})
|
||||
top = sorted(rows, key=lambda row: row["score"], reverse=True)
|
||||
top_score = top[0]["score"] if top else 0
|
||||
secondary = next((row for row in top if row["time"] not in primary), None)
|
||||
margin = 0 if not secondary else max(0, (top_score - secondary["score"]) / max(abs(top_score), 1e-9) * 100)
|
||||
positive_total = sum(max(row["score"], 0) for row in rows)
|
||||
primary_mass = sum(max(row["score"], 0) for row in rows if row["time"] in primary)
|
||||
date_items = []
|
||||
for item in built["date_sensitivity"]:
|
||||
date_items.append({
|
||||
**{key: value for key, value in item.items() if key != "sample_winners"},
|
||||
"candidate_cluster_retention_rate": sum(winner in primary for winner in item["sample_winners"]) / len(item["sample_winners"]),
|
||||
})
|
||||
layers: dict[str, float] = defaultdict(float)
|
||||
for event_id, candidates in built["matrix"].items():
|
||||
for contribution in candidates.values():
|
||||
for layer in contribution["technique_layers"]:
|
||||
layers[layer] += abs(contribution["points"])
|
||||
clusters = [_primary_cluster(rows)]
|
||||
candidate_splits = []
|
||||
if secondary and clusters[0]:
|
||||
candidate_splits.append({
|
||||
"left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]},
|
||||
"right_cluster": {"start": secondary["time"], "end": secondary["time"]},
|
||||
"technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]],
|
||||
"event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0],
|
||||
})
|
||||
return {
|
||||
"primary_cluster_retention_rate": 1.0 if primary else 0.0,
|
||||
"leave_one_event_out_retention_rate": sum(item["retained"] for item in event_runs) / len(event_runs) if event_runs else 0.0,
|
||||
"leave_one_domain_out_retention_rate": sum(item["retained"] for item in domain_runs) / len(domain_runs) if domain_runs else 0.0,
|
||||
"date_sensitivity_retention_rate": sum(item["candidate_cluster_retention_rate"] for item in date_items) / len(date_items) if date_items else 0.0,
|
||||
"neighbor_support_minutes": len(primary),
|
||||
"primary_secondary_margin_percent": round(min(margin, 100), 4),
|
||||
"cluster_mass_ratio": primary_mass / positive_total if positive_total else 0.0,
|
||||
"unstable_event_ids": [item["removed_event_id"] for item in event_runs if not item["retained"]],
|
||||
"most_discriminating_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:12]],
|
||||
"event_date_sensitivity": date_items,
|
||||
"candidate_splits": candidate_splits,
|
||||
"leave_one_event_out": event_runs,
|
||||
"leave_one_domain_out": domain_runs,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
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.contracts import LifeEvent, RectificationRequest
|
||||
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
|
||||
|
||||
|
||||
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]:
|
||||
return {
|
||||
"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": event["domain"], "date": sampled_date,
|
||||
"precision": "day", "summary": event.get("summary", ""),
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
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)))
|
||||
|
||||
|
||||
def build_event_contribution_matrix(
|
||||
request: RectificationRequest,
|
||||
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
static_contexts = None if row_provider is not None else compute_candidate_static_contexts(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 request["events"]:
|
||||
samples = sample_event_dates(event)
|
||||
sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples]
|
||||
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": sorted({rule.split(":", 1)[0] 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,
|
||||
})
|
||||
return {
|
||||
"candidate_times": candidate_grid or [],
|
||||
"matrix": dict(matrix),
|
||||
"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]:
|
||||
rows: list[CandidateScoreRow] = []
|
||||
for candidate_time in built["candidate_times"]:
|
||||
evidence = []
|
||||
for event in request["events"]:
|
||||
contribution = built["matrix"][event["id"]][candidate_time]
|
||||
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]:
|
||||
return {
|
||||
"version": INPUT_CONTRACT_VERSION,
|
||||
"birthDate": request["birth_date"],
|
||||
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
|
||||
"latitude": request["lat"], "longitude": request["lon"], "timezoneOffsetHours": request["tz"],
|
||||
"ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1,
|
||||
}
|
||||
|
||||
|
||||
def sha256(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode()).hexdigest()
|
||||
Reference in New Issue
Block a user