# /// script # requires-python = ">=3.11" # dependencies = [] # /// # ─── How to run ─── # .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py """Candidate-model construction and opportunity partitioning for rectification.""" from __future__ import annotations import hashlib import json import math from collections import defaultdict from collections.abc import Callable, Mapping, Sequence from datetime import date, datetime, time, timedelta from typing import Final from uuid import NAMESPACE_URL, uuid5 from scripts.dynamic_rectification_copy import ( DIMENSION_CONTEXT, SUPPORTED_DIMENSIONS, visible_range_labels, ) from scripts.dynamic_rectification_fact_priority import ( EVENT_FACT_PRIORITY_VERSION, FACT_PRIORITY_VERSION, build_domain_fact_priorities, build_historical_event_priorities, ) ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2" OPPORTUNITY_MODEL_VERSION: Final = "birth-time-opportunity-model-v4" MIN_INFORMATION_GAIN: Final = 0.15 def canonical_hash(value: Mapping | Sequence) -> str: encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) return hashlib.sha256(encoded.encode("utf-8")).hexdigest() def candidate_times(birth_date: str, start_time: str, end_time: str) -> list[str]: day = date.fromisoformat(birth_date) start = datetime.combine(day, time.fromisoformat(start_time)) end = datetime.combine(day, time.fromisoformat(end_time)) if end < start: end += timedelta(days=1) count = int((end - start).total_seconds() // 60) + 1 if not 1 <= count <= 1_440: raise ValueError("candidate range must contain between 1 and 1440 minutes") return [(start + timedelta(minutes=offset)).strftime("%H:%M") for offset in range(count)] def experience_window_sets( birth_date: str, as_of_date: str, ) -> list[tuple[str, list[tuple[date, date]]]]: born = date.fromisoformat(birth_date) as_of = date.fromisoformat(as_of_date) try: first = born.replace(year=born.year + 12) except ValueError: first = born.replace(year=born.year + 12, day=28) if as_of < first: return [] day_count = (as_of - first).days + 1 counts = [1] if day_count == 1 else list(range(2, min(4, day_count) + 1)) return [ ( f"periods-{count}", [ ( first + timedelta(days=day_count * index // count), as_of if index == count - 1 else ( first + timedelta(days=day_count * (index + 1) // count - 1) ), ) for index in range(count) ], ) for count in counts ] def experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]: sets = experience_window_sets(birth_date, as_of_date) return sets[-1][1] if sets else [] def candidate_window_rows(request: dict) -> list[dict]: """Compute each candidate chart once and reuse it across every window.""" from scripts.active_rectification_event_engine import ( DOMAIN_CONFIG, _candidate_datetimes, _candidate_row, ) window_sets = experience_window_sets(request["birth_date"], request["as_of_date"]) if not window_sets: return [] events = [] event_windows: dict[str, tuple[str, str, date, date]] = {} for dimension in sorted(SUPPORTED_DIMENSIONS): for window_group, windows in window_sets: for window_start, window_end in windows: event_id = str(uuid5( NAMESPACE_URL, f"{ALGORITHM_VERSION}:{window_group}:{dimension}:{window_start}:{window_end}", )) midpoint = window_start + (window_end - window_start) / 2 events.append({ "id": event_id, "domain": dimension, "date": midpoint.isoformat(), "precision": "day", }) event_windows[event_id] = ( window_group, dimension, window_start, window_end, ) calculation_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": events, } candidates = _candidate_datetimes(calculation_request) rows = [_candidate_row(calculation_request, candidate) for candidate in candidates] fact_priorities = build_domain_fact_priorities(calculation_request) event_priorities = build_historical_event_priorities({ **calculation_request, "historical_events": request.get("events") or [], }) activations = { event_id: {row["time"]: 0.0 for row in rows} for event_id in event_windows } missing = {layer for row in rows for layer in row["missing_layers"]} for row in rows: for evidence in row["evidence"]: activations[evidence["event_id"]][row["time"]] = float(evidence["points"]) return [ { "window_group": window_group, "dimension_code": dimension, "window_start": window_start.isoformat(), "window_end": window_end.isoformat(), "activations": activations[event_id], "missing_layers": sorted(set(DOMAIN_CONFIG[dimension][0]) & missing), "fact_selection_priority": fact_priorities[dimension]["selection_priority"], "fact_priority_version": FACT_PRIORITY_VERSION, "event_fact_selection_priority": event_priorities[dimension]["selection_priority"], "event_fact_priority_version": EVENT_FACT_PRIORITY_VERSION, } for event_id, (window_group, dimension, window_start, window_end) in event_windows.items() ] def compute_candidate_model(request: dict, row_builder: Callable[[dict], list[dict]]) -> dict: return { "version": ALGORITHM_VERSION, "opportunity_model_version": OPPORTUNITY_MODEL_VERSION, "historical_event_fingerprint": historical_event_fingerprint(request), "birth_date": request["birth_date"], "as_of_date": request["as_of_date"], "range": {"start_time": request["start_time"], "end_time": request["end_time"]}, "location": { "lat": request["lat"], "lon": request["lon"], "tz": request["tz"], }, "candidate_times": candidate_times( request["birth_date"], request["start_time"], request["end_time"] ), "windows": row_builder(request), } def validate_candidate_model(model: dict, request: dict) -> dict: expected = { "version", "opportunity_model_version", "birth_date", "as_of_date", "range", "location", "candidate_times", "windows", "historical_event_fingerprint", } candidates = candidate_times(request["birth_date"], request["start_time"], request["end_time"]) try: valid_header = ( set(model) == expected and model["version"] == ALGORITHM_VERSION and model["opportunity_model_version"] == OPPORTUNITY_MODEL_VERSION and model["historical_event_fingerprint"] == historical_event_fingerprint(request) and model["birth_date"] == request["birth_date"] and model["as_of_date"] == request["as_of_date"] and model["range"] == { "start_time": request["start_time"], "end_time": request["end_time"] } and model["location"] == { "lat": request["lat"], "lon": request["lon"], "tz": request["tz"] } and model["candidate_times"] == candidates and isinstance(model["windows"], list) ) valid_windows = _validate_windows(model["windows"], request, candidates) except (KeyError, TypeError, ValueError): valid_header = valid_windows = False if not valid_header or not valid_windows: raise ValueError("candidate model does not match the submitted request") return model def _validate_windows(windows: list, request: dict, candidates: list[str]) -> bool: generated = experience_window_sets(request["birth_date"], request["as_of_date"]) minimum = generated[0][1][0][0] if generated else date.max maximum = date.fromisoformat(request["as_of_date"]) groups = {name for name, _windows in generated} keys = [ (row.get("window_group"), row.get("dimension_code"), row.get("window_start"), row.get("window_end")) for row in windows if isinstance(row, dict) ] return len(keys) == len(set(keys)) and all( isinstance(row, dict) and set(row) == { "window_group", "dimension_code", "window_start", "window_end", "activations", "missing_layers", "fact_selection_priority", "fact_priority_version", "event_fact_selection_priority", "event_fact_priority_version", } and row["window_group"] in groups and row["dimension_code"] in SUPPORTED_DIMENSIONS and minimum <= date.fromisoformat(row["window_start"]) <= date.fromisoformat(row["window_end"]) <= maximum and isinstance(row["activations"], dict) and set(row["activations"]) == set(candidates) and all( not isinstance(score, bool) and isinstance(score, int | float) and math.isfinite(score) and score >= 0 for score in row["activations"].values() ) and isinstance(row["missing_layers"], list) and all(isinstance(layer, str) and layer for layer in row["missing_layers"]) and not isinstance(row["fact_selection_priority"], bool) and isinstance(row["fact_selection_priority"], int | float) and math.isfinite(row["fact_selection_priority"]) and 0 <= row["fact_selection_priority"] <= 1 and row["fact_priority_version"] == FACT_PRIORITY_VERSION and not isinstance(row["event_fact_selection_priority"], bool) and isinstance(row["event_fact_selection_priority"], int | float) and math.isfinite(row["event_fact_selection_priority"]) and 0 <= row["event_fact_selection_priority"] <= 1 and row["event_fact_priority_version"] == EVENT_FACT_PRIORITY_VERSION for row in windows ) def opportunities(model: dict) -> list[dict]: grouped: dict[tuple[str, str], list[dict]] = defaultdict(list) for row in model["windows"]: if not row["missing_layers"]: grouped[(row["dimension_code"], row["window_group"])].append(row) variants: dict[str, list[dict]] = defaultdict(list) for (dimension, window_group), windows in sorted(grouped.items()): opportunity = _dimension_opportunity( dimension, window_group, windows, model["candidate_times"], ) if opportunity is not None: variants[dimension].append(opportunity) selected = [ sorted(items, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))[0] for items in variants.values() ] result = sorted(selected, key=lambda item: ( -item["_event_fact_selection_priority"], -item["_fact_selection_priority"], -item["estimated_information_gain"], item["opportunity_id"], )) return [ { key: value for key, value in item.items() if key not in {"_fact_selection_priority", "_event_fact_selection_priority"} } for item in result ] def _dimension_opportunity( dimension: str, window_group: str | list[dict], windows: list[dict] | list[str], candidates: list[str] | None = None, ) -> dict | None: # Preserve the original three-argument helper contract for frozen fixtures; # production calls always supply an explicit period-window group. legacy_contract = candidates is None if legacy_contract: candidates = list(windows) windows = list(window_group) resolved_window_group: str | None = None else: resolved_window_group = str(window_group) neutral_context = DIMENSION_CONTEXT[dimension] memberships: dict[int, list[str]] = defaultdict(list) for candidate in candidates: winner = max( range(len(windows)), key=lambda index: (windows[index]["activations"][candidate], -index), ) memberships[winner].append(candidate) populated = [(windows[index], members) for index, members in sorted(memberships.items())] if not 2 <= len(populated) <= 4: return None probabilities = [len(members) / len(candidates) for _, members in populated] gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated)) if gain < MIN_INFORMATION_GAIN: return None basis = [ { "version": ALGORITHM_VERSION, "dimension": dimension, "window_start": window["window_start"], "window_end": window["window_end"], "members": sorted(members), } for window, members in populated ] if resolved_window_group is not None: basis = [{**item, "window_group": resolved_window_group} for item in basis] labels = visible_range_labels([ {"window_start": item["window_start"], "window_end": item["window_end"]} for item in basis ]) partitions = [ { "partition_id": canonical_hash(item), "descriptor": f"{item['window_start']}--{item['window_end']}", "fallback_label": label, "candidate_scores": { candidate: 1.0 if candidate in item["members"] else 0.0 for candidate in candidates }, } for item, label in zip(basis, labels, strict=True) ] fingerprint = canonical_hash({"version": ALGORITHM_VERSION, "partitions": basis}) return { "opportunity_id": canonical_hash({ "version": ALGORITHM_VERSION, "dimension": dimension, "partitions": basis }), "dimension_code": dimension, "neutral_context": neutral_context, "estimated_information_gain": round(gain, 6), "candidate_partition_fingerprint": fingerprint, "fallback_prompt": f"哪一个时间段更接近{neutral_context}?", "partitions": partitions, "_fact_selection_priority": max( (float(window.get("fact_selection_priority", 0.0)) for window in windows), default=0.0, ), "_event_fact_selection_priority": max( (float(window.get("event_fact_selection_priority", 0.0)) for window in windows), default=0.0, ), } def historical_event_fingerprint(request: dict) -> str: """Bind reusable private models to the exact normalized historical events.""" events = sorted( ( { "id": item["id"], "domain": item["domain"], "date": item["date"], "precision": item["precision"], } for item in (request.get("events") or []) ), key=lambda item: (item["id"], item["domain"], item["date"], item["precision"]), ) return canonical_hash(events)