feat: add agent guided birth time rectification
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
# ─── How to run ───
|
||||
# .venv/bin/python -m pytest -q tests/test_active_rectification_events.py
|
||||
"""Deterministically adjudicate dated events against birth-time candidates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Final, Literal, TypedDict, assert_never
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
EventPrecision = Literal["year", "month", "day"]
|
||||
EventDomain = Literal[
|
||||
"education",
|
||||
"relocation",
|
||||
"relationship",
|
||||
"career",
|
||||
"health_pressure",
|
||||
]
|
||||
Confidence = Literal["low", "medium", "high"]
|
||||
|
||||
ALGORITHM_VERSION: Final = "birth-time-event-scoring-v1"
|
||||
PRECISION_WEIGHTS: Final[dict[EventPrecision, float]] = {
|
||||
"day": 1.0,
|
||||
"month": 0.8,
|
||||
"year": 0.5,
|
||||
}
|
||||
|
||||
|
||||
class CandidateEvidence(TypedDict):
|
||||
event_id: str
|
||||
domain: str
|
||||
candidate_time: str
|
||||
rule_ids: list[str]
|
||||
points: float
|
||||
|
||||
|
||||
class LifeEvent(TypedDict):
|
||||
id: str
|
||||
domain: EventDomain
|
||||
date: str
|
||||
precision: EventPrecision
|
||||
|
||||
|
||||
class RectificationEventRequest(TypedDict):
|
||||
birth_date: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
lat: float
|
||||
lon: float
|
||||
tz: float
|
||||
events: list[LifeEvent]
|
||||
|
||||
|
||||
class CandidateScoreRow(TypedDict):
|
||||
time: str
|
||||
score: float
|
||||
evidence: list[CandidateEvidence]
|
||||
missing_layers: list[str]
|
||||
|
||||
|
||||
class WinningSegment(TypedDict):
|
||||
start_time: str
|
||||
end_time: str
|
||||
representative_time: str
|
||||
width_minutes: int
|
||||
|
||||
|
||||
class CandidateResult(TypedDict):
|
||||
result_id: str
|
||||
confidence: Confidence
|
||||
can_apply: bool
|
||||
winning_segment: WinningSegment | None
|
||||
event_count: int
|
||||
domain_count: int
|
||||
top_score: float
|
||||
second_score: float
|
||||
margin_percent: float
|
||||
reasons: list[str]
|
||||
evidence: list[CandidateEvidence]
|
||||
algorithm_version: str
|
||||
|
||||
|
||||
def precision_weight(precision: EventPrecision) -> float:
|
||||
"""Return the fixed evidence weight for a declared date precision."""
|
||||
match precision:
|
||||
case "day" | "month" | "year":
|
||||
return PRECISION_WEIGHTS[precision]
|
||||
case unreachable:
|
||||
assert_never(unreachable)
|
||||
|
||||
|
||||
def _minute_value(value: str) -> int:
|
||||
hour, minute = value.split(":", maxsplit=1)
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _is_next_minute(previous: str, current: str) -> bool:
|
||||
return (_minute_value(current) - _minute_value(previous)) % (24 * 60) == 1
|
||||
|
||||
|
||||
def _top_segments(rows: Sequence[CandidateScoreRow], top_score: float) -> list[list[CandidateScoreRow]]:
|
||||
segments: list[list[CandidateScoreRow]] = []
|
||||
for row in rows:
|
||||
if row["score"] != top_score:
|
||||
continue
|
||||
if segments and _is_next_minute(segments[-1][-1]["time"], row["time"]):
|
||||
segments[-1].append(row)
|
||||
else:
|
||||
segments.append([row])
|
||||
return segments
|
||||
|
||||
|
||||
def _winning_segment(rows: Sequence[CandidateScoreRow]) -> WinningSegment:
|
||||
width = len(rows)
|
||||
representative = rows[(width - 1) // 2]["time"]
|
||||
return {
|
||||
"start_time": rows[0]["time"],
|
||||
"end_time": rows[-1]["time"],
|
||||
"representative_time": representative,
|
||||
"width_minutes": width,
|
||||
}
|
||||
|
||||
|
||||
def adjudicate_candidate_rows(
|
||||
rows: Sequence[CandidateScoreRow],
|
||||
*,
|
||||
event_count: int,
|
||||
domain_count: int,
|
||||
request_fingerprint: str,
|
||||
) -> CandidateResult:
|
||||
"""Rank precomputed candidate rows and apply conservative confidence gates."""
|
||||
if not rows:
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
|
||||
"confidence": "low",
|
||||
"can_apply": False,
|
||||
"winning_segment": None,
|
||||
"event_count": event_count,
|
||||
"domain_count": domain_count,
|
||||
"top_score": 0.0,
|
||||
"second_score": 0.0,
|
||||
"margin_percent": 0.0,
|
||||
"reasons": ["no_candidate_rows"],
|
||||
"evidence": [],
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
|
||||
ranked_scores = sorted({row["score"] for row in rows}, reverse=True)
|
||||
top_score = ranked_scores[0]
|
||||
second_score = ranked_scores[1] if len(ranked_scores) > 1 else top_score
|
||||
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
|
||||
segments = _top_segments(rows, top_score)
|
||||
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
|
||||
reasons: list[str] = []
|
||||
|
||||
if len(segments) != 1:
|
||||
reasons.append("tied_leader")
|
||||
if event_count < 3:
|
||||
reasons.append("insufficient_events")
|
||||
if domain_count < 2:
|
||||
reasons.append("insufficient_domains")
|
||||
if missing_layers:
|
||||
reasons.append("missing_mandatory_layers")
|
||||
|
||||
segment = _winning_segment(segments[0]) if len(segments) == 1 else None
|
||||
if segment and segment["width_minutes"] > 15:
|
||||
reasons.append("winning_interval_too_wide")
|
||||
if margin < 10:
|
||||
reasons.append("lead_margin_below_medium_threshold")
|
||||
|
||||
if reasons:
|
||||
confidence: Confidence = "low"
|
||||
elif (
|
||||
event_count >= 4
|
||||
and domain_count >= 3
|
||||
and segment is not None
|
||||
and segment["width_minutes"] <= 5
|
||||
and margin >= 20
|
||||
):
|
||||
confidence = "high"
|
||||
else:
|
||||
confidence = "medium"
|
||||
|
||||
top_rows = segments[0] if len(segments) == 1 else []
|
||||
representative_row = top_rows[(len(top_rows) - 1) // 2] if top_rows else None
|
||||
evidence = list(representative_row["evidence"]) if representative_row else []
|
||||
return {
|
||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
|
||||
"confidence": confidence,
|
||||
"can_apply": confidence == "high",
|
||||
"winning_segment": segment,
|
||||
"event_count": event_count,
|
||||
"domain_count": domain_count,
|
||||
"top_score": top_score,
|
||||
"second_score": second_score,
|
||||
"margin_percent": margin,
|
||||
"reasons": reasons,
|
||||
"evidence": evidence,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def score_life_events(request: RectificationEventRequest) -> CandidateResult:
|
||||
"""Compute actual candidate rows, then apply the versioned confidence gates."""
|
||||
from scripts.active_rectification_event_engine import compute_event_candidate_result
|
||||
|
||||
return compute_event_candidate_result(request)
|
||||
Reference in New Issue
Block a user