feat: rebuild birth time rectification workflow

This commit is contained in:
Jesse_Chen
2026-07-27 09:57:14 +08:00
parent 380aba4628
commit 2ca245d643
66 changed files with 5826 additions and 699 deletions
+221
View File
@@ -0,0 +1,221 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Range-preserving event scoring for the asynchronous rectification V4 worker."""
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 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"]
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,
}
if __name__ == "__main__":
raise SystemExit("Import score_life_events_v4 from the worker or API server.")
+102
View File
@@ -1629,6 +1629,7 @@ API_COMMAND_MAP = {
'active-rectification-questions': '/api/active_rectification_questions',
'active-rectification-score': '/api/active_rectification_score',
'active-rectification-events': '/api/active_rectification_events',
'active-rectification-events-v4': '/api/active_rectification_events_v4',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
@@ -1663,6 +1664,7 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/active_rectification_questions',
'/api/active_rectification_score',
'/api/active_rectification_events',
'/api/active_rectification_events_v4',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
@@ -2109,6 +2111,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
elif path == '/api/active_rectification_events':
result = self._compute_active_rectification_events(body)
self._json(result)
elif path == '/api/active_rectification_events_v4':
result = self._compute_active_rectification_events_v4(body)
self._json(result)
elif path == '/api/dynamic_rectification_opportunities':
result = self._compute_dynamic_rectification_opportunities(body)
self._json(result)
@@ -7522,6 +7527,101 @@ 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')
try:
birth_day = datetime.strptime(birth_date, '%Y-%m-%d').date()
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')
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'},
}
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()
allowed_fields = {
@@ -8384,6 +8484,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/active_rectification_questions': self._compute_active_rectification_questions,
'/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/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
@@ -8509,6 +8610,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/prashna': 'Compute Prashna chart and answer evidence',
'/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/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',