Files
Jyotisha/scripts/rectification/contracts.py
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
2026-09-21 02:55:00 +08:00

414 lines
19 KiB
Python

from __future__ import annotations
import math
import re
import uuid
from datetime import date
from typing import Any, Literal, NotRequired, TypedDict, cast
from scripts.ayanamsa_utils import UnsupportedAyanamsaError, normalize_ayanamsa_name
DatePrecision = Literal["day", "month", "quarter", "year", "range"]
EVENT_CONTRACT_VERSION = "rectification-event-contract-v2"
EVENT_KINDS: dict[str, frozenset[str]] = {
"education": frozenset({
"education_start", "education_completion", "education_interruption", "education_change",
"education_milestone", # v1 compatibility
}),
"career": frozenset({
"career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start",
}),
"relationship": frozenset({
"relationship_start", "relationship_commitment", "relationship_separation", "relationship_end",
"relationship_change", # v1 compatibility
}),
"relocation": frozenset({"relocation", "foreign_move", "return", "home_change"}),
"finance": frozenset({"finance_gain", "finance_loss", "income_change", "asset_change", "finance_change"}),
"health": frozenset({"self_health_event", "pressure_period"}),
"health_pressure": frozenset({"self_health_event", "pressure_period"}), # v1 domain compatibility
"family": frozenset({"family_event"}),
"appearance": frozenset({"appearance_note"}),
"marks": frozenset({"birthmark_or_scar"}),
"occupation": frozenset({"occupation_note"}),
"horary": frozenset({"horary_query"}),
"other": frozenset({"other"}),
}
BACKGROUND_EVENT_KINDS = frozenset({"other", "horary_query"})
AUXILIARY_EVENT_KINDS = frozenset({"appearance_note", "birthmark_or_scar", "occupation_note"})
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
domain: frozenset(kind for kind in kinds if kind not in BACKGROUND_EVENT_KINDS)
for domain, kinds in EVENT_KINDS.items()
if any(kind not in BACKGROUND_EVENT_KINDS for kind in kinds)
}
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
_BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"})
_LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"})
_REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"})
_EVENT_PROVENANCE_FIELDS = frozenset({
"date_source", "date_reliability", "date_corroboration", "date_conflict_status",
"source_turn_id", "subject",
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks",
"refresh_probes", "candidate_intervals",
}) | _REQUEST_PROVENANCE_FIELDS
ASKED_PROBE_KEY_MAX_LENGTH = 200
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
_MINUTES_PER_DAY = 24 * 60
def _clock_minutes(value: str) -> int:
hour, minute = value.split(":", 1)
return int(hour) * 60 + int(minute)
def _clock_in_window(clock: str, start_time: str, end_time: str) -> bool:
current = _clock_minutes(clock)
start = _clock_minutes(start_time)
end = _clock_minutes(end_time)
if start <= end:
return start <= current <= end
return current >= start or current <= end
def _inclusive_minutes(start_time: str, end_time: str) -> list[int]:
start = _clock_minutes(start_time)
end = _clock_minutes(end_time)
span = end - start if end >= start else _MINUTES_PER_DAY - start + end
return [(start + offset) % _MINUTES_PER_DAY for offset in range(span + 1)]
def _block_contained(block_start: str, block_end: str, window_start: str, window_end: str) -> bool:
window = set(_inclusive_minutes(window_start, window_end))
return all(minute in window for minute in _inclusive_minutes(block_start, block_end))
def _ranges_overlap(left_start: str, left_end: str, right_start: str, right_end: str) -> bool:
left = set(_inclusive_minutes(left_start, left_end))
right = set(_inclusive_minutes(right_start, right_end))
shared = left & right
if not shared:
return False
endpoints = {
_clock_minutes(left_start),
_clock_minutes(left_end),
_clock_minutes(right_start),
_clock_minutes(right_end),
}
interior = shared - endpoints
if interior:
return True
# Adjacent blocks may share a single endpoint minute; more than that is overlap.
return len(shared) > 1
def _normalize_blocks(body: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]:
raw_blocks = body.get("blocks")
if not isinstance(raw_blocks, list) or not 1 <= len(raw_blocks) <= 5:
raise ValueError("blocks must contain between 1 and 5 items")
cleaned: list[dict[str, str]] = []
for index, raw in enumerate(raw_blocks):
if not isinstance(raw, dict):
raise ValueError(f"blocks[{index}] must be an object")
label = raw.get("label") or raw.get("period")
block_start, block_end = raw.get("start_time"), raw.get("end_time")
if not isinstance(label, str) or not label.strip() or len(label.strip()) > 40:
raise ValueError(f"blocks[{index}].label must be a non-empty string up to 40 characters")
if not isinstance(block_start, str) or not _CLOCK.fullmatch(block_start):
raise ValueError(f"blocks[{index}].start_time must be HH:MM")
if not isinstance(block_end, str) or not _CLOCK.fullmatch(block_end):
raise ValueError(f"blocks[{index}].end_time must be HH:MM")
if block_start == block_end:
raise ValueError(f"blocks[{index}] start_time and end_time must differ")
if not _block_contained(block_start, block_end, start_time, end_time):
raise ValueError(f"blocks[{index}] must fall inside the request window")
for previous in cleaned:
if _ranges_overlap(previous["start_time"], previous["end_time"], block_start, block_end):
raise ValueError("blocks must not overlap")
cleaned.append({
"label": label.strip(),
"period": label.strip(),
"start_time": block_start,
"end_time": block_end,
})
return cleaned
class LifeEvent(TypedDict):
id: str
domain: str
event_kind: str
date_start: str
date_end: str
precision: DatePrecision
summary: NotRequired[str]
date_source: NotRequired[str | None]
date_reliability: NotRequired[str | None]
date_corroboration: NotRequired[str | None]
date_conflict_status: NotRequired[str | None]
source_turn_id: NotRequired[str | None]
subject: NotRequired[Literal["self", "family", "other"]]
class RectificationRequest(TypedDict):
birth_date: str
start_time: str
end_time: str
lat: float
lon: float
tz: float
events: list[LifeEvent]
ayanamsa: NotRequired[str]
node_mode: NotRequired[str]
birth_time_source: NotRequired[str | None]
timezone_id: NotRequired[str | None]
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
declined_domains: NotRequired[list[str]]
dropped_asked_probe_keys: NotRequired[int]
column_times: NotRequired[list[str]]
refresh_probes: NotRequired[bool]
minute_step: NotRequired[int]
blocks: NotRequired[list[dict[str, Any]]]
candidate_intervals: NotRequired[list[dict[str, str]]]
JsonObject = dict[str, Any]
def is_scoreable_event(event: LifeEvent) -> bool:
return event["event_kind"] not in BACKGROUND_EVENT_KINDS
def is_primary_scoreable_event(event: LifeEvent) -> bool:
"""Dated events that may move the candidate ranking as a primary formula."""
return is_scoreable_event(event) and event["event_kind"] not in AUXILIARY_EVENT_KINDS
def subject_for_event_domain(domain: str, subject: str | None = None) -> Literal["self", "family", "other"]:
if domain == "family":
return "family"
if domain == "other":
return "other"
if subject in {"self", "family", "other"}:
return cast(Literal["self", "family", "other"], subject)
return "self"
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 _copy_nullable_text(
source: dict[str, Any], target: dict[str, Any], name: str, label: str, maximum: int,
allowed: frozenset[str] | None = None,
) -> None:
if name not in source:
return
value = source[name]
if value is None:
target[name] = None
return
if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
raise ValueError(f"{label} must be null or a non-empty string up to {maximum} characters")
cleaned = value.strip()
if allowed is not None and cleaned not in allowed:
raise ValueError(f"{label} is invalid")
target[name] = cleaned
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")
events = body.get("events")
if not isinstance(events, list) or not 0 <= len(events) <= 100:
raise ValueError("events must contain between 0 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 EVENT_KINDS:
raise ValueError(f"events[{index}].domain is invalid")
if event_kind not in 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")
raw_subject = raw_event.get("subject")
if raw_subject is not None and raw_subject not in {"self", "family", "other"}:
raise ValueError(f"events[{index}].subject is invalid")
subject = subject_for_event_domain(cast(str, domain), None if raw_subject is None else str(raw_subject))
if event_kind not in BACKGROUND_EVENT_KINDS and domain != "family" and subject != "self":
raise ValueError(f"events[{index}].subject must be self for scoreable events")
cleaned_event: dict[str, Any] = {
"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(),
"subject": cast(Literal["self", "family", "other"], subject),
}
_copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120)
_copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000)
_copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120)
if "source_turn_id" in raw_event:
source_turn_id = raw_event.get("source_turn_id")
if source_turn_id is None:
cleaned_event["source_turn_id"] = None
else:
try:
cleaned_event["source_turn_id"] = str(uuid.UUID(str(source_turn_id)))
except (ValueError, AttributeError) as exc:
raise ValueError(f"events[{index}].source_turn_id must be null or a UUID") from exc
cleaned_events.append(cast(LifeEvent, cleaned_event))
cleaned_request: dict[str, Any] = {
"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,
}
if "ayanamsa" in body:
try:
cleaned_request["ayanamsa"] = normalize_ayanamsa_name(body.get("ayanamsa"))
except UnsupportedAyanamsaError as exc:
raise ValueError(str(exc)) from exc
if "node_mode" in body:
node_mode = body.get("node_mode")
if not isinstance(node_mode, str) or node_mode.strip().lower() not in {"mean", "true"}:
raise ValueError("node_mode must be mean or true")
cleaned_request["node_mode"] = node_mode.strip().lower()
_copy_nullable_text(body, cleaned_request, "birth_time_source", "birth_time_source", 120, _BIRTH_TIME_SOURCES)
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
_copy_nullable_text(body, cleaned_request, "local_time_status", "local_time_status", 120, _LOCAL_TIME_STATUSES)
if "asked_probe_keys" in body:
asked = body.get("asked_probe_keys")
if not isinstance(asked, list) or len(asked) > 200:
raise ValueError("asked_probe_keys must contain between 0 and 200 strings")
cleaned_keys: list[str] = []
seen: set[str] = set()
dropped = 0
for index, item in enumerate(asked):
if not isinstance(item, str) or not item.strip():
raise ValueError(
f"asked_probe_keys[{index}] must be a non-empty string up to {ASKED_PROBE_KEY_MAX_LENGTH} characters"
)
key = item.strip()
if len(key) > ASKED_PROBE_KEY_MAX_LENGTH:
dropped += 1
continue
if key in seen:
continue
seen.add(key)
cleaned_keys.append(key)
cleaned_request["asked_probe_keys"] = cleaned_keys
if dropped:
cleaned_request["dropped_asked_probe_keys"] = dropped
if "declined_domains" in body:
raw_domains = body.get("declined_domains")
if not isinstance(raw_domains, list) or len(raw_domains) > 20:
raise ValueError("declined_domains must contain between 0 and 20 strings")
cleaned_domains: list[str] = []
seen_domains: set[str] = set()
allowed = {
"education", "career", "relocation", "relationship",
"family", "finance", "health_pressure",
}
for index, item in enumerate(raw_domains):
if not isinstance(item, str) or not item.strip():
raise ValueError(f"declined_domains[{index}] must be a non-empty domain name")
domain = item.strip()
if domain not in allowed:
raise ValueError(f"declined_domains[{index}] is not a collect domain")
if domain in seen_domains:
continue
seen_domains.add(domain)
cleaned_domains.append(domain)
cleaned_request["declined_domains"] = cleaned_domains
if "column_times" in body:
raw_times = body.get("column_times")
if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64:
raise ValueError("column_times must contain between 1 and 64 HH:MM values")
cleaned_times: list[str] = []
seen_times: set[str] = set()
for index, item in enumerate(raw_times):
if not isinstance(item, str) or not _CLOCK.fullmatch(item):
raise ValueError(f"column_times[{index}] must be HH:MM")
if item in seen_times:
continue
seen_times.add(item)
cleaned_times.append(item)
cleaned_request["column_times"] = cleaned_times
if "refresh_probes" in body:
if body.get("refresh_probes") is not True and body.get("refresh_probes") is not False:
raise ValueError("refresh_probes must be a boolean")
if body.get("refresh_probes") is True:
cleaned_request["refresh_probes"] = True
if "minute_step" in body:
minute_step = body.get("minute_step")
if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15:
raise ValueError("minute_step must be an integer from 1 to 15")
if minute_step != 1:
cleaned_request["minute_step"] = minute_step
if "blocks" in body:
cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time)
if "candidate_intervals" in body:
from scripts.rectification.candidate_window import candidate_intervals
cleaned_request["candidate_intervals"] = candidate_intervals(body)
return cast(RectificationRequest, cleaned_request)