diff --git a/README.md b/README.md index 4d6015d..4ac4b13 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ it never submits roster changes to Sleeper. - ESPN and official NBA injury reports provide current schedules, results, and availability. - Historical SportsDataverse data supports projection experiments and backtesting. - Local commands persist state in SQLite; the deployed Cloudflare Worker uses D1. -- Notification actions record acknowledgements in Sleeper Manager only. The manager still - makes every roster or Lock-In change in Sleeper. +- The five-minute scheduled wake captures pre-tipoff starters, watches ESPN finals, + and recommends Lock or Pass until acknowledgement or deadline. See [LOCK_IN_MODE.md](LOCK_IN_MODE.md) for the league rules enforced by the decision engine. @@ -80,10 +80,12 @@ Local commands persist in SQLite; only `STATE_BACKEND=sqlite` is supported. The Worker uses D1 and does not read this variable. Manager policy TOML is the local source of manager intent: decision preset and confidence, -notification quiet hours, protected players, and mapping overrides. `sync-cloudflare-runtime-data` +notification quiet hours, and mapping overrides. `sync-cloudflare-runtime-data` translates that intent into the deployed runtime policy envelope in D1, including a content hash stamped on live plans as `manager_policy_version`. Version-one policy files reject removed -keys such as `use_matchup_context`, `protect_elite_upside`, `daily_summary`, and `injury_alerts`. +keys such as `use_matchup_context`, `protect_elite_upside`, `daily_summary`, `injury_alerts`, +and `protected_sleeper_ids`. Quiet-hour fields are accepted and stored, but quiet-hour +suppression is deferred. Live Lock-In advice uses the resolved minimum-confidence threshold. Changing `.local/policy.toml` does not affect the Worker until the next runtime-data sync. ## Commands diff --git a/docs/cloudflare-runtime.md b/docs/cloudflare-runtime.md index 35607e2..2d47d50 100644 --- a/docs/cloudflare-runtime.md +++ b/docs/cloudflare-runtime.md @@ -1,9 +1,12 @@ # Cloudflare runtime -The Worker uses one five-minute Cron Trigger. Each wake claims due daily, pre-tipoff, or -delivery-retry work and runs the weekly lineup planner at most once. Lineup notifications -include Open Sleeper only. The `test-notification` command remains a local diagnostic -and is never invoked from `scheduled()`. +The Worker uses one five-minute Cron Trigger. Each wake claims due daily, pre-tipoff, +postgame, or delivery-retry work. Daily and pre-tipoff wakes run the weekly lineup +planner at most once and refresh live Lock-In opportunity rows. Postgame wakes fetch +ESPN game summaries directly, stabilize finals, and may send Lock, Pass, or +unavailable-warning notifications. Lineup notifications include Open Sleeper only. +Lock-In notifications include Locked, Passed, and Open Sleeper. The `test-notification` +command remains a local diagnostic and is never invoked from `scheduled()`. Projection history and the active runtime policy live in D1. They are not bundled into the Worker deploy. @@ -25,9 +28,18 @@ activates runtime policy last. The sync also translates the local manager policy TOML (`.local/policy.toml` by default) into the runtime policy envelope. That translation copies decision preset and confidence, quiet-hour -settings, protected players, mapping overrides, and a content hash used as -`manager_policy_version` on live plans. Operational fields such as freshness windows and the -pinned projection-history version remain runtime defaults unless changed in code. +settings, mapping overrides, and a content hash used as `manager_policy_version` on live plans. +`protected_sleeper_ids` is rejected in new TOML and accepted only as an empty legacy runtime +value. Quiet-hour fields are stored for later use; quiet-hour suppression is not enforced. +Live Lock-In evaluation consumes the resolved minimum-confidence threshold. Operational fields +such as freshness windows and the pinned projection-history version remain runtime defaults +unless changed in code. + +Postgame watches start two hours after tipoff and retry every five minutes until the +opportunity is acknowledged, expired, or otherwise closed. Lock and Pass notifications +include Locked, Passed, and Open Sleeper actions. Unavailable warnings include Open Sleeper +only. Acknowledgement updates recommendation and opportunity state together; a later ESPN +stat correction refreshes the locked score without sending another action. `--apply` requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in the environment. Do not print those values; Wrangler secrets stay in the Worker environment: diff --git a/infra/cloudflare/migrations/0004_live_lock_in.sql b/infra/cloudflare/migrations/0004_live_lock_in.sql new file mode 100644 index 0000000..2033dbe --- /dev/null +++ b/infra/cloudflare/migrations/0004_live_lock_in.sql @@ -0,0 +1,89 @@ +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE scheduled_work_v4 ( + work_id TEXT PRIMARY KEY, + dedupe_key TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL CHECK ( + kind IN ('daily', 'pre_tipoff', 'delivery_retry', 'postgame') + ), + due_at TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('pending', 'running', 'retry', 'completed', 'canceled') + ), + local_day TEXT, + game_id TEXT, + recommendation_id TEXT, + deadline TEXT, + lease_expires_at TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0, + correlation_id TEXT, + failure_category TEXT, + terminal_summary_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (recommendation_id) REFERENCES recommendations(recommendation_id) +); + +INSERT INTO scheduled_work_v4 ( + work_id, dedupe_key, kind, due_at, status, local_day, game_id, + recommendation_id, deadline, lease_expires_at, attempt_count, + correlation_id, failure_category, terminal_summary_json, created_at, updated_at +) +SELECT + work_id, dedupe_key, kind, due_at, status, local_day, game_id, + recommendation_id, deadline, lease_expires_at, attempt_count, + correlation_id, failure_category, terminal_summary_json, created_at, updated_at +FROM scheduled_work; + +DROP TABLE scheduled_work; +ALTER TABLE scheduled_work_v4 RENAME TO scheduled_work; + +CREATE INDEX scheduled_work_due_idx +ON scheduled_work (status, due_at, lease_expires_at); + +CREATE TABLE lock_in_opportunities ( + league_id TEXT NOT NULL, + fantasy_week INTEGER NOT NULL, + roster_id INTEGER NOT NULL, + player_id TEXT NOT NULL, + game_id TEXT NOT NULL, + provider_player_id TEXT NOT NULL, + scheduled_start TEXT NOT NULL, + action_deadline TEXT NOT NULL, + fantasy_week_end TEXT NOT NULL, + status TEXT NOT NULL, + next_check_at TEXT NOT NULL, + slot_index INTEGER, + slot_position TEXT, + eligible_positions_json TEXT NOT NULL, + rostered_at_tipoff INTEGER, + roster_evidence_at TEXT, + league_configuration_fingerprint TEXT, + current_observed_score REAL, + current_observation_fingerprint TEXT, + consecutive_direct_poll_count INTEGER NOT NULL DEFAULT 0, + current_poll_id TEXT, + previous_observed_at TEXT, + current_observed_at TEXT, + stable_score REAL, + stable_fingerprint TEXT, + stabilized_at TEXT, + score_revision INTEGER NOT NULL DEFAULT 0, + current_recommendation_id TEXT, + current_recommendation_kind TEXT, + acknowledged_action TEXT, + acknowledged_at TEXT, + latest_evaluation_hash TEXT, + trace_json TEXT NOT NULL DEFAULT '{}', + row_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (league_id, fantasy_week, roster_id, player_id, game_id), + FOREIGN KEY (current_recommendation_id) REFERENCES recommendations(recommendation_id) +); + +CREATE INDEX lock_in_opportunities_due_idx +ON lock_in_opportunities (status, next_check_at, action_deadline); + +CREATE INDEX lock_in_opportunities_ack_idx +ON lock_in_opportunities (league_id, fantasy_week, status, roster_id); diff --git a/manager-policy.example.toml b/manager-policy.example.toml index 190e39d..cc4989d 100644 --- a/manager-policy.example.toml +++ b/manager-policy.example.toml @@ -3,10 +3,10 @@ preset = "balanced" minimum_confidence = 0.70 [notifications] +# Accepted and stored; quiet-hour suppression is not enforced yet. quiet_hours_start = "23:00" quiet_hours_end = "07:00" urgent_actions_override_quiet_hours = true [players] -protected_sleeper_ids = [] mapping_overrides = {} diff --git a/src/sleeper_manager/cli.py b/src/sleeper_manager/cli.py index d530bbd..f262efc 100644 --- a/src/sleeper_manager/cli.py +++ b/src/sleeper_manager/cli.py @@ -32,6 +32,7 @@ ) from sleeper_manager.cloudflare.dispatcher import dispatch_due_work from sleeper_manager.cloudflare.planning import collect_cloudflare_planning_inputs +from sleeper_manager.cloudflare.providers import CloudflareESPNProvider from sleeper_manager.cloudflare.runtime_sync import ( RemoteD1, compact_history_from_workspace, @@ -58,6 +59,7 @@ NotificationLoop, default_placeholder_request, ) +from sleeper_manager.workflows.postgame_lock_in import LOCK_IN_ACKNOWLEDGEMENT_KINDS def build_parser() -> argparse.ArgumentParser: @@ -302,6 +304,7 @@ async def _test_notification(settings: Settings) -> int: repository, dispatcher, acknowledgement_base_url=settings.acknowledgement_base_url, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, ).run( default_placeholder_request( league_id=settings.sleeper_league_id or "local-diagnostic", @@ -444,6 +447,7 @@ async def _run_scheduled(settings: Settings) -> int: repository, build_notification_dispatcher(settings), acknowledgement_base_url=settings.acknowledgement_base_url, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, ) now = datetime.now(UTC) env = SimpleNamespace( @@ -472,6 +476,7 @@ async def collect(*, repository, policy, scheduled_at): # type: ignore[no-untyp scheduled_at=now, correlation_id=uuid4().hex, open_sleeper_url="https://sleeper.com", + fetch_game_summary=CloudflareESPNProvider(fetch, clock=lambda: now).game_summary, ) except (OSError, RuntimeError, ValueError) as error: print(redact_secrets(f"Scheduled run failed: {error}"), file=sys.stderr) diff --git a/src/sleeper_manager/cloudflare/dispatcher.py b/src/sleeper_manager/cloudflare/dispatcher.py index 5ec8394..eb3027f 100644 --- a/src/sleeper_manager/cloudflare/dispatcher.py +++ b/src/sleeper_manager/cloudflare/dispatcher.py @@ -14,6 +14,7 @@ from typing import Protocol from sleeper_manager.cloudflare.planning import CloudflarePlanningAssembly +from sleeper_manager.cloudflare.postgame_dispatch import dispatch_postgame_work from sleeper_manager.cloudflare.scheduler_types import ( FailureCategory, FreshnessDetail, @@ -34,8 +35,13 @@ ) from sleeper_manager.projections.live_baseline import ProjectionHistoryError from sleeper_manager.workflows.daily_plan import WeeklyLineupWorkflowResult, run_daily_plan +from sleeper_manager.workflows.lock_in_planning import ( + refresh_terminal_lock_in_scores, + sync_live_lock_in_opportunities, +) from sleeper_manager.workflows.notification_loop import NotificationLoop from sleeper_manager.workflows.planning_inputs import LivePlanningInputs +from sleeper_manager.workflows.postgame_lock_in import DirectGameSummarySource from sleeper_manager.workflows.pre_tipoff_check import run_pre_tipoff_check _RETRY_DELAY = timedelta(minutes=5) @@ -71,8 +77,9 @@ async def dispatch_due_work( open_sleeper_url: str, plan_policy: WeeklyPlanPolicyConfig | None = None, clock: Callable[[], datetime] | None = None, + fetch_game_summary: DirectGameSummarySource | None = None, ) -> ScheduledRunSummary: - """Dispatch claimed daily, pre-tipoff, and delivery-retry rows for this wake.""" + """Dispatch claimed daily, pre-tipoff, postgame, and delivery-retry rows for this wake.""" if scheduled_at.tzinfo is None or scheduled_at.utcoffset() is None: raise ValueError("Scheduled time must be timezone-aware") tick = clock or (lambda: scheduled_at) @@ -89,6 +96,7 @@ async def dispatch_due_work( await _ensure_daily_work(repository, policy, scheduled_at) await repository.cancel_expired_scheduled_work(scheduled_at) + await repository.expire_lock_in_opportunities(scheduled_at) claimed = await repository.claim_due_work(scheduled_at, correlation_id=correlation_id) if not claimed: return ScheduledRunSummary( @@ -98,7 +106,12 @@ async def dispatch_due_work( claimed_count=0, ) - planning = tuple(item for item in claimed if item.kind is not DueWorkKind.DELIVERY_RETRY) + planning = tuple( + item + for item in claimed + if item.kind not in {DueWorkKind.DELIVERY_RETRY, DueWorkKind.POSTGAME} + ) + postgame = tuple(item for item in claimed if item.kind is DueWorkKind.POSTGAME) retries = tuple(item for item in claimed if item.kind is DueWorkKind.DELIVERY_RETRY) attempts: list[WorkAttemptSummary] = [] if planning: @@ -114,6 +127,22 @@ async def dispatch_due_work( open_sleeper_url=open_sleeper_url, plan_policy=plan_policy, clock=tick, + fetch_game_summary=fetch_game_summary, + ) + ) + if postgame: + attempts.extend( + await dispatch_postgame_work( + repository, + postgame, + policy=policy, + notifications=notifications, + collect=collect, + fetch_summary=fetch_game_summary, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + open_sleeper_url=open_sleeper_url, + clock=tick, ) ) for work in retries: @@ -178,6 +207,7 @@ async def _run_planning( open_sleeper_url: str, plan_policy: WeeklyPlanPolicyConfig | None, clock: Callable[[], datetime], + fetch_game_summary: DirectGameSummarySource | None = None, ) -> tuple[WorkAttemptSummary, ...]: trigger = ( "pre_tipoff" if any(item.kind is DueWorkKind.PRE_TIPOFF for item in claimed) else "daily" @@ -210,15 +240,29 @@ async def _run_planning( retry=True, ) + planning_inputs = assembly.evidence.inputs await _replace_future_pre_tipoff( repository, policy, - assembly.evidence.inputs, + planning_inputs, scheduled_at, ) + await sync_live_lock_in_opportunities( + planning_inputs, + repository=repository, + observed_at=assembly.evidence.decision_time, + ) + if trigger == "daily" and fetch_game_summary is not None: + planning_inputs = await refresh_terminal_lock_in_scores( + planning_inputs, + repository=repository, + fetch_summary=fetch_game_summary, + observed_at=assembly.evidence.decision_time, + poll_id=f"{correlation_id}:daily-refresh", + ) workflow = run_pre_tipoff_check if trigger == "pre_tipoff" else run_daily_plan result = await workflow( - assembly.evidence.inputs, + planning_inputs, decision_time=assembly.evidence.decision_time, repository=repository, notifications=notifications, diff --git a/src/sleeper_manager/cloudflare/postgame_dispatch.py b/src/sleeper_manager/cloudflare/postgame_dispatch.py new file mode 100644 index 0000000..0366c0d --- /dev/null +++ b/src/sleeper_manager/cloudflare/postgame_dispatch.py @@ -0,0 +1,221 @@ +"""Dispatch coalesced postgame Lock-In work outside the main scheduler module.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Protocol + +from sleeper_manager.cloudflare.planning import CloudflarePlanningAssembly +from sleeper_manager.cloudflare.scheduler_types import ( + FailureCategory, + ScheduledRunStatus, + WorkAttemptSummary, +) +from sleeper_manager.domain.runtime_policy import RuntimePolicy +from sleeper_manager.persistence.base import ( + AsyncRuntimeStateRepository, + ScheduledWorkRecord, + ScheduledWorkStatus, +) +from sleeper_manager.workflows.notification_loop import NotificationLoop +from sleeper_manager.workflows.postgame_lock_in import ( + DirectGameSummarySource, + run_postgame_lock_in, +) + +_RETRY_DELAY = timedelta(minutes=5) + + +class PostgamePlanningCollector(Protocol): + """Collect current weekly inputs needed to evaluate a stable final score.""" + + async def __call__( + self, + *, + repository: AsyncRuntimeStateRepository, + policy: RuntimePolicy, + scheduled_at: datetime, + ) -> CloudflarePlanningAssembly: ... + + +async def dispatch_postgame_work( + repository: AsyncRuntimeStateRepository, + work_items: tuple[ScheduledWorkRecord, ...], + *, + policy: RuntimePolicy, + notifications: NotificationLoop, + collect: PostgamePlanningCollector, + fetch_summary: DirectGameSummarySource | None, + scheduled_at: datetime, + correlation_id: str, + open_sleeper_url: str, + clock: Callable[[], datetime], +) -> tuple[WorkAttemptSummary, ...]: + """Collect once, fetch each game summary once, and retain five-minute watches.""" + + if fetch_summary is None: + return await _finish_all( + repository, + work_items, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=ScheduledRunStatus.BLOCKED, + failure=FailureCategory.CONFIGURATION, + detail="direct_game_summary_source_missing", + ) + try: + assembly = await collect( + repository=repository, + policy=policy, + scheduled_at=scheduled_at, + ) + except Exception as error: + return await _finish_all( + repository, + work_items, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=ScheduledRunStatus.FAILED, + failure=FailureCategory.PROVIDER, + detail=str(error), + ) + attempts: list[WorkAttemptSummary] = [] + for work in work_items: + if work.game_id is None: + attempts.append( + await _finish( + repository, + work, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=ScheduledRunStatus.FAILED, + failure=FailureCategory.INTERNAL_INVARIANT, + detail="postgame_work_missing_game", + retry=False, + ) + ) + continue + try: + result = await run_postgame_lock_in( + work.game_id, + assembly.evidence.inputs, + decision_time=assembly.evidence.decision_time, + repository=repository, + notifications=notifications, + fetch_summary=fetch_summary, + runtime_policy=policy, + open_sleeper_url=open_sleeper_url, + poll_id=f"{correlation_id}:{work.work_id}", + player_names=dict(assembly.player_names), + ) + outcome = { + "wait": ScheduledRunStatus.NO_ACTION, + "notified": ScheduledRunStatus.SUCCESS, + "duplicate": ScheduledRunStatus.DUPLICATE, + "unavailable": ScheduledRunStatus.BLOCKED, + "automatic_final": ScheduledRunStatus.NO_ACTION, + "delivery_failed": ScheduledRunStatus.DELIVERY_FAILED, + }[result.outcome] + retry = await repository.has_open_lock_in_watch(work.game_id, scheduled_at) + failure = FailureCategory.DELIVERY if result.outcome == "delivery_failed" else None + attempts.append( + await _finish( + repository, + work, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=outcome, + failure=failure, + recommendation=result.recommendation, + retry=retry, + clock=clock, + ) + ) + except Exception as error: + attempts.append( + await _finish( + repository, + work, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=ScheduledRunStatus.FAILED, + failure=FailureCategory.PROVIDER, + detail=str(error), + ) + ) + return tuple(attempts) + + +async def _finish_all( + repository: AsyncRuntimeStateRepository, + work_items: tuple[ScheduledWorkRecord, ...], + *, + scheduled_at: datetime, + correlation_id: str, + outcome: ScheduledRunStatus, + failure: FailureCategory | None = None, + detail: str | None = None, +) -> tuple[WorkAttemptSummary, ...]: + """Finish every claimed postgame row with the same blocked or failed outcome.""" + + attempts: list[WorkAttemptSummary] = [] + for work in work_items: + attempts.append( + await _finish( + repository, + work, + scheduled_at=scheduled_at, + correlation_id=correlation_id, + outcome=outcome, + failure=failure, + detail=detail, + ) + ) + return tuple(attempts) + + +async def _finish( + repository: AsyncRuntimeStateRepository, + work: ScheduledWorkRecord, + *, + scheduled_at: datetime, + correlation_id: str, + outcome: ScheduledRunStatus, + failure: FailureCategory | None = None, + detail: str | None = None, + recommendation: object | None = None, + retry: bool = True, + clock: Callable[[], datetime] | None = None, +) -> WorkAttemptSummary: + """Persist one postgame attempt and its next five-minute wake when still open.""" + + recommendation_id = getattr(recommendation, "recommendation_id", None) + revision = getattr(recommendation, "revision", None) + attempt = WorkAttemptSummary( + work_id=work.work_id, + kind=work.kind, + outcome=outcome, + attempt_count=work.attempt_count, + recommendation_id=recommendation_id, + recommendation_revision=revision, + failure_category=failure, + ) + payload = attempt.as_dict() + if detail: + payload["detail"] = detail + finished_at = (clock or (lambda: scheduled_at))() + await repository.finish_scheduled_work( + work.work_id, + status=ScheduledWorkStatus.RETRY if retry else ScheduledWorkStatus.COMPLETED, + finished_at=finished_at, + correlation_id=correlation_id, + failure_category=failure.value if failure else None, + terminal_summary_json=json.dumps(payload, sort_keys=True), + retry_at=scheduled_at + _RETRY_DELAY if retry else None, + ) + return attempt + + +__all__ = ["PostgamePlanningCollector", "dispatch_postgame_work"] diff --git a/src/sleeper_manager/cloudflare/runtime.py b/src/sleeper_manager/cloudflare/runtime.py index 149cae4..a4db54c 100644 --- a/src/sleeper_manager/cloudflare/runtime.py +++ b/src/sleeper_manager/cloudflare/runtime.py @@ -17,6 +17,7 @@ CloudflarePlanningAssembly, collect_cloudflare_planning_inputs, ) +from sleeper_manager.cloudflare.providers import CloudflareESPNProvider from sleeper_manager.cloudflare.scheduler_types import ( FailureCategory, ScheduledRunStatus, @@ -27,6 +28,7 @@ from sleeper_manager.persistence.base import AsyncRuntimeStateRepository from sleeper_manager.persistence.d1 import D1StateRepository from sleeper_manager.workflows.notification_loop import NotificationLoop +from sleeper_manager.workflows.postgame_lock_in import LOCK_IN_ACKNOWLEDGEMENT_KINDS def _value(env: Any, name: str, default: str = "") -> str: @@ -119,10 +121,12 @@ async def collect( dispatcher, acknowledgement_base_url=acknowledgement_base_url, clock=lambda: now, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, ), collect=collect, scheduled_at=now, correlation_id=correlation, open_sleeper_url=_value(env, "OPEN_SLEEPER_URL", "https://sleeper.com"), + fetch_game_summary=CloudflareESPNProvider(fetcher, clock=lambda: now).game_summary, ) return summary.as_dict() diff --git a/src/sleeper_manager/config.py b/src/sleeper_manager/config.py index aba4ae4..fab6798 100644 --- a/src/sleeper_manager/config.py +++ b/src/sleeper_manager/config.py @@ -20,7 +20,10 @@ _REMOVED_DECISION_KEYS = frozenset({"use_matchup_context", "protect_elite_upside"}) _REMOVED_NOTIFICATION_KEYS = frozenset({"daily_summary", "injury_alerts"}) -_REMOVED_FROM_VERSION_ONE = _REMOVED_DECISION_KEYS | _REMOVED_NOTIFICATION_KEYS +_REMOVED_PLAYER_KEYS = frozenset({"protected_sleeper_ids"}) +_REMOVED_FROM_VERSION_ONE = ( + _REMOVED_DECISION_KEYS | _REMOVED_NOTIFICATION_KEYS | _REMOVED_PLAYER_KEYS +) class DecisionPolicy(BaseModel): @@ -41,7 +44,6 @@ class NotificationPolicy(BaseModel): class PlayerPolicy(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - protected_sleeper_ids: tuple[str, ...] = () mapping_overrides: dict[str, str] = Field(default_factory=dict) @@ -70,7 +72,6 @@ def to_manager_intent(self) -> ManagerIntent: urgent_actions_override_quiet_hours=( self.notifications.urgent_actions_override_quiet_hours ), - protected_sleeper_ids=self.players.protected_sleeper_ids, version=self.version, ) @@ -102,6 +103,10 @@ def load_manager_policy(path: Path) -> ManagerPolicy: raise ValueError("The [notifications] policy section must be a TOML table") _reject_removed_policy_keys(decision_values, section="decision") _reject_removed_policy_keys(notification_values, section="notifications") + player_values = raw.get("players", {}) + if not isinstance(player_values, dict): + raise ValueError("The [players] policy section must be a TOML table") + _reject_removed_policy_keys(player_values, section="players") preset = decision_values.get("preset", "balanced") if preset not in _PRESET_VALUES: @@ -115,7 +120,7 @@ def load_manager_policy(path: Path) -> ManagerPolicy: resolved = { "decision": resolved_decision, "notifications": notification_values, - "players": raw.get("players", {}), + "players": player_values, } return ManagerPolicy.model_validate(resolved) diff --git a/src/sleeper_manager/decisions/live_lock_in.py b/src/sleeper_manager/decisions/live_lock_in.py new file mode 100644 index 0000000..3196099 --- /dev/null +++ b/src/sleeper_manager/decisions/live_lock_in.py @@ -0,0 +1,263 @@ +"""Translate stable live evidence into actionable or deferred Lock-In outcomes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from math import isfinite + +from sleeper_manager.decisions.lock_in import ( + LockInPolicyError, + ScoreMaximizingLockInPolicy, +) +from sleeper_manager.domain.eligibility import eligible_for_slot +from sleeper_manager.domain.lock_in import ( + LockInEvaluation, + LockInEvaluationKind, +) +from sleeper_manager.domain.planning import ( + GameOpportunity, + PlanningGameStatus, + TeamWeekState, +) + + +@dataclass(frozen=True, slots=True) +class LiveLockInPolicyConfig: + """Control the minimum scenario confidence required for live advice.""" + + minimum_confidence: float + + def __post_init__(self) -> None: + """Reject confidence thresholds outside the probability interval.""" + + if not isfinite(self.minimum_confidence) or not 0 <= self.minimum_confidence <= 1: + raise ValueError("Minimum Lock-In confidence must be between zero and one") + + +def evaluate_live_lock_in( + state: TeamWeekState, + completed_game: GameOpportunity, + *, + deadline: datetime, + manager_policy_version: str, + policy: ScoreMaximizingLockInPolicy, + config: LiveLockInPolicyConfig, +) -> LockInEvaluation: + """Evaluate stable final evidence using the shared historical policy.""" + + if deadline.tzinfo is None or deadline.utcoffset() is None: + raise ValueError("Lock-In deadlines must be timezone-aware") + trace = _evaluation_trace(state, completed_game, config) + if state.decision_time >= deadline: + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.UNAVAILABLE, + reason="deadline_elapsed", + trace=trace, + ) + if state.is_blocked: + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.UNAVAILABLE, + reason="planning_state_blocked", + trace=trace, + ) + if not _has_legal_open_slot(state, completed_game): + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.UNAVAILABLE, + reason="ineligible_at_tipoff", + trace=trace, + ) + if not _has_later_eligible_game(state, completed_game): + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.AUTOMATIC_FINAL, + reason="final_eligible_game", + trace=trace, + ) + try: + comparison = policy.compare_after_game(state, completed_game) + except LockInPolicyError as error: + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.UNAVAILABLE, + reason=_policy_error_reason(error), + trace=trace, + ) + if not comparison.selected_terminal_scores: + return _deferred_evaluation( + state, + completed_game, + deadline=deadline, + manager_policy_version=manager_policy_version, + kind=LockInEvaluationKind.UNAVAILABLE, + reason="scenario_comparison_unavailable", + trace=trace, + ) + confidence = _scenario_confidence( + comparison.selected_terminal_scores, + comparison.counterfactual_terminal_scores, + tie_tolerance=policy.config.tie_tolerance, + ) + alternative_scores = comparison.counterfactual_terminal_scores + if confidence < config.minimum_confidence: + return LockInEvaluation( + decision_time=state.decision_time, + kind=LockInEvaluationKind.WAIT, + player_id=completed_game.sleeper_player_id, + game_id=completed_game.game_id, + deadline=deadline, + information_version=state.input_version, + manager_policy_version=manager_policy_version, + reason_codes=("confidence_below_threshold",), + trace=trace, + observed_score=completed_game.completed_fantasy_score, + alternative_expected_score=_mean(alternative_scores), + alternative_percentiles=_percentiles(alternative_scores), + confidence=confidence, + ) + return LockInEvaluation( + decision_time=state.decision_time, + kind=LockInEvaluationKind(comparison.decision.kind.value), + player_id=completed_game.sleeper_player_id, + game_id=completed_game.game_id, + deadline=deadline, + information_version=state.input_version, + manager_policy_version=manager_policy_version, + reason_codes=("confidence_met",), + trace=trace, + observed_score=completed_game.completed_fantasy_score, + alternative_expected_score=_mean(alternative_scores), + alternative_percentiles=_percentiles(alternative_scores), + confidence=confidence, + decision=comparison.decision, + ) + + +def _deferred_evaluation( + state: TeamWeekState, + completed_game: GameOpportunity, + *, + deadline: datetime, + manager_policy_version: str, + kind: LockInEvaluationKind, + reason: str, + trace: tuple[tuple[str, str], ...], +) -> LockInEvaluation: + """Build a non-actionable evaluation while retaining observed evidence.""" + + return LockInEvaluation( + decision_time=state.decision_time, + kind=kind, + player_id=completed_game.sleeper_player_id, + game_id=completed_game.game_id, + deadline=deadline, + information_version=state.input_version, + manager_policy_version=manager_policy_version, + reason_codes=(reason,), + trace=trace, + observed_score=completed_game.completed_fantasy_score, + ) + + +def _has_legal_open_slot(state: TeamWeekState, opportunity: GameOpportunity) -> bool: + """Report whether the tipoff evidence admits a current starting slot.""" + + return opportunity.rostered_at_tipoff is True and any( + slot.index in state.open_slot_indices + and slot.index in opportunity.eligible_slot_indices + and eligible_for_slot(opportunity.eligible_positions, slot.position) + for slot in state.starter_slots + ) + + +def _has_later_eligible_game(state: TeamWeekState, completed_game: GameOpportunity) -> bool: + """Report whether this player retains a legal later chance in the fantasy week.""" + + open_indices = set(state.open_slot_indices) + return any( + opportunity.sleeper_player_id == completed_game.sleeper_player_id + and opportunity.game_id != completed_game.game_id + and opportunity.status is PlanningGameStatus.SCHEDULED + and opportunity.scheduled_start > state.decision_time + and bool(open_indices.intersection(opportunity.eligible_slot_indices)) + for opportunity in state.opportunities + ) + + +def _scenario_confidence( + selected: tuple[float, ...], + counterfactual: tuple[float, ...], + *, + tie_tolerance: float, +) -> float: + """Return the fraction of scenarios with a material selected-action win.""" + + wins = sum( + selected_score - counterfactual_score > tie_tolerance + for selected_score, counterfactual_score in zip(selected, counterfactual, strict=True) + ) + return wins / len(selected) + + +def _mean(values: tuple[float, ...]) -> float: + """Return a canonical six-decimal scenario mean.""" + + return round(sum(values) / len(values), 6) + + +def _percentiles(values: tuple[float, ...]) -> tuple[tuple[int, float], ...]: + """Summarize the alternative terminal distribution at stable percentile ranks.""" + + ordered = tuple(sorted(values)) + return tuple((rank, ordered[round((len(ordered) - 1) * rank / 100)]) for rank in (10, 50, 90)) + + +def _evaluation_trace( + state: TeamWeekState, + completed_game: GameOpportunity, + config: LiveLockInPolicyConfig, +) -> tuple[tuple[str, str], ...]: + """Capture stable versions and thresholds that explain the live result.""" + + return ( + ("league_configuration_version", state.league_configuration_version), + ("scoring_policy_version", state.scoring_policy_version), + ("projection_model_version", state.projection_model_version), + ("completed_game_status", completed_game.status.value), + ("minimum_confidence", f"{config.minimum_confidence:.6f}"), + ) + + +def _policy_error_reason(error: LockInPolicyError) -> str: + """Reduce a policy failure to a stable non-actionable reason code.""" + + message = str(error).lower() + if "projection" in message: + return "projection_unavailable" + if "blocked" in message: + return "planning_state_blocked" + return "policy_input_unavailable" + + +__all__ = ( + "LiveLockInPolicyConfig", + "evaluate_live_lock_in", +) diff --git a/src/sleeper_manager/decisions/lock_in.py b/src/sleeper_manager/decisions/lock_in.py index beb6452..87157e5 100644 --- a/src/sleeper_manager/decisions/lock_in.py +++ b/src/sleeper_manager/decisions/lock_in.py @@ -14,6 +14,7 @@ Scenario, ScenarioInput, generate_projection_scenarios, + rollout_scenario_assignments, rollout_scenario_terminal_score, stable_scenario_seed, ) @@ -48,6 +49,21 @@ def __post_init__(self) -> None: raise ValueError("Tie tolerance must be non-negative") +@dataclass(frozen=True, slots=True) +class LockInComparison: + """Expose the selected and counterfactual terminal values by scenario.""" + + decision: LockInDecision + selected_terminal_scores: tuple[float, ...] + counterfactual_terminal_scores: tuple[float, ...] + + def __post_init__(self) -> None: + """Require paired scenarios whenever a comparison was available.""" + + if len(self.selected_terminal_scores) != len(self.counterfactual_terminal_scores): + raise ValueError("Lock-In comparison scenarios must be paired") + + class ScoreMaximizingLockInPolicy: """Choose Lock or Pass by maximizing expected own-team terminal score.""" @@ -63,16 +79,29 @@ def decide_after_game( ) -> LockInDecision: """Evaluate one finalized opportunity against every legal future alternative.""" + return self.compare_after_game(state, completed_game).decision + + def compare_after_game( + self, + state: TeamWeekState, + completed_game: GameOpportunity, + ) -> LockInComparison: + """Return the historical decision plus paired scenario terminal values.""" + _validate_policy_input(state, completed_game) completed_score = completed_game.completed_fantasy_score assert completed_score is not None locked_score = sum(slot.accepted_fantasy_score for slot in state.fixed_slots) if completed_game.rostered_at_tipoff is False: - return _terminal_pass( - state, - completed_game, - expected_terminal_score=locked_score, - reason="PASS because the player was not rostered at tipoff.", + return LockInComparison( + decision=_terminal_pass( + state, + completed_game, + expected_terminal_score=locked_score, + reason="PASS because the player was not rostered at tipoff.", + ), + selected_terminal_scores=(), + counterfactual_terminal_scores=(), ) if completed_game.rostered_at_tipoff is None: raise LockInPolicyError("Completed opportunity lacks tipoff roster evidence") @@ -87,11 +116,15 @@ def decide_after_game( and eligible_for_slot(completed_game.eligible_positions, slot.position) ) if not legal_open_slots: - return _terminal_pass( - state, - completed_game, - expected_terminal_score=locked_score, - reason="No legal open starting slot remained.", + return LockInComparison( + decision=_terminal_pass( + state, + completed_game, + expected_terminal_score=locked_score, + reason="No legal open starting slot remained.", + ), + selected_terminal_scores=(), + counterfactual_terminal_scores=(), ) remaining = decision_critical_opportunities(state, completed_game) @@ -114,7 +147,13 @@ def decide_after_game( open_slots, scenarios=scenarios, ) - best_lock: tuple[float, int, float] | None = None + pass_scores = _scenario_terminal_scores( + inputs, + open_slots, + scenarios=scenarios, + fixed_score=locked_score, + ) + best_lock: tuple[float, int, float, tuple[float, ...]] | None = None for slot in legal_open_slots: fixed = AssignmentCandidate( candidate_id=( @@ -134,35 +173,51 @@ def decide_after_game( slot_indices=tuple(item.index for item in remaining_slots), scenarios=scenarios, ) - candidate = (lock_value, slot.index, lock_value - pass_value) + lock_scores = _scenario_terminal_scores( + inputs, + remaining_slots, + scenarios=scenarios, + fixed_assignments=(fixed,), + fixed_score=locked_score + completed_score, + ) + candidate = (lock_value, slot.index, lock_value - pass_value, lock_scores) if best_lock is None or candidate[0] > best_lock[0] + 1e-9: best_lock = candidate assert best_lock is not None if best_lock[0] <= pass_value + self.config.tie_tolerance: - return LockInDecision( + return LockInComparison( + decision=LockInDecision( + decision_time=state.decision_time, + kind=LockInDecisionKind.PASS, + player_id=completed_game.sleeper_player_id, + game_id=completed_game.game_id, + slot_index=None, + expected_terminal_score=pass_value, + counterfactual_value=pass_value - best_lock[0], + information_version=state.input_version, + reason="PASS preserved future own-team slot flexibility within tie tolerance.", + ), + selected_terminal_scores=pass_scores, + counterfactual_terminal_scores=best_lock[3], + ) + return LockInComparison( + decision=LockInDecision( decision_time=state.decision_time, - kind=LockInDecisionKind.PASS, + kind=LockInDecisionKind.LOCK, player_id=completed_game.sleeper_player_id, game_id=completed_game.game_id, - slot_index=None, - expected_terminal_score=pass_value, - counterfactual_value=pass_value - best_lock[0], + slot_index=best_lock[1], + expected_terminal_score=best_lock[0], + counterfactual_value=best_lock[0] - pass_value, information_version=state.input_version, - reason="PASS preserved future own-team slot flexibility within tie tolerance.", - ) - return LockInDecision( - decision_time=state.decision_time, - kind=LockInDecisionKind.LOCK, - player_id=completed_game.sleeper_player_id, - game_id=completed_game.game_id, - slot_index=best_lock[1], - expected_terminal_score=best_lock[0], - counterfactual_value=best_lock[0] - pass_value, - information_version=state.input_version, - reason=( - "LOCK maximized expected terminal own-team score across deterministic scenarios." + reason=( + "LOCK maximized expected terminal own-team score " + "across deterministic scenarios." + ), ), + selected_terminal_scores=best_lock[3], + counterfactual_terminal_scores=pass_scores, ) @@ -259,6 +314,26 @@ def _remaining_terminal_value( ) +def _scenario_terminal_scores( + inputs: tuple[ScenarioInput, ...], + open_slots: tuple[StarterSlot, ...], + *, + scenarios: tuple[Scenario, ...], + fixed_score: float, + fixed_assignments: tuple[AssignmentCandidate, ...] = (), +) -> tuple[float, ...]: + """Return one complete terminal score for each deterministic scenario.""" + + results = rollout_scenario_assignments( + fixed_assignments=fixed_assignments, + remaining_inputs=inputs, + open_slots=tuple(slot.position for slot in open_slots), + slot_indices=tuple(slot.index for slot in open_slots), + scenarios=scenarios, + ) + return tuple(fixed_score + result.score for result in results) + + def _terminal_pass( state: TeamWeekState, completed_game: GameOpportunity, @@ -288,6 +363,7 @@ def _opportunity_key(opportunity: GameOpportunity) -> str: __all__ = ( + "LockInComparison", "LockInPolicyConfig", "LockInPolicyError", "ScoreMaximizingLockInPolicy", diff --git a/src/sleeper_manager/domain/lock_in.py b/src/sleeper_manager/domain/lock_in.py index e94bd1d..f732a94 100644 --- a/src/sleeper_manager/domain/lock_in.py +++ b/src/sleeper_manager/domain/lock_in.py @@ -24,6 +24,31 @@ class LockInDecisionKind(StrEnum): PASS = "pass" +class LockInEvaluationKind(StrEnum): + """Identify actionable, deferred, and terminal live evaluation outcomes.""" + + LOCK = "lock" + PASS = "pass" + WAIT = "wait" + UNAVAILABLE = "unavailable" + AUTOMATIC_FINAL = "automatic_final" + + +class LockInOpportunityStatus(StrEnum): + """Identify every durable stage in a live player-game opportunity.""" + + SCHEDULED = "scheduled" + ACTIVE = "active" + INELIGIBLE = "ineligible" + FINALIZING = "finalizing" + ACTIONABLE = "actionable" + AUTOMATIC_FINAL = "automatic_final" + ACKNOWLEDGED_LOCKED = "acknowledged_locked" + ACKNOWLEDGED_PASSED = "acknowledged_passed" + EXPIRED = "expired" + RECONCILIATION_REQUIRED = "reconciliation_required" + + @dataclass(frozen=True, slots=True) class LockInDecision: """Capture one policy result with the evidence required to audit it later.""" @@ -78,6 +103,64 @@ def __post_init__(self) -> None: raise LockInContractError("Lock-In evaluation order must be positive") +@dataclass(frozen=True, slots=True) +class LockInEvaluation: + """Describe one auditable live result without inventing a Lock/Pass decision.""" + + decision_time: datetime + kind: LockInEvaluationKind + player_id: str + game_id: str + deadline: datetime + information_version: str + manager_policy_version: str + reason_codes: tuple[str, ...] + trace: tuple[tuple[str, str], ...] + observed_score: float | None = None + alternative_expected_score: float | None = None + alternative_percentiles: tuple[tuple[int, float], ...] = () + confidence: float | None = None + decision: LockInDecision | None = None + + def __post_init__(self) -> None: + """Reject evaluations whose outcome and supporting evidence disagree.""" + + _require_aware(self.decision_time, "evaluation time") + _require_aware(self.deadline, "evaluation deadline") + _require_text(self.player_id, "player ID") + _require_text(self.game_id, "game ID") + _require_text(self.information_version, "information version") + _require_text(self.manager_policy_version, "manager policy version") + if not self.reason_codes or any(not item.strip() for item in self.reason_codes): + raise LockInContractError("Lock-In evaluations require reason codes") + trace_keys = tuple(key for key, _ in self.trace) + if not self.trace or len(set(trace_keys)) != len(trace_keys): + raise LockInContractError("Lock-In evaluation trace requires unique evidence keys") + if any(not key.strip() or not value.strip() for key, value in self.trace): + raise LockInContractError("Lock-In evaluation trace values must be non-empty") + if self.observed_score is not None and not isfinite(self.observed_score): + raise LockInContractError("Observed Lock-In score must be finite") + if self.alternative_expected_score is not None and not isfinite( + self.alternative_expected_score + ): + raise LockInContractError("Alternative Lock-In score must be finite") + if self.confidence is not None and ( + not isfinite(self.confidence) or not 0 <= self.confidence <= 1 + ): + raise LockInContractError("Lock-In confidence must be between zero and one") + _validate_percentiles(self.alternative_percentiles) + actionable = self.kind in {LockInEvaluationKind.LOCK, LockInEvaluationKind.PASS} + if actionable != (self.decision is not None): + raise LockInContractError("Only actionable evaluations carry a Lock-In decision") + if self.decision is not None: + if self.decision.player_id != self.player_id or self.decision.game_id != self.game_id: + raise LockInContractError("Evaluation and decision identities must match") + if self.decision.kind.value != self.kind.value: + raise LockInContractError("Evaluation and decision actions must match") + if self.observed_score is None or self.confidence is None: + raise LockInContractError("Actionable evaluations require score and confidence") + + def _require_text(value: str, label: str) -> None: """Require stable nonblank identifiers and human-readable evidence.""" @@ -85,9 +168,29 @@ def _require_text(value: str, label: str) -> None: raise LockInContractError(f"Lock-In {label} must be non-empty") +def _require_aware(value: datetime, label: str) -> None: + """Require timezone-aware timestamps at live decision boundaries.""" + + if value.tzinfo is None or value.utcoffset() is None: + raise LockInContractError(f"Lock-In {label} must be timezone-aware") + + +def _validate_percentiles(values: tuple[tuple[int, float], ...]) -> None: + """Require ordered finite percentile evidence without duplicate ranks.""" + + ranks = tuple(rank for rank, _ in values) + if ranks != tuple(sorted(set(ranks))) or any(not 0 <= rank <= 100 for rank in ranks): + raise LockInContractError("Lock-In percentiles must have unique ordered ranks") + if any(not isfinite(value) for _, value in values): + raise LockInContractError("Lock-In percentile values must be finite") + + __all__ = ( "LockInContractError", "LockInDecision", "LockInDecisionKind", "LockInDecisionTrace", + "LockInEvaluation", + "LockInEvaluationKind", + "LockInOpportunityStatus", ) diff --git a/src/sleeper_manager/domain/runtime_policy.py b/src/sleeper_manager/domain/runtime_policy.py index 54c024a..da66ce4 100644 --- a/src/sleeper_manager/domain/runtime_policy.py +++ b/src/sleeper_manager/domain/runtime_policy.py @@ -25,7 +25,6 @@ class ManagerIntent: quiet_hours_start: str quiet_hours_end: str urgent_actions_override_quiet_hours: bool - protected_sleeper_ids: tuple[str, ...] version: str def __post_init__(self) -> None: @@ -47,12 +46,6 @@ def __post_init__(self) -> None: "quiet_hours_end", _validate_wall_clock(self.quiet_hours_end, "quiet_hours_end"), ) - protected = tuple(item.strip() for item in self.protected_sleeper_ids) - if any(not item for item in protected): - raise RuntimePolicyError("Protected Sleeper IDs must be non-empty") - if len(set(protected)) != len(protected): - raise RuntimePolicyError("Protected Sleeper IDs must be unique") - object.__setattr__(self, "protected_sleeper_ids", protected) if not self.version.strip(): raise RuntimePolicyError("Manager intent version must be non-empty") @@ -70,9 +63,13 @@ def from_json(cls, payload: Mapping[str, object]) -> ManagerIntent: extras = set(payload) - allowed if extras: raise RuntimePolicyError("Unknown manager intent fields: " + ", ".join(sorted(extras))) - protected_raw = payload.get("protected_sleeper_ids", ()) + protected_raw = payload.get("protected_sleeper_ids", []) if not isinstance(protected_raw, list): raise RuntimePolicyError("protected_sleeper_ids must be a JSON array") + if protected_raw: + raise RuntimePolicyError( + "protected_sleeper_ids is removed from version one; resync manager policy" + ) preset = payload.get("preset", "balanced") if not isinstance(preset, str): raise RuntimePolicyError("preset must be a string") @@ -88,7 +85,6 @@ def from_json(cls, payload: Mapping[str, object]) -> ManagerIntent: quiet_hours_start=_required_string(payload, "quiet_hours_start", default="23:00"), quiet_hours_end=_required_string(payload, "quiet_hours_end", default="07:00"), urgent_actions_override_quiet_hours=quiet_override, - protected_sleeper_ids=tuple(str(item) for item in protected_raw), version=_required_string(payload, "version"), ) @@ -99,7 +95,6 @@ def to_json(self) -> dict[str, object]: "quiet_hours_start": self.quiet_hours_start, "quiet_hours_end": self.quiet_hours_end, "urgent_actions_override_quiet_hours": self.urgent_actions_override_quiet_hours, - "protected_sleeper_ids": list(self.protected_sleeper_ids), "version": self.version, } diff --git a/src/sleeper_manager/persistence/async_sqlite.py b/src/sleeper_manager/persistence/async_sqlite.py index ff7297f..91c998c 100644 --- a/src/sleeper_manager/persistence/async_sqlite.py +++ b/src/sleeper_manager/persistence/async_sqlite.py @@ -26,6 +26,11 @@ ScheduledWorkRecord, ScheduledWorkStatus, ) +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityKey, + LockInOpportunityRecord, +) from sleeper_manager.persistence.sqlite import SQLiteStateRepository @@ -222,3 +227,80 @@ async def list_scheduled_work( async def cancel_expired_scheduled_work(self, now: datetime) -> int: return self._repository.cancel_expired_scheduled_work(now) + + async def upsert_lock_in_opportunity(self, record: LockInOpportunityRecord) -> bool: + """Insert one opportunity through the synchronous SQLite repository.""" + + return self._repository.upsert_lock_in_opportunity(record) + + async def get_lock_in_opportunity( + self, key: LockInOpportunityKey + ) -> LockInOpportunityRecord | None: + """Load one opportunity through the synchronous SQLite repository.""" + + return self._repository.get_lock_in_opportunity(key) + + async def record_lock_in_observation( + self, + key: LockInOpportunityKey, + observation: LockInObservation, + *, + expected_version: int, + ) -> LockInOpportunityRecord | None: + """Apply one guarded direct observation through SQLite.""" + + return self._repository.record_lock_in_observation( + key, + observation, + expected_version=expected_version, + ) + + async def update_lock_in_opportunity( + self, + record: LockInOpportunityRecord, + *, + expected_version: int, + ) -> bool: + """Apply one guarded full opportunity update through SQLite.""" + + return self._repository.update_lock_in_opportunity( + record, + expected_version=expected_version, + ) + + async def list_due_lock_in_opportunities( + self, now: datetime, *, limit: int = 100 + ) -> tuple[LockInOpportunityRecord, ...]: + """List opportunities due for this Worker-shaped wake.""" + + return self._repository.list_due_lock_in_opportunities(now, limit=limit) + + async def list_actionable_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """List actionable opportunities through SQLite.""" + + return self._repository.list_actionable_lock_in_opportunities( + league_id, + fantasy_week, + ) + + async def expire_lock_in_opportunities(self, now: datetime) -> int: + """Expire elapsed opportunity windows through SQLite.""" + + return self._repository.expire_lock_in_opportunities(now) + + async def has_open_lock_in_watch(self, game_id: str, now: datetime) -> bool: + """Report whether a game still needs postgame wakes through SQLite.""" + + return self._repository.has_open_lock_in_watch(game_id, now) + + async def load_acknowledged_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """Load acknowledged opportunity evidence through SQLite.""" + + return self._repository.load_acknowledged_lock_in_opportunities( + league_id, + fantasy_week, + ) diff --git a/src/sleeper_manager/persistence/base.py b/src/sleeper_manager/persistence/base.py index 804be4a..2106e24 100644 --- a/src/sleeper_manager/persistence/base.py +++ b/src/sleeper_manager/persistence/base.py @@ -13,6 +13,7 @@ from sleeper_manager.domain.nba import DataQualityState from sleeper_manager.domain.planning import AcknowledgedDecisionEvidence +from sleeper_manager.persistence.lock_in_opportunities import AsyncLockInOpportunityRepository DEFAULT_SCHEDULED_WORK_LEASE = timedelta(minutes=15) PROJECTION_OBSERVATION_PAGE_SIZE = 1000 @@ -91,6 +92,7 @@ class DueWorkKind(StrEnum): DAILY = "daily" PRE_TIPOFF = "pre_tipoff" DELIVERY_RETRY = "delivery_retry" + POSTGAME = "postgame" class ScheduledWorkStatus(StrEnum): @@ -375,7 +377,12 @@ async def list_pending_recommendations( async def supersede_recommendation(self, recommendation_id: str, now: datetime) -> bool: ... -class AsyncRuntimeStateRepository(AsyncStateRepository, AsyncNBADataCache, Protocol): +class AsyncRuntimeStateRepository( + AsyncStateRepository, + AsyncNBADataCache, + AsyncLockInOpportunityRepository, + Protocol, +): """Worker runtime store: recommendations, NBA cache, policy, history, and scheduled work.""" async def load_runtime_policy(self) -> RuntimePolicyRecord | None: ... diff --git a/src/sleeper_manager/persistence/d1.py b/src/sleeper_manager/persistence/d1.py index f87565e..8d4b137 100644 --- a/src/sleeper_manager/persistence/d1.py +++ b/src/sleeper_manager/persistence/d1.py @@ -38,6 +38,8 @@ ScheduledWorkRecord, ScheduledWorkStatus, ) +from sleeper_manager.persistence.lock_in_d1 import D1LockInOpportunityMixin +from sleeper_manager.persistence.lock_in_statements import CONSUME_LOCK_IN_OPPORTUNITY_SQL from sleeper_manager.persistence.rows import ( acknowledgement_id, cached_nba_as_of, @@ -116,7 +118,7 @@ def _d1_field(original: object, payload: object, name: str) -> object: return _MISSING -class D1StateRepository(AsyncRuntimeStateRepository): +class D1StateRepository(D1LockInOpportunityMixin, AsyncRuntimeStateRepository): """Async repository backed by a Cloudflare D1 binding.""" def __init__(self, database: Any) -> None: @@ -338,6 +340,16 @@ async def consume_action_token( AcknowledgementAction.LOCKED.value, ), ), + self._statement( + CONSUME_LOCK_IN_OPPORTUNITY_SQL, + ( + action.value, + action.value, + acknowledged_at.isoformat(), + acknowledged_at.isoformat(), + recommendation_id, + ), + ), ] results = await self._database.batch(statements) if isinstance(results, list) and results and self._changes(results[0]) == 1: diff --git a/src/sleeper_manager/persistence/lock_in_d1.py b/src/sleeper_manager/persistence/lock_in_d1.py new file mode 100644 index 0000000..040451a --- /dev/null +++ b/src/sleeper_manager/persistence/lock_in_d1.py @@ -0,0 +1,164 @@ +"""D1 implementation of guarded live Lock-In opportunity persistence.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from typing import Any + +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityKey, + LockInOpportunityRecord, + lock_in_key_values, + lock_in_observation_values, + lock_in_opportunity_from_mapping, + lock_in_opportunity_update_values, + lock_in_opportunity_values, +) +from sleeper_manager.persistence.lock_in_statements import ( + EXPIRE_LOCK_IN_OPPORTUNITIES_SQL, + HAS_OPEN_LOCK_IN_WATCH_SQL, + INSERT_LOCK_IN_OPPORTUNITY_SQL, + LIST_ACKNOWLEDGED_LOCK_IN_OPPORTUNITIES_SQL, + LIST_ACTIONABLE_LOCK_IN_OPPORTUNITIES_SQL, + LIST_DUE_LOCK_IN_OPPORTUNITIES_SQL, + LOAD_LOCK_IN_OPPORTUNITY_SQL, + RECORD_LOCK_IN_OBSERVATION_SQL, + UPDATE_LOCK_IN_OPPORTUNITY_SQL, +) + + +class D1LockInOpportunityMixin: + """Add Lock-In opportunity operations to a D1 repository.""" + + async def _first(self, query: str, *params: object) -> dict[str, Any] | None: + """Require the concrete repository's single-row D1 helper.""" + + raise NotImplementedError + + async def _all(self, query: str, *params: object) -> Sequence[object]: + """Require the concrete repository's multi-row D1 helper.""" + + raise NotImplementedError + + async def _run(self, query: str, *params: object) -> Any: + """Require the concrete repository's mutation D1 helper.""" + + raise NotImplementedError + + @staticmethod + def _changes(result: Any) -> int: + """Require the concrete repository's D1 change-count decoder.""" + + raise NotImplementedError + + @staticmethod + def _mapping(row: object) -> Mapping[str, Any]: + """Require the concrete repository's D1 row decoder.""" + + raise NotImplementedError + + async def upsert_lock_in_opportunity(self, record: LockInOpportunityRecord) -> bool: + """Insert one opportunity without overwriting a concurrently advanced row.""" + + result = await self._run( + INSERT_LOCK_IN_OPPORTUNITY_SQL, + *lock_in_opportunity_values(record), + ) + return self._changes(result) == 1 + + async def get_lock_in_opportunity( + self, key: LockInOpportunityKey + ) -> LockInOpportunityRecord | None: + """Load one opportunity by its complete player-game identity.""" + + row = await self._first(LOAD_LOCK_IN_OPPORTUNITY_SQL, *lock_in_key_values(key)) + return lock_in_opportunity_from_mapping(row) if row is not None else None + + async def record_lock_in_observation( + self, + key: LockInOpportunityKey, + observation: LockInObservation, + *, + expected_version: int, + ) -> LockInOpportunityRecord | None: + """Apply one distinct direct poll and stabilize after two matching fingerprints.""" + + rows = await self._all( + RECORD_LOCK_IN_OBSERVATION_SQL, + *lock_in_observation_values(key, observation, expected_version), + ) + return lock_in_opportunity_from_mapping(self._mapping(rows[0])) if rows else None + + async def update_lock_in_opportunity( + self, + record: LockInOpportunityRecord, + *, + expected_version: int, + ) -> bool: + """Replace mutable opportunity evidence under compare-and-swap protection.""" + + result = await self._run( + UPDATE_LOCK_IN_OPPORTUNITY_SQL, + *lock_in_opportunity_update_values(record, expected_version), + ) + return self._changes(result) == 1 + + async def list_due_lock_in_opportunities( + self, now: datetime, *, limit: int = 100 + ) -> tuple[LockInOpportunityRecord, ...]: + """List open opportunities ready for the current scheduled wake.""" + + if limit <= 0: + return () + rows = await self._all( + LIST_DUE_LOCK_IN_OPPORTUNITIES_SQL, + now.isoformat(), + now.isoformat(), + limit, + ) + return tuple(lock_in_opportunity_from_mapping(self._mapping(row)) for row in rows) + + async def list_actionable_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """List current actionable recommendations in deadline order.""" + + rows = await self._all( + LIST_ACTIONABLE_LOCK_IN_OPPORTUNITIES_SQL, + league_id, + fantasy_week, + ) + return tuple(lock_in_opportunity_from_mapping(self._mapping(row)) for row in rows) + + async def expire_lock_in_opportunities(self, now: datetime) -> int: + """Expire every unacknowledged opportunity whose safe deadline elapsed.""" + + result = await self._run( + EXPIRE_LOCK_IN_OPPORTUNITIES_SQL, + now.isoformat(), + now.isoformat(), + ) + return self._changes(result) + + async def has_open_lock_in_watch(self, game_id: str, now: datetime) -> bool: + """Report whether any player for this game still needs a five-minute watch.""" + + row = await self._first(HAS_OPEN_LOCK_IN_WATCH_SQL, game_id, now.isoformat()) + return row is not None + + async def load_acknowledged_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """Load locked, passed, and automatic-final evidence for later planning.""" + + rows = await self._all( + LIST_ACKNOWLEDGED_LOCK_IN_OPPORTUNITIES_SQL, + league_id, + fantasy_week, + ) + return tuple(lock_in_opportunity_from_mapping(self._mapping(row)) for row in rows) + + +__all__ = ["D1LockInOpportunityMixin"] diff --git a/src/sleeper_manager/persistence/lock_in_opportunities.py b/src/sleeper_manager/persistence/lock_in_opportunities.py new file mode 100644 index 0000000..6759f0d --- /dev/null +++ b/src/sleeper_manager/persistence/lock_in_opportunities.py @@ -0,0 +1,322 @@ +"""Durable live Lock-In opportunity records shared by SQLite and D1.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Protocol + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus + + +@dataclass(frozen=True, slots=True) +class LockInOpportunityKey: + """Identify one rostered player-game opportunity within a fantasy week.""" + + league_id: str + fantasy_week: int + roster_id: int + player_id: str + game_id: str + + +@dataclass(frozen=True, slots=True) +class LockInObservation: + """Describe one direct provider poll used for score stabilization.""" + + score: float + fingerprint: str + poll_id: str + observed_at: datetime + next_check_at: datetime + + +@dataclass(frozen=True, slots=True) +class LockInOpportunityRecord: + """Store the current lifecycle and evidence for one live opportunity.""" + + key: LockInOpportunityKey + provider_player_id: str + scheduled_start: datetime + action_deadline: datetime + fantasy_week_end: datetime + status: LockInOpportunityStatus + next_check_at: datetime + created_at: datetime + updated_at: datetime + slot_index: int | None = None + slot_position: str | None = None + eligible_positions: tuple[str, ...] = () + rostered_at_tipoff: bool | None = None + roster_evidence_at: datetime | None = None + league_configuration_fingerprint: str | None = None + current_observed_score: float | None = None + current_observation_fingerprint: str | None = None + consecutive_direct_poll_count: int = 0 + current_poll_id: str | None = None + previous_observed_at: datetime | None = None + current_observed_at: datetime | None = None + stable_score: float | None = None + stable_fingerprint: str | None = None + stabilized_at: datetime | None = None + score_revision: int = 0 + current_recommendation_id: str | None = None + current_recommendation_kind: str | None = None + acknowledged_action: str | None = None + acknowledged_at: datetime | None = None + latest_evaluation_hash: str | None = None + trace_json: str = "{}" + row_version: int = 0 + + +class LockInOpportunityRepository(Protocol): + """Synchronous guarded persistence surface for live Lock-In state.""" + + def upsert_lock_in_opportunity(self, record: LockInOpportunityRecord) -> bool: ... + + def get_lock_in_opportunity( + self, key: LockInOpportunityKey + ) -> LockInOpportunityRecord | None: ... + + def record_lock_in_observation( + self, + key: LockInOpportunityKey, + observation: LockInObservation, + *, + expected_version: int, + ) -> LockInOpportunityRecord | None: ... + + def update_lock_in_opportunity( + self, + record: LockInOpportunityRecord, + *, + expected_version: int, + ) -> bool: ... + + def list_due_lock_in_opportunities( + self, now: datetime, *, limit: int = 100 + ) -> tuple[LockInOpportunityRecord, ...]: ... + + def list_actionable_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: ... + + def expire_lock_in_opportunities(self, now: datetime) -> int: ... + + def has_open_lock_in_watch(self, game_id: str, now: datetime) -> bool: ... + + def load_acknowledged_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: ... + + +class AsyncLockInOpportunityRepository(Protocol): + """Async guarded persistence surface used by the Worker runtime.""" + + async def upsert_lock_in_opportunity(self, record: LockInOpportunityRecord) -> bool: ... + + async def get_lock_in_opportunity( + self, key: LockInOpportunityKey + ) -> LockInOpportunityRecord | None: ... + + async def record_lock_in_observation( + self, + key: LockInOpportunityKey, + observation: LockInObservation, + *, + expected_version: int, + ) -> LockInOpportunityRecord | None: ... + + async def update_lock_in_opportunity( + self, + record: LockInOpportunityRecord, + *, + expected_version: int, + ) -> bool: ... + + async def list_due_lock_in_opportunities( + self, now: datetime, *, limit: int = 100 + ) -> tuple[LockInOpportunityRecord, ...]: ... + + async def list_actionable_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: ... + + async def expire_lock_in_opportunities(self, now: datetime) -> int: ... + + async def has_open_lock_in_watch(self, game_id: str, now: datetime) -> bool: ... + + async def load_acknowledged_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: ... + + +def lock_in_opportunity_values(record: LockInOpportunityRecord) -> tuple[object, ...]: + """Encode one opportunity in table column order.""" + + key = record.key + return ( + key.league_id, + key.fantasy_week, + key.roster_id, + key.player_id, + key.game_id, + record.provider_player_id, + record.scheduled_start.isoformat(), + record.action_deadline.isoformat(), + record.fantasy_week_end.isoformat(), + record.status.value, + record.next_check_at.isoformat(), + record.slot_index, + record.slot_position, + json.dumps(record.eligible_positions, separators=(",", ":")), + None if record.rostered_at_tipoff is None else int(record.rostered_at_tipoff), + _datetime_value(record.roster_evidence_at), + record.league_configuration_fingerprint, + record.current_observed_score, + record.current_observation_fingerprint, + record.consecutive_direct_poll_count, + record.current_poll_id, + _datetime_value(record.previous_observed_at), + _datetime_value(record.current_observed_at), + record.stable_score, + record.stable_fingerprint, + _datetime_value(record.stabilized_at), + record.score_revision, + record.current_recommendation_id, + record.current_recommendation_kind, + record.acknowledged_action, + _datetime_value(record.acknowledged_at), + record.latest_evaluation_hash, + record.trace_json, + record.row_version, + record.created_at.isoformat(), + record.updated_at.isoformat(), + ) + + +def lock_in_opportunity_update_values( + record: LockInOpportunityRecord, expected_version: int +) -> tuple[object, ...]: + """Encode a full guarded update without replacing immutable identity or creation time.""" + + values = lock_in_opportunity_values(record) + return values[5:33] + (values[35],) + values[:5] + (expected_version,) + + +def lock_in_opportunity_from_mapping(row: Mapping[str, Any]) -> LockInOpportunityRecord: + """Decode a SQLite or D1 mapping into the shared opportunity record.""" + + return LockInOpportunityRecord( + key=LockInOpportunityKey( + league_id=str(row["league_id"]), + fantasy_week=int(row["fantasy_week"]), + roster_id=int(row["roster_id"]), + player_id=str(row["player_id"]), + game_id=str(row["game_id"]), + ), + provider_player_id=str(row["provider_player_id"]), + scheduled_start=datetime.fromisoformat(str(row["scheduled_start"])), + action_deadline=datetime.fromisoformat(str(row["action_deadline"])), + fantasy_week_end=datetime.fromisoformat(str(row["fantasy_week_end"])), + status=LockInOpportunityStatus(str(row["status"])), + next_check_at=datetime.fromisoformat(str(row["next_check_at"])), + created_at=datetime.fromisoformat(str(row["created_at"])), + updated_at=datetime.fromisoformat(str(row["updated_at"])), + slot_index=_optional_int(row.get("slot_index")), + slot_position=_optional_str(row.get("slot_position")), + eligible_positions=tuple(json.loads(str(row["eligible_positions_json"]))), + rostered_at_tipoff=_optional_bool(row.get("rostered_at_tipoff")), + roster_evidence_at=_optional_datetime(row.get("roster_evidence_at")), + league_configuration_fingerprint=_optional_str(row.get("league_configuration_fingerprint")), + current_observed_score=_optional_float(row.get("current_observed_score")), + current_observation_fingerprint=_optional_str(row.get("current_observation_fingerprint")), + consecutive_direct_poll_count=int(row["consecutive_direct_poll_count"]), + current_poll_id=_optional_str(row.get("current_poll_id")), + previous_observed_at=_optional_datetime(row.get("previous_observed_at")), + current_observed_at=_optional_datetime(row.get("current_observed_at")), + stable_score=_optional_float(row.get("stable_score")), + stable_fingerprint=_optional_str(row.get("stable_fingerprint")), + stabilized_at=_optional_datetime(row.get("stabilized_at")), + score_revision=int(row["score_revision"]), + current_recommendation_id=_optional_str(row.get("current_recommendation_id")), + current_recommendation_kind=_optional_str(row.get("current_recommendation_kind")), + acknowledged_action=_optional_str(row.get("acknowledged_action")), + acknowledged_at=_optional_datetime(row.get("acknowledged_at")), + latest_evaluation_hash=_optional_str(row.get("latest_evaluation_hash")), + trace_json=str(row["trace_json"]), + row_version=int(row["row_version"]), + ) + + +def lock_in_key_values(key: LockInOpportunityKey) -> tuple[object, ...]: + """Encode an opportunity primary key for shared queries.""" + + return (key.league_id, key.fantasy_week, key.roster_id, key.player_id, key.game_id) + + +def lock_in_observation_values( + key: LockInOpportunityKey, + observation: LockInObservation, + expected_version: int, +) -> tuple[object, ...]: + """Encode a guarded observation and its repeated stabilization comparisons.""" + + return ( + observation.score, + observation.fingerprint, + observation.fingerprint, + observation.poll_id, + observation.observed_at.isoformat(), + observation.next_check_at.isoformat(), + observation.fingerprint, + observation.score, + observation.fingerprint, + observation.fingerprint, + observation.fingerprint, + observation.observed_at.isoformat(), + observation.fingerprint, + observation.fingerprint, + observation.observed_at.isoformat(), + *lock_in_key_values(key), + expected_version, + observation.poll_id, + ) + + +def _datetime_value(value: datetime | None) -> str | None: + """Encode an optional timestamp using the repository ISO convention.""" + + return value.isoformat() if value is not None else None + + +def _optional_datetime(value: object) -> datetime | None: + """Decode an optional timestamp from SQLite or D1.""" + + return datetime.fromisoformat(str(value)) if value is not None else None + + +def _optional_str(value: object) -> str | None: + """Decode an optional text field.""" + + return str(value) if value is not None else None + + +def _optional_int(value: object) -> int | None: + """Decode an optional integer field.""" + + return int(str(value)) if value is not None else None + + +def _optional_float(value: object) -> float | None: + """Decode an optional numeric field.""" + + return float(str(value)) if value is not None else None + + +def _optional_bool(value: object) -> bool | None: + """Decode a nullable SQLite boolean.""" + + return bool(value) if value is not None else None diff --git a/src/sleeper_manager/persistence/lock_in_sqlite.py b/src/sleeper_manager/persistence/lock_in_sqlite.py new file mode 100644 index 0000000..71a8a4f --- /dev/null +++ b/src/sleeper_manager/persistence/lock_in_sqlite.py @@ -0,0 +1,166 @@ +"""SQLite implementation of guarded live Lock-In opportunity persistence.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterator +from contextlib import AbstractContextManager +from datetime import datetime +from typing import Protocol + +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityKey, + LockInOpportunityRecord, + lock_in_key_values, + lock_in_observation_values, + lock_in_opportunity_from_mapping, + lock_in_opportunity_update_values, + lock_in_opportunity_values, +) +from sleeper_manager.persistence.lock_in_statements import ( + EXPIRE_LOCK_IN_OPPORTUNITIES_SQL, + HAS_OPEN_LOCK_IN_WATCH_SQL, + INSERT_LOCK_IN_OPPORTUNITY_SQL, + LIST_ACKNOWLEDGED_LOCK_IN_OPPORTUNITIES_SQL, + LIST_ACTIONABLE_LOCK_IN_OPPORTUNITIES_SQL, + LIST_DUE_LOCK_IN_OPPORTUNITIES_SQL, + LOAD_LOCK_IN_OPPORTUNITY_SQL, + RECORD_LOCK_IN_OBSERVATION_SQL, + UPDATE_LOCK_IN_OPPORTUNITY_SQL, +) + + +class _SQLiteConnectionOwner(Protocol): + """Describe the connection hook supplied by the concrete repository.""" + + def _connect(self) -> AbstractContextManager[sqlite3.Connection]: ... + + +class SQLiteLockInOpportunityMixin: + """Add Lock-In opportunity operations to a SQLite repository.""" + + def _connect(self) -> AbstractContextManager[sqlite3.Connection]: + """Require the concrete repository to provide a configured connection.""" + + raise NotImplementedError + + @staticmethod + def _records( + rows: Iterator[sqlite3.Row] | list[sqlite3.Row], + ) -> tuple[LockInOpportunityRecord, ...]: + """Decode SQLite rows into immutable opportunity records.""" + + return tuple(lock_in_opportunity_from_mapping(dict(row)) for row in rows) + + def upsert_lock_in_opportunity(self, record: LockInOpportunityRecord) -> bool: + """Insert one opportunity without overwriting a concurrently advanced row.""" + + with self._connect() as connection: + cursor = connection.execute( + INSERT_LOCK_IN_OPPORTUNITY_SQL, + lock_in_opportunity_values(record), + ) + return cursor.rowcount == 1 + + def get_lock_in_opportunity(self, key: LockInOpportunityKey) -> LockInOpportunityRecord | None: + """Load one opportunity by its complete player-game identity.""" + + with self._connect() as connection: + row = connection.execute( + LOAD_LOCK_IN_OPPORTUNITY_SQL, + lock_in_key_values(key), + ).fetchone() + return lock_in_opportunity_from_mapping(dict(row)) if row is not None else None + + def record_lock_in_observation( + self, + key: LockInOpportunityKey, + observation: LockInObservation, + *, + expected_version: int, + ) -> LockInOpportunityRecord | None: + """Apply one distinct direct poll and stabilize after two matching fingerprints.""" + + with self._connect() as connection: + row = connection.execute( + RECORD_LOCK_IN_OBSERVATION_SQL, + lock_in_observation_values(key, observation, expected_version), + ).fetchone() + return lock_in_opportunity_from_mapping(dict(row)) if row is not None else None + + def update_lock_in_opportunity( + self, + record: LockInOpportunityRecord, + *, + expected_version: int, + ) -> bool: + """Replace mutable opportunity evidence under compare-and-swap protection.""" + + with self._connect() as connection: + cursor = connection.execute( + UPDATE_LOCK_IN_OPPORTUNITY_SQL, + lock_in_opportunity_update_values(record, expected_version), + ) + return cursor.rowcount == 1 + + def list_due_lock_in_opportunities( + self, now: datetime, *, limit: int = 100 + ) -> tuple[LockInOpportunityRecord, ...]: + """List open opportunities ready for the current scheduled wake.""" + + if limit <= 0: + return () + with self._connect() as connection: + rows = connection.execute( + LIST_DUE_LOCK_IN_OPPORTUNITIES_SQL, + (now.isoformat(), now.isoformat(), limit), + ).fetchall() + return self._records(iter(rows)) + + def list_actionable_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """List current actionable recommendations in deadline order.""" + + with self._connect() as connection: + rows = connection.execute( + LIST_ACTIONABLE_LOCK_IN_OPPORTUNITIES_SQL, + (league_id, fantasy_week), + ).fetchall() + return self._records(iter(rows)) + + def expire_lock_in_opportunities(self, now: datetime) -> int: + """Expire every unacknowledged opportunity whose safe deadline elapsed.""" + + with self._connect() as connection: + cursor = connection.execute( + EXPIRE_LOCK_IN_OPPORTUNITIES_SQL, + (now.isoformat(), now.isoformat()), + ) + return cursor.rowcount + + def has_open_lock_in_watch(self, game_id: str, now: datetime) -> bool: + """Report whether any player for this game still needs a five-minute watch.""" + + with self._connect() as connection: + row = connection.execute( + HAS_OPEN_LOCK_IN_WATCH_SQL, + (game_id, now.isoformat()), + ).fetchone() + return row is not None + + def load_acknowledged_lock_in_opportunities( + self, league_id: str, fantasy_week: int + ) -> tuple[LockInOpportunityRecord, ...]: + """Load locked, passed, and automatic-final evidence for later planning.""" + + with self._connect() as connection: + rows = connection.execute( + LIST_ACKNOWLEDGED_LOCK_IN_OPPORTUNITIES_SQL, + (league_id, fantasy_week), + ).fetchall() + return self._records(iter(rows)) + + +__all__ = ["SQLiteLockInOpportunityMixin"] diff --git a/src/sleeper_manager/persistence/lock_in_statements.py b/src/sleeper_manager/persistence/lock_in_statements.py new file mode 100644 index 0000000..94719be --- /dev/null +++ b/src/sleeper_manager/persistence/lock_in_statements.py @@ -0,0 +1,162 @@ +"""Schema and guarded SQL for the live Lock-In opportunity table.""" + +LOCK_IN_OPPORTUNITY_SCHEMA = """ +CREATE TABLE IF NOT EXISTS lock_in_opportunities ( + league_id TEXT NOT NULL, + fantasy_week INTEGER NOT NULL, + roster_id INTEGER NOT NULL, + player_id TEXT NOT NULL, + game_id TEXT NOT NULL, + provider_player_id TEXT NOT NULL, + scheduled_start TEXT NOT NULL, + action_deadline TEXT NOT NULL, + fantasy_week_end TEXT NOT NULL, + status TEXT NOT NULL, + next_check_at TEXT NOT NULL, + slot_index INTEGER, + slot_position TEXT, + eligible_positions_json TEXT NOT NULL, + rostered_at_tipoff INTEGER, + roster_evidence_at TEXT, + league_configuration_fingerprint TEXT, + current_observed_score REAL, + current_observation_fingerprint TEXT, + consecutive_direct_poll_count INTEGER NOT NULL DEFAULT 0, + current_poll_id TEXT, + previous_observed_at TEXT, + current_observed_at TEXT, + stable_score REAL, + stable_fingerprint TEXT, + stabilized_at TEXT, + score_revision INTEGER NOT NULL DEFAULT 0, + current_recommendation_id TEXT, + current_recommendation_kind TEXT, + acknowledged_action TEXT, + acknowledged_at TEXT, + latest_evaluation_hash TEXT, + trace_json TEXT NOT NULL DEFAULT '{}', + row_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (league_id, fantasy_week, roster_id, player_id, game_id), + FOREIGN KEY (current_recommendation_id) REFERENCES recommendations(recommendation_id) +); +CREATE INDEX IF NOT EXISTS lock_in_opportunities_due_idx +ON lock_in_opportunities (status, next_check_at, action_deadline); +CREATE INDEX IF NOT EXISTS lock_in_opportunities_ack_idx +ON lock_in_opportunities (league_id, fantasy_week, status, roster_id); +""" + +LOCK_IN_OPPORTUNITY_COLUMNS = """ +league_id, fantasy_week, roster_id, player_id, game_id, +provider_player_id, scheduled_start, action_deadline, fantasy_week_end, +status, next_check_at, slot_index, slot_position, eligible_positions_json, +rostered_at_tipoff, roster_evidence_at, league_configuration_fingerprint, +current_observed_score, current_observation_fingerprint, +consecutive_direct_poll_count, current_poll_id, previous_observed_at, +current_observed_at, stable_score, stable_fingerprint, stabilized_at, +score_revision, current_recommendation_id, current_recommendation_kind, +acknowledged_action, acknowledged_at, latest_evaluation_hash, trace_json, +row_version, created_at, updated_at +""" + +INSERT_LOCK_IN_OPPORTUNITY_SQL = f""" +INSERT OR IGNORE INTO lock_in_opportunities ({LOCK_IN_OPPORTUNITY_COLUMNS}) +VALUES ({", ".join("?" for _ in range(36))}) +""" + +UPDATE_LOCK_IN_OPPORTUNITY_SQL = """ +UPDATE lock_in_opportunities SET + provider_player_id = ?, scheduled_start = ?, action_deadline = ?, fantasy_week_end = ?, + status = ?, next_check_at = ?, slot_index = ?, slot_position = ?, + eligible_positions_json = ?, rostered_at_tipoff = ?, roster_evidence_at = ?, + league_configuration_fingerprint = ?, current_observed_score = ?, + current_observation_fingerprint = ?, consecutive_direct_poll_count = ?, + current_poll_id = ?, previous_observed_at = ?, current_observed_at = ?, + stable_score = ?, stable_fingerprint = ?, stabilized_at = ?, score_revision = ?, + current_recommendation_id = ?, current_recommendation_kind = ?, + acknowledged_action = ?, acknowledged_at = ?, latest_evaluation_hash = ?, + trace_json = ?, row_version = row_version + 1, updated_at = ? +WHERE league_id = ? AND fantasy_week = ? AND roster_id = ? AND player_id = ? + AND game_id = ? AND row_version = ? +""" + +LOAD_LOCK_IN_OPPORTUNITY_SQL = f""" +SELECT {LOCK_IN_OPPORTUNITY_COLUMNS} FROM lock_in_opportunities +WHERE league_id = ? AND fantasy_week = ? AND roster_id = ? AND player_id = ? AND game_id = ? +""" + +RECORD_LOCK_IN_OBSERVATION_SQL = f""" +UPDATE lock_in_opportunities SET + status = CASE + WHEN status IN ('acknowledged_locked', 'acknowledged_passed') THEN status + ELSE 'finalizing' + END, + previous_observed_at = current_observed_at, + current_observed_score = ?, current_observation_fingerprint = ?, + consecutive_direct_poll_count = CASE + WHEN current_observation_fingerprint = ? THEN consecutive_direct_poll_count + 1 ELSE 1 + END, + current_poll_id = ?, current_observed_at = ?, next_check_at = ?, + stable_score = CASE + WHEN current_observation_fingerprint = ? AND consecutive_direct_poll_count >= 1 + THEN ? ELSE stable_score END, + stable_fingerprint = CASE + WHEN current_observation_fingerprint = ? AND consecutive_direct_poll_count >= 1 + THEN ? ELSE stable_fingerprint END, + stabilized_at = CASE + WHEN current_observation_fingerprint = ? AND consecutive_direct_poll_count >= 1 + THEN ? ELSE stabilized_at END, + score_revision = CASE + WHEN current_observation_fingerprint = ? AND consecutive_direct_poll_count >= 1 + AND (stable_fingerprint IS NULL OR stable_fingerprint != ?) + THEN score_revision + 1 ELSE score_revision END, + row_version = row_version + 1, updated_at = ? +WHERE league_id = ? AND fantasy_week = ? AND roster_id = ? AND player_id = ? + AND game_id = ? AND row_version = ? AND (current_poll_id IS NULL OR current_poll_id != ?) +RETURNING {LOCK_IN_OPPORTUNITY_COLUMNS} +""" + +LIST_DUE_LOCK_IN_OPPORTUNITIES_SQL = f""" +SELECT {LOCK_IN_OPPORTUNITY_COLUMNS} FROM lock_in_opportunities +WHERE next_check_at <= ? AND action_deadline > ? + AND status IN ('scheduled', 'active', 'finalizing', 'actionable', 'reconciliation_required') +ORDER BY next_check_at, league_id, fantasy_week, roster_id, player_id, game_id +LIMIT ? +""" + +LIST_ACTIONABLE_LOCK_IN_OPPORTUNITIES_SQL = f""" +SELECT {LOCK_IN_OPPORTUNITY_COLUMNS} FROM lock_in_opportunities +WHERE league_id = ? AND fantasy_week = ? AND status = 'actionable' +ORDER BY action_deadline, roster_id, player_id, game_id +""" + +EXPIRE_LOCK_IN_OPPORTUNITIES_SQL = """ +UPDATE lock_in_opportunities SET status = 'expired', row_version = row_version + 1, updated_at = ? +WHERE action_deadline <= ? + AND status IN ('scheduled', 'active', 'finalizing', 'actionable', 'reconciliation_required') +""" + +LIST_ACKNOWLEDGED_LOCK_IN_OPPORTUNITIES_SQL = f""" +SELECT {LOCK_IN_OPPORTUNITY_COLUMNS} FROM lock_in_opportunities +WHERE league_id = ? AND fantasy_week = ? + AND status IN ('acknowledged_locked', 'acknowledged_passed', 'automatic_final') +ORDER BY roster_id, player_id, scheduled_start, game_id +""" + +HAS_OPEN_LOCK_IN_WATCH_SQL = """ +SELECT 1 FROM lock_in_opportunities +WHERE game_id = ? AND action_deadline > ? + AND status IN ('scheduled', 'active', 'finalizing', 'actionable', 'reconciliation_required') +LIMIT 1 +""" + +CONSUME_LOCK_IN_OPPORTUNITY_SQL = """ +UPDATE lock_in_opportunities SET + status = CASE WHEN ? = 'locked' THEN 'acknowledged_locked' ELSE 'acknowledged_passed' END, + acknowledged_action = ?, + acknowledged_at = ?, + updated_at = ?, + row_version = row_version + 1 +WHERE current_recommendation_id = ? AND status = 'actionable' +""" diff --git a/src/sleeper_manager/persistence/sqlite.py b/src/sleeper_manager/persistence/sqlite.py index ee9714e..41907ba 100644 --- a/src/sleeper_manager/persistence/sqlite.py +++ b/src/sleeper_manager/persistence/sqlite.py @@ -38,6 +38,8 @@ ScheduledWorkStatus, StoredLeagueProfile, ) +from sleeper_manager.persistence.lock_in_sqlite import SQLiteLockInOpportunityMixin +from sleeper_manager.persistence.lock_in_statements import CONSUME_LOCK_IN_OPPORTUNITY_SQL from sleeper_manager.persistence.rows import ( acknowledgement_id, cached_nba_as_of, @@ -105,7 +107,7 @@ def _mapping(row: sqlite3.Row | None) -> dict[str, Any] | None: return {str(key): row[key] for key in row.keys()} -class SQLiteStateRepository: +class SQLiteStateRepository(SQLiteLockInOpportunityMixin): """Local file-backed `StateRepository` plus runtime tables and league profiles.""" def __init__(self, path: Path) -> None: @@ -352,6 +354,16 @@ def consume_action_token( acknowledged_at.isoformat(), ), ) + connection.execute( + CONSUME_LOCK_IN_OPPORTUNITY_SQL, + ( + action.value, + action.value, + acknowledged_at.isoformat(), + acknowledged_at.isoformat(), + recommendation.recommendation_id, + ), + ) updated = _mapping( connection.execute( LOAD_RECOMMENDATION_SQL, (recommendation.recommendation_id,) diff --git a/src/sleeper_manager/persistence/statements.py b/src/sleeper_manager/persistence/statements.py index bc8317b..bfaf9b9 100644 --- a/src/sleeper_manager/persistence/statements.py +++ b/src/sleeper_manager/persistence/statements.py @@ -10,6 +10,7 @@ from datetime import datetime from sleeper_manager.persistence.base import DueWorkKind, ScheduledWorkStatus +from sleeper_manager.persistence.lock_in_statements import LOCK_IN_OPPORTUNITY_SCHEMA D1_SCHEMA = """ PRAGMA foreign_keys = ON; @@ -145,7 +146,7 @@ CREATE TABLE IF NOT EXISTS scheduled_work ( work_id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, - kind TEXT NOT NULL CHECK (kind IN ('daily', 'pre_tipoff', 'delivery_retry')), + kind TEXT NOT NULL CHECK (kind IN ('daily', 'pre_tipoff', 'delivery_retry', 'postgame')), due_at TEXT NOT NULL, status TEXT NOT NULL CHECK ( status IN ('pending', 'running', 'retry', 'completed', 'canceled') @@ -167,6 +168,7 @@ CREATE INDEX IF NOT EXISTS scheduled_work_due_idx ON scheduled_work (status, due_at, lease_expires_at); """ +D1_SCHEMA += LOCK_IN_OPPORTUNITY_SCHEMA SQLITE_CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS lock_acknowledgements ( @@ -320,6 +322,7 @@ CREATE INDEX IF NOT EXISTS scheduled_work_due_idx ON scheduled_work (status, due_at, lease_expires_at); """ +SQLITE_RUNTIME_SCHEMA += LOCK_IN_OPPORTUNITY_SCHEMA UPSERT_LEAGUE_SNAPSHOT_SQL = """ INSERT INTO league_snapshots ( diff --git a/src/sleeper_manager/workflows/lock_in_evidence.py b/src/sleeper_manager/workflows/lock_in_evidence.py new file mode 100644 index 0000000..5c575d7 --- /dev/null +++ b/src/sleeper_manager/workflows/lock_in_evidence.py @@ -0,0 +1,84 @@ +"""Convert terminal live Lock-In rows into later-planning acknowledgement evidence.""" + +from __future__ import annotations + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.domain.planning import AcknowledgedAction, AcknowledgedDecisionEvidence +from sleeper_manager.persistence.lock_in_opportunities import LockInOpportunityRecord + +LOCK_IN_OPPORTUNITY_PROVENANCE = "lock_in_opportunity_v1" + + +def evidence_from_lock_in_opportunity( + record: LockInOpportunityRecord, +) -> AcknowledgedDecisionEvidence | None: + """Return fixed Lock or Pass evidence for later planning, or None if not terminal.""" + + if record.status is LockInOpportunityStatus.ACKNOWLEDGED_PASSED: + return AcknowledgedDecisionEvidence( + decision_id=_decision_id(record), + player_id=record.key.player_id, + game_id=record.key.game_id, + action=AcknowledgedAction.PASS, + decided_at=record.acknowledged_at or record.updated_at, + provenance=LOCK_IN_OPPORTUNITY_PROVENANCE, + ) + if record.status in { + LockInOpportunityStatus.ACKNOWLEDGED_LOCKED, + LockInOpportunityStatus.AUTOMATIC_FINAL, + }: + reconciled = ( + record.slot_index is not None + and record.slot_position is not None + and record.stable_score is not None + ) + return AcknowledgedDecisionEvidence( + decision_id=_decision_id(record), + player_id=record.key.player_id, + game_id=record.key.game_id, + action=AcknowledgedAction.LOCK, + decided_at=record.acknowledged_at or record.stabilized_at or record.updated_at, + provenance=LOCK_IN_OPPORTUNITY_PROVENANCE, + slot_index=record.slot_index, + slot_position=record.slot_position, + accepted_fantasy_score=record.stable_score, + reconciled=reconciled, + ) + return None + + +def merge_lock_in_opportunity_evidence( + existing: tuple[AcknowledgedDecisionEvidence, ...], + records: tuple[LockInOpportunityRecord, ...], +) -> tuple[AcknowledgedDecisionEvidence, ...]: + """Prefer current opportunity scores over recommendation-trace snapshots.""" + + converted: list[AcknowledgedDecisionEvidence] = [] + keys: set[tuple[str, str]] = set() + for record in records: + evidence = evidence_from_lock_in_opportunity(record) + if evidence is None: + continue + converted.append(evidence) + keys.add((evidence.player_id, evidence.game_id)) + kept = tuple(item for item in existing if (item.player_id, item.game_id) not in keys) + return kept + tuple(converted) + + +def _decision_id(record: LockInOpportunityRecord) -> str: + """Reuse the recommendation identity when present; otherwise name the automatic final.""" + + if record.current_recommendation_id: + return record.current_recommendation_id + key = record.key + return ( + f"automatic-final:{key.league_id}:{key.fantasy_week}:{key.roster_id}:" + f"{key.player_id}:{key.game_id}" + ) + + +__all__ = [ + "LOCK_IN_OPPORTUNITY_PROVENANCE", + "evidence_from_lock_in_opportunity", + "merge_lock_in_opportunity_evidence", +] diff --git a/src/sleeper_manager/workflows/lock_in_planning.py b/src/sleeper_manager/workflows/lock_in_planning.py new file mode 100644 index 0000000..f14dd4c --- /dev/null +++ b/src/sleeper_manager/workflows/lock_in_planning.py @@ -0,0 +1,399 @@ +"""Create live opportunities and coalesced postgame watches from planning evidence.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta +from hashlib import sha256 + +from sleeper_manager.domain.eligibility import eligible_for_slot +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.domain.nba import GameStatus, ScheduledGame +from sleeper_manager.domain.scoring import calculate_fantasy_points +from sleeper_manager.persistence.base import ( + AsyncRuntimeStateRepository, + DueWorkKind, + ScheduledWorkRecord, + ScheduledWorkStatus, +) +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityKey, + LockInOpportunityRecord, +) +from sleeper_manager.workflows.lock_in_evidence import merge_lock_in_opportunity_evidence +from sleeper_manager.workflows.planning_inputs import ( + LivePlanningInputs, + PlayerEligibilityEvidence, + ResolvedPlayerIdentity, +) +from sleeper_manager.workflows.postgame_lock_in import ( + DirectGameSummarySource, + summary_fingerprint, + summary_wait_reason, +) + +_POSTGAME_INITIAL_DELAY = timedelta(hours=2) + + +async def sync_live_lock_in_opportunities( + inputs: LivePlanningInputs, + *, + repository: AsyncRuntimeStateRepository, + observed_at: datetime, +) -> None: + """Persist current roster schedule evidence and one postgame row per NBA game.""" + + profile = inputs.league_profile + roster = next(item for item in profile.rosters if item.roster_id == profile.manager_roster_id) + identities = {item.sleeper_player_id: item for item in inputs.identities} + eligibility = _eligibility_by_player(inputs.player_eligibility) + games = _in_window_games(inputs) + games_by_team = _games_by_team(games) + starters = _starter_evidence(inputs, roster.starter_ids, eligibility) + watched_games: dict[str, tuple[ScheduledGame, datetime]] = {} + for player_id in roster.player_ids: + identity = identities.get(player_id) + if ( + identity is None + or identity.provider_player_id is None + or identity.provider_team_id is None + ): + continue + player_games = tuple(games_by_team.get(identity.provider_team_id, ())) + for index, game in enumerate(player_games): + deadline = _action_deadline(inputs, player_games, index) + candidate = _opportunity_record( + inputs, + player_id=player_id, + identity=identity, + game=game, + deadline=deadline, + starter=starters.get(player_id), + listed_as_starter=player_id in roster.starter_ids, + observed_at=observed_at, + ) + await _guarded_upsert(repository, candidate) + if candidate.status is LockInOpportunityStatus.INELIGIBLE: + continue + previous = watched_games.get(game.provider_id) + watch_deadline = max(deadline, previous[1]) if previous else deadline + watched_games[game.provider_id] = (game, watch_deadline) + for game, deadline in watched_games.values(): + await repository.upsert_scheduled_work( + _postgame_work(game, deadline=deadline, observed_at=observed_at) + ) + + +def _eligibility_by_player( + evidence: tuple[PlayerEligibilityEvidence, ...], +) -> dict[str, PlayerEligibilityEvidence]: + """Retain only unambiguous latest eligibility evidence per player.""" + + result: dict[str, PlayerEligibilityEvidence] = {} + conflicts: set[str] = set() + for item in sorted(evidence, key=lambda value: value.available_as_of): + previous = result.get(item.sleeper_player_id) + if previous is not None and previous.eligible_positions != item.eligible_positions: + conflicts.add(item.sleeper_player_id) + continue + result[item.sleeper_player_id] = item + return {key: value for key, value in result.items() if key not in conflicts} + + +def _in_window_games(inputs: LivePlanningInputs) -> tuple[ScheduledGame, ...]: + """Deduplicate consistent games and exclude records outside the fantasy week.""" + + indexed: dict[str, ScheduledGame] = {} + conflicts: set[str] = set() + for result in inputs.schedule_results: + for game in result.games: + if not inputs.week_window.contains(game.start_time): + continue + previous = indexed.get(game.provider_id) + if previous is not None and _game_facts(previous) != _game_facts(game): + conflicts.add(game.provider_id) + continue + indexed[game.provider_id] = game + return tuple( + sorted( + (game for key, game in indexed.items() if key not in conflicts), + key=lambda game: (game.start_time, game.provider_id), + ) + ) + + +def _game_facts(game: ScheduledGame) -> tuple[object, ...]: + """Return schedule facts whose conflict would make an opportunity unsafe.""" + + return ( + game.start_time, + game.status, + game.home_team_id, + game.away_team_id, + game.finalized_at, + ) + + +def _games_by_team(games: tuple[ScheduledGame, ...]) -> dict[str, tuple[ScheduledGame, ...]]: + """Index each game under both participating provider teams.""" + + indexed: dict[str, list[ScheduledGame]] = {} + for game in games: + indexed.setdefault(game.home_team_id, []).append(game) + indexed.setdefault(game.away_team_id, []).append(game) + return {team: tuple(values) for team, values in indexed.items()} + + +def _starter_evidence( + inputs: LivePlanningInputs, + starter_ids: tuple[str | None, ...], + eligibility: dict[str, PlayerEligibilityEvidence], +) -> dict[str, tuple[int, str, tuple[str, ...], datetime] | None]: + """Map the current Sleeper starter sequence to exact configured slots.""" + + slots = tuple( + sorted( + (slot for slot in inputs.league_profile.roster_slots if slot.is_starting), + key=lambda slot: slot.index, + ) + ) + result: dict[str, tuple[int, str, tuple[str, ...], datetime] | None] = {} + for sequence_index, player_id in enumerate(starter_ids): + if player_id is None or sequence_index >= len(slots): + continue + item = eligibility.get(player_id) + slot = slots[sequence_index] + if item is None or not eligible_for_slot(item.eligible_positions, slot.position): + result[player_id] = None + continue + result[player_id] = ( + slot.index, + slot.position, + item.eligible_positions, + inputs.league_profile.retrieved_at, + ) + return result + + +def _action_deadline( + inputs: LivePlanningInputs, + games: tuple[ScheduledGame, ...], + index: int, +) -> datetime: + """Use the next player-game lead-time boundary capped by fantasy-week end.""" + + if index + 1 >= len(games): + return inputs.week_window.ends_at + return min( + games[index + 1].start_time - inputs.move_lead_time, + inputs.week_window.ends_at, + ) + + +def _opportunity_record( + inputs: LivePlanningInputs, + *, + player_id: str, + identity: ResolvedPlayerIdentity, + game: ScheduledGame, + deadline: datetime, + starter: tuple[int, str, tuple[str, ...], datetime] | None, + listed_as_starter: bool, + observed_at: datetime, +) -> LockInOpportunityRecord: + """Build current schedule state and safe pre-tipoff evidence when available.""" + + snapshot_at = inputs.league_profile.retrieved_at + evidence_precedes_tipoff = snapshot_at <= game.start_time + slot_index, slot_position, positions, evidence_at = ( + starter if evidence_precedes_tipoff and starter is not None else (None, None, (), None) + ) + if evidence_precedes_tipoff: + evidence_at = snapshot_at + rostered = listed_as_starter if evidence_precedes_tipoff else None + status = _initial_status(game) + if evidence_precedes_tipoff and starter is None: + status = LockInOpportunityStatus.INELIGIBLE + return LockInOpportunityRecord( + key=LockInOpportunityKey( + inputs.league_profile.league_id, + inputs.week_window.week, + inputs.league_profile.manager_roster_id, + player_id, + game.provider_id, + ), + provider_player_id=identity.provider_player_id or "", + scheduled_start=game.start_time, + action_deadline=deadline, + fantasy_week_end=inputs.week_window.ends_at, + status=status, + next_check_at=game.start_time + _POSTGAME_INITIAL_DELAY, + created_at=observed_at, + updated_at=observed_at, + slot_index=slot_index, + slot_position=slot_position, + eligible_positions=positions, + rostered_at_tipoff=rostered, + roster_evidence_at=evidence_at, + league_configuration_fingerprint=inputs.league_profile.configuration_fingerprint, + ) + + +def _initial_status(game: ScheduledGame) -> LockInOpportunityStatus: + """Translate provider schedule status without treating a final as stable.""" + + if game.status is GameStatus.IN_PROGRESS: + return LockInOpportunityStatus.ACTIVE + if game.status is GameStatus.FINAL: + return LockInOpportunityStatus.FINALIZING + if game.status in (GameStatus.POSTPONED, GameStatus.CANCELED, GameStatus.UNKNOWN): + return LockInOpportunityStatus.RECONCILIATION_REQUIRED + return LockInOpportunityStatus.SCHEDULED + + +async def _guarded_upsert( + repository: AsyncRuntimeStateRepository, + candidate: LockInOpportunityRecord, +) -> None: + """Insert or merge schedule/evidence without erasing live observations.""" + + if await repository.upsert_lock_in_opportunity(candidate): + return + current = await repository.get_lock_in_opportunity(candidate.key) + if current is None: + return + merged = _merge_planning_evidence(current, candidate) + await repository.update_lock_in_opportunity(merged, expected_version=current.row_version) + + +def _merge_planning_evidence( + current: LockInOpportunityRecord, + candidate: LockInOpportunityRecord, +) -> LockInOpportunityRecord: + """Refresh schedule and newer pre-tipoff evidence while retaining lifecycle progress.""" + + advanced = current.status not in { + LockInOpportunityStatus.SCHEDULED, + LockInOpportunityStatus.ACTIVE, + LockInOpportunityStatus.INELIGIBLE, + LockInOpportunityStatus.RECONCILIATION_REQUIRED, + } + newer_evidence = candidate.roster_evidence_at is not None and ( + current.roster_evidence_at is None + or candidate.roster_evidence_at > current.roster_evidence_at + ) + league_changed = ( + current.league_configuration_fingerprint is not None + and current.league_configuration_fingerprint != candidate.league_configuration_fingerprint + ) + return replace( + current, + provider_player_id=candidate.provider_player_id, + scheduled_start=candidate.scheduled_start, + action_deadline=candidate.action_deadline, + fantasy_week_end=candidate.fantasy_week_end, + status=( + LockInOpportunityStatus.RECONCILIATION_REQUIRED + if league_changed + else current.status + if current.status is LockInOpportunityStatus.INELIGIBLE + and candidate.roster_evidence_at is None + else current.status + if advanced + else candidate.status + ), + next_check_at=(current.next_check_at if advanced else candidate.next_check_at), + slot_index=candidate.slot_index if newer_evidence else current.slot_index, + slot_position=candidate.slot_position if newer_evidence else current.slot_position, + eligible_positions=( + candidate.eligible_positions if newer_evidence else current.eligible_positions + ), + rostered_at_tipoff=( + candidate.rostered_at_tipoff if newer_evidence else current.rostered_at_tipoff + ), + roster_evidence_at=( + candidate.roster_evidence_at if newer_evidence else current.roster_evidence_at + ), + league_configuration_fingerprint=candidate.league_configuration_fingerprint, + updated_at=candidate.updated_at, + ) + + +def _postgame_work( + game: ScheduledGame, + *, + deadline: datetime, + observed_at: datetime, +) -> ScheduledWorkRecord: + """Build the single game-level watch shared by every relevant roster player.""" + + key = f"postgame:{game.provider_id}" + return ScheduledWorkRecord( + work_id=sha256(key.encode()).hexdigest()[:32], + dedupe_key=key, + kind=DueWorkKind.POSTGAME, + due_at=game.start_time + _POSTGAME_INITIAL_DELAY, + status=ScheduledWorkStatus.PENDING, + game_id=game.provider_id, + deadline=deadline, + created_at=observed_at, + updated_at=observed_at, + ) + + +async def refresh_terminal_lock_in_scores( + inputs: LivePlanningInputs, + *, + repository: AsyncRuntimeStateRepository, + fetch_summary: DirectGameSummarySource, + observed_at: datetime, + poll_id: str, +) -> LivePlanningInputs: + """Re-observe terminal scores and return inputs containing any stabilized correction.""" + + if observed_at > inputs.week_window.ends_at: + return inputs + records = await repository.load_acknowledged_lock_in_opportunities( + inputs.league_profile.league_id, + inputs.week_window.week, + ) + if not records: + return inputs + grouped: dict[str, list[LockInOpportunityRecord]] = {} + for record in records: + grouped.setdefault(record.key.game_id, []).append(record) + for game_id, group in grouped.items(): + opportunities = tuple(group) + summary_result = await fetch_summary(game_id) + if summary_wait_reason(summary_result, opportunities) is not None: + continue + fingerprint = summary_fingerprint(summary_result.records) + boxes = {item.player_id: item for item in summary_result.records.player_box_scores} + for opportunity in opportunities: + box_score = boxes.get(opportunity.provider_player_id) + if box_score is None: + continue + score = calculate_fantasy_points(box_score.line, inputs.league_profile.scoring) + await repository.record_lock_in_observation( + opportunity.key, + LockInObservation( + score=score, + fingerprint=fingerprint, + poll_id=f"{poll_id}:{game_id}", + observed_at=observed_at, + next_check_at=opportunity.next_check_at, + ), + expected_version=opportunity.row_version, + ) + refreshed = await repository.load_acknowledged_lock_in_opportunities( + inputs.league_profile.league_id, + inputs.week_window.week, + ) + return replace( + inputs, + acknowledgements=merge_lock_in_opportunity_evidence(inputs.acknowledgements, refreshed), + ) + + +__all__ = ["refresh_terminal_lock_in_scores", "sync_live_lock_in_opportunities"] diff --git a/src/sleeper_manager/workflows/planning_collection.py b/src/sleeper_manager/workflows/planning_collection.py index 16e8594..9364fa1 100644 --- a/src/sleeper_manager/workflows/planning_collection.py +++ b/src/sleeper_manager/workflows/planning_collection.py @@ -31,6 +31,7 @@ parse_sleeper_player_identity, ) from sleeper_manager.projections.live_baseline import LiveProjectionTarget +from sleeper_manager.workflows.lock_in_evidence import merge_lock_in_opportunity_evidence from sleeper_manager.workflows.planning_inputs import ( AvailabilityResourceResult, FantasyWeekWindow, @@ -153,6 +154,14 @@ def tick() -> datetime: if acknowledgement_source is not None else () ) + load_opportunities = getattr( + acknowledgement_source, "load_acknowledged_lock_in_opportunities", None + ) + if load_opportunities is not None: + acknowledgements = merge_lock_in_opportunity_evidence( + acknowledgements, + await load_opportunities(profile.league_id, week_window.week), + ) try: inputs = LivePlanningInputs( league_profile=profile, diff --git a/src/sleeper_manager/workflows/postgame_lock_in.py b/src/sleeper_manager/workflows/postgame_lock_in.py new file mode 100644 index 0000000..7e0a7d0 --- /dev/null +++ b/src/sleeper_manager/workflows/postgame_lock_in.py @@ -0,0 +1,598 @@ +"""Observe direct ESPN finals and turn stable scores into live Lock-In advice.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, replace +from datetime import datetime, timedelta +from hashlib import sha256 +from typing import Literal, Protocol + +from sleeper_manager.decisions.live_lock_in import ( + LiveLockInPolicyConfig, + evaluate_live_lock_in, +) +from sleeper_manager.decisions.lock_in import ScoreMaximizingLockInPolicy +from sleeper_manager.domain.lock_in import ( + LockInEvaluation, + LockInEvaluationKind, + LockInOpportunityStatus, +) +from sleeper_manager.domain.nba import DataQualityState, GameStatus, GameSummary, ProviderResult +from sleeper_manager.domain.planning import GameOpportunity, PlanningGameStatus, TeamWeekState +from sleeper_manager.domain.runtime_policy import RuntimePolicy +from sleeper_manager.domain.scoring import calculate_fantasy_points +from sleeper_manager.persistence.base import AsyncRuntimeStateRepository, RecommendationRecord +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityRecord, +) +from sleeper_manager.workflows.lock_in_evidence import merge_lock_in_opportunity_evidence +from sleeper_manager.workflows.notification_loop import ( + NotificationLoop, + RecommendationRequest, + recommendation_id_for, +) +from sleeper_manager.workflows.planning_inputs import ( + LivePlanningInputs, + build_live_team_week_state, +) + +LOCK_IN_DECISION_TYPE = "live_lock_in" +LOCK_IN_WARNING_TYPE = "live_lock_in_unavailable" +LOCK_IN_ACKNOWLEDGEMENT_KINDS = frozenset({LOCK_IN_DECISION_TYPE, "placeholder_lock_in"}) +_RECHECK_DELAY = timedelta(minutes=5) + + +class DirectGameSummarySource(Protocol): + """Fetch an uncached provider summary for one scheduled postgame wake.""" + + async def __call__(self, game_id: str) -> ProviderResult[GameSummary]: ... + + +@dataclass(frozen=True, slots=True) +class PostgameLockInResult: + """Report whether a postgame wake should complete or remain scheduled.""" + + outcome: Literal[ + "wait", + "notified", + "duplicate", + "unavailable", + "automatic_final", + "delivery_failed", + ] + recommendation: RecommendationRecord | None = None + evaluation: LockInEvaluation | None = None + + +async def run_postgame_lock_in( + game_id: str, + inputs: LivePlanningInputs, + *, + decision_time: datetime, + repository: AsyncRuntimeStateRepository, + notifications: NotificationLoop, + fetch_summary: DirectGameSummarySource, + runtime_policy: RuntimePolicy, + open_sleeper_url: str, + poll_id: str, + player_names: dict[str, str] | None = None, + policy: ScoreMaximizingLockInPolicy | None = None, +) -> PostgameLockInResult: + """Fetch once, stabilize every relevant player, evaluate, and notify safely.""" + + due = await repository.list_due_lock_in_opportunities(decision_time) + opportunities = tuple(item for item in due if item.key.game_id == game_id) + if not opportunities: + return PostgameLockInResult("wait") + summary_result = await fetch_summary(game_id) + summary = summary_result.records + reason = summary_wait_reason(summary_result, opportunities) + if reason is not None: + await _defer_opportunities( + repository, + opportunities, + decision_time=decision_time, + reason=reason, + active=summary.game.status is GameStatus.IN_PROGRESS, + ) + return PostgameLockInResult("wait") + + fingerprint = summary_fingerprint(summary) + boxes = {item.player_id: item for item in summary.player_box_scores} + updated: list[LockInOpportunityRecord] = [] + for opportunity in opportunities: + box_score = boxes[opportunity.provider_player_id] + score = calculate_fantasy_points(box_score.line, inputs.league_profile.scoring) + changed = ( + opportunity.current_observation_fingerprint is not None + and opportunity.current_observation_fingerprint != fingerprint + ) + if changed and opportunity.current_recommendation_id is not None: + await repository.supersede_recommendation( + opportunity.current_recommendation_id, + decision_time, + ) + stored = await repository.record_lock_in_observation( + opportunity.key, + LockInObservation( + score=score, + fingerprint=fingerprint, + poll_id=poll_id, + observed_at=decision_time, + next_check_at=decision_time + _RECHECK_DELAY, + ), + expected_version=opportunity.row_version, + ) + if stored is not None: + updated.append(stored) + + stable = tuple( + item + for item in updated + if item.stable_fingerprint == fingerprint and item.consecutive_direct_poll_count >= 2 + ) + if not stable: + return PostgameLockInResult("wait") + + live_inputs = await _inputs_with_lock_in_evidence(inputs, repository) + state = build_live_team_week_state(live_inputs, decision_time=decision_time) + results: list[PostgameLockInResult] = [] + for opportunity in stable: + results.append( + await _evaluate_opportunity( + opportunity, + state, + summary=summary, + repository=repository, + notifications=notifications, + runtime_policy=runtime_policy, + open_sleeper_url=open_sleeper_url, + player_names=player_names or {}, + policy=policy or ScoreMaximizingLockInPolicy(), + ) + ) + return _combined_result(results) + + +def summary_wait_reason( + result: ProviderResult[GameSummary], + opportunities: tuple[LockInOpportunityRecord, ...], +) -> str | None: + """Identify provider evidence that cannot count as a stable final poll.""" + + if result.records.game.status is not GameStatus.FINAL: + return "game_not_final" + if result.quality.state in { + DataQualityState.PARTIAL, + DataQualityState.EMPTY, + DataQualityState.ERROR, + DataQualityState.UNRESOLVED, + DataQualityState.STALE, + }: + return "summary_incomplete" + present = {item.player_id for item in result.records.player_box_scores} + if any(item.provider_player_id not in present for item in opportunities): + return "rostered_player_missing" + return None + + +async def _defer_opportunities( + repository: AsyncRuntimeStateRepository, + opportunities: tuple[LockInOpportunityRecord, ...], + *, + decision_time: datetime, + reason: str, + active: bool, +) -> None: + """Persist Wait evidence without emitting a routine notification.""" + + for opportunity in opportunities: + deferred = replace( + opportunity, + status=( + LockInOpportunityStatus.ACTIVE if active else LockInOpportunityStatus.FINALIZING + ), + next_check_at=decision_time + _RECHECK_DELAY, + trace_json=json.dumps({"reason_codes": [reason]}, sort_keys=True), + updated_at=decision_time, + ) + await repository.update_lock_in_opportunity( + deferred, + expected_version=opportunity.row_version, + ) + + +async def _evaluate_opportunity( + opportunity: LockInOpportunityRecord, + state: TeamWeekState, + *, + summary: GameSummary, + repository: AsyncRuntimeStateRepository, + notifications: NotificationLoop, + runtime_policy: RuntimePolicy, + open_sleeper_url: str, + player_names: dict[str, str], + policy: ScoreMaximizingLockInPolicy, +) -> PostgameLockInResult: + """Evaluate one stable player score and persist its material outcome.""" + + completed_state = _completed_state(state, opportunity, summary) + if completed_state is None: + evaluation = _mapping_unavailable_evaluation(state, opportunity, runtime_policy) + else: + completed, live_state = completed_state + evaluation = evaluate_live_lock_in( + live_state, + completed, + deadline=opportunity.action_deadline, + manager_policy_version=runtime_policy.manager_intent.version, + policy=policy, + config=LiveLockInPolicyConfig(runtime_policy.manager_intent.minimum_confidence), + ) + evaluation_hash = _evaluation_hash(evaluation) + trace_json = _evaluation_trace_json(evaluation, opportunity) + if evaluation.kind is LockInEvaluationKind.WAIT: + await _supersede_changed_recommendation( + repository, + opportunity, + replacement_id=None, + changed_at=state.decision_time, + ) + await _store_evaluation( + repository, + opportunity, + status=LockInOpportunityStatus.FINALIZING, + evaluation_hash=evaluation_hash, + trace_json=trace_json, + ) + return PostgameLockInResult("wait", evaluation=evaluation) + if evaluation.kind is LockInEvaluationKind.AUTOMATIC_FINAL: + await _supersede_changed_recommendation( + repository, + opportunity, + replacement_id=None, + changed_at=state.decision_time, + ) + await _store_evaluation( + repository, + opportunity, + status=LockInOpportunityStatus.AUTOMATIC_FINAL, + evaluation_hash=evaluation_hash, + trace_json=trace_json, + ) + return PostgameLockInResult("automatic_final", evaluation=evaluation) + + decision_type = ( + LOCK_IN_WARNING_TYPE + if evaluation.kind is LockInEvaluationKind.UNAVAILABLE + else LOCK_IN_DECISION_TYPE + ) + idempotency_key = _material_idempotency_key(opportunity, evaluation, evaluation_hash) + await _supersede_changed_recommendation( + repository, + opportunity, + replacement_id=recommendation_id_for(idempotency_key), + changed_at=state.decision_time, + ) + request = RecommendationRequest( + league_id=opportunity.key.league_id, + fantasy_week=opportunity.key.fantasy_week, + player_id=opportunity.key.player_id, + game_id=opportunity.key.game_id, + decision_type=decision_type, + title=_evaluation_title(evaluation, player_names), + message=_evaluation_message(evaluation), + deadline=opportunity.action_deadline, + policy_version=runtime_policy.manager_intent.version, + open_sleeper_url=open_sleeper_url, + trace_json=trace_json, + idempotency_key=idempotency_key, + ) + result = await notifications.run(request) + stored = await repository.get_lock_in_opportunity(opportunity.key) + if stored is not None: + await _store_evaluation( + repository, + stored, + status=( + LockInOpportunityStatus.RECONCILIATION_REQUIRED + if evaluation.kind is LockInEvaluationKind.UNAVAILABLE + else LockInOpportunityStatus.ACTIONABLE + ), + evaluation_hash=evaluation_hash, + trace_json=trace_json, + recommendation=result.recommendation, + ) + outcome: Literal["notified", "duplicate", "unavailable", "delivery_failed"] + if result.status == "delivery_failed": + outcome = "delivery_failed" + elif evaluation.kind is LockInEvaluationKind.UNAVAILABLE: + outcome = "unavailable" + else: + outcome = "duplicate" if result.status == "duplicate" else "notified" + return PostgameLockInResult(outcome, result.recommendation, evaluation) + + +async def _supersede_changed_recommendation( + repository: AsyncRuntimeStateRepository, + opportunity: LockInOpportunityRecord, + *, + replacement_id: str | None, + changed_at: datetime, +) -> None: + """Retire stale pending advice before storing a materially different outcome.""" + + current_id = opportunity.current_recommendation_id + if current_id is not None and current_id != replacement_id: + await repository.supersede_recommendation(current_id, changed_at) + + +def _completed_state( + state: TeamWeekState, + opportunity: LockInOpportunityRecord, + summary: GameSummary, +) -> tuple[GameOpportunity, TeamWeekState] | None: + """Overlay durable final evidence when the current mapped player-game still exists.""" + + target = next( + ( + item + for item in state.opportunities + if item.sleeper_player_id == opportunity.key.player_id + and item.game_id == opportunity.key.game_id + ), + None, + ) + if target is None: + return None + completed = replace( + target, + status=PlanningGameStatus.FINAL, + eligible_slot_indices=( + (opportunity.slot_index,) if opportunity.slot_index is not None else () + ), + eligible_positions=opportunity.eligible_positions, + rostered_at_tipoff=opportunity.rostered_at_tipoff, + completed_fantasy_score=opportunity.stable_score, + finalized_at=summary.game.finalized_at or state.decision_time, + data_quality="stable-direct-espn-summary", + ) + finalized_at = completed.finalized_at + return completed, replace( + state, + opportunities=tuple( + completed + if item == target + else replace(item, status=PlanningGameStatus.FINAL, finalized_at=finalized_at) + if item.game_id == opportunity.key.game_id + else item + for item in state.opportunities + ), + ) + + +def _mapping_unavailable_evaluation( + state: TeamWeekState, + opportunity: LockInOpportunityRecord, + runtime_policy: RuntimePolicy, +) -> LockInEvaluation: + """Describe a persisted player-game that disappeared from current mapped inputs.""" + + return LockInEvaluation( + decision_time=state.decision_time, + kind=LockInEvaluationKind.UNAVAILABLE, + player_id=opportunity.key.player_id, + game_id=opportunity.key.game_id, + deadline=opportunity.action_deadline, + information_version=state.input_version, + manager_policy_version=runtime_policy.manager_intent.version, + reason_codes=("mapping_unavailable",), + trace=( + ("league_configuration_version", state.league_configuration_version), + ("scoring_policy_version", state.scoring_policy_version), + ("projection_model_version", state.projection_model_version), + ("current_player_game_mapping", "missing"), + ), + observed_score=opportunity.stable_score, + ) + + +async def _store_evaluation( + repository: AsyncRuntimeStateRepository, + opportunity: LockInOpportunityRecord, + *, + status: LockInOpportunityStatus, + evaluation_hash: str, + trace_json: str, + recommendation: RecommendationRecord | None = None, +) -> None: + """Guard the latest evaluation and recommendation identity against stale writers.""" + + updated = replace( + opportunity, + status=status, + current_recommendation_id=(recommendation.recommendation_id if recommendation else None), + current_recommendation_kind=(recommendation.decision_type if recommendation else None), + latest_evaluation_hash=evaluation_hash, + trace_json=trace_json, + updated_at=opportunity.current_observed_at or opportunity.updated_at, + ) + await repository.update_lock_in_opportunity( + updated, + expected_version=opportunity.row_version, + ) + + +def summary_fingerprint(summary: GameSummary) -> str: + """Hash normalized game and box-score facts without retrieval timestamps.""" + + payload = { + "game": { + "id": summary.game.provider_id, + "start": summary.game.start_time.isoformat(), + "status": summary.game.status.value, + "completed_periods": summary.game.completed_periods, + }, + "players": [ + { + "player_id": item.player_id, + "team_id": item.team_id, + "started": item.started, + "did_play": item.did_play, + "minutes": item.minutes, + "line": asdict(item.line), + } + for item in sorted(summary.player_box_scores, key=lambda value: value.player_id) + ], + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return sha256(encoded).hexdigest() + + +def _evaluation_hash(evaluation: LockInEvaluation) -> str: + """Hash canonical rounded material advice while excluding retrieval timestamps.""" + + payload = { + "kind": evaluation.kind.value, + "score": _rounded(evaluation.observed_score), + "alternative": _rounded(evaluation.alternative_expected_score), + "percentiles": [ + [rank, _rounded(value)] for rank, value in evaluation.alternative_percentiles + ], + "confidence": _rounded(evaluation.confidence), + "reason_codes": evaluation.reason_codes, + "trace": evaluation.trace, + "slot_index": evaluation.decision.slot_index if evaluation.decision else None, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return sha256(encoded).hexdigest() + + +def _rounded(value: float | None) -> float | None: + """Canonicalize material numeric evidence to two fantasy-score decimals.""" + + return round(value, 2) if value is not None else None + + +def _evaluation_trace_json( + evaluation: LockInEvaluation, + opportunity: LockInOpportunityRecord, +) -> str: + """Serialize the auditable live evaluation trace for persistence and acknowledgements.""" + + payload: dict[str, object] = { + "kind": evaluation.kind.value, + "reason_codes": evaluation.reason_codes, + "trace": dict(evaluation.trace), + "observed_score": evaluation.observed_score, + "alternative_expected_score": evaluation.alternative_expected_score, + "alternative_percentiles": evaluation.alternative_percentiles, + "confidence": evaluation.confidence, + } + if evaluation.kind is LockInEvaluationKind.LOCK: + payload["acknowledgement"] = { + "schema_version": 1, + "slot_index": evaluation.decision.slot_index if evaluation.decision else None, + "slot_position": opportunity.slot_position, + "accepted_fantasy_score": evaluation.observed_score, + } + elif evaluation.kind is LockInEvaluationKind.PASS: + payload["acknowledgement"] = {"schema_version": 1} + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + + +def _material_idempotency_key( + opportunity: LockInOpportunityRecord, + evaluation: LockInEvaluation, + evaluation_hash: str, +) -> str: + """Identify one material player-game recommendation across retries and restarts.""" + + return ":".join( + ( + opportunity.key.league_id, + str(opportunity.key.fantasy_week), + str(opportunity.key.roster_id), + opportunity.key.player_id, + opportunity.key.game_id, + evaluation.manager_policy_version, + str(opportunity.score_revision), + evaluation.kind.value, + evaluation.deadline.isoformat(), + evaluation_hash, + ) + ) + + +def _evaluation_title( + evaluation: LockInEvaluation, + player_names: dict[str, str], +) -> str: + """Render a compact actionable or warning title.""" + + player = player_names.get(evaluation.player_id, evaluation.player_id) + if evaluation.kind is LockInEvaluationKind.UNAVAILABLE: + return f"Lock-In unavailable: {player}" + return f"{evaluation.kind.value.title()} {player}?" + + +def _evaluation_message(evaluation: LockInEvaluation) -> str: + """Render stable score, alternative value, confidence, and reason evidence.""" + + if evaluation.kind is LockInEvaluationKind.UNAVAILABLE: + return "Safe Lock-In advice is unavailable: " + ", ".join(evaluation.reason_codes) + assert evaluation.observed_score is not None + assert evaluation.alternative_expected_score is not None + assert evaluation.confidence is not None + return ( + f"Observed {evaluation.observed_score:.2f}; alternative expected " + f"{evaluation.alternative_expected_score:.2f}; confidence " + f"{evaluation.confidence:.0%}. Complete the action in Sleeper, then acknowledge it." + ) + + +def _combined_result(results: list[PostgameLockInResult]) -> PostgameLockInResult: + """Choose the most operationally significant result from a coalesced game.""" + + for outcome in ( + "delivery_failed", + "notified", + "unavailable", + "duplicate", + "wait", + "automatic_final", + ): + match = next((item for item in results if item.outcome == outcome), None) + if match is not None: + return match + return PostgameLockInResult("wait") + + +async def _inputs_with_lock_in_evidence( + inputs: LivePlanningInputs, + repository: AsyncRuntimeStateRepository, +) -> LivePlanningInputs: + """Overlay current locked, passed, and automatic-final scores onto live inputs.""" + + records = await repository.load_acknowledged_lock_in_opportunities( + inputs.league_profile.league_id, + inputs.week_window.week, + ) + return replace( + inputs, + acknowledgements=merge_lock_in_opportunity_evidence(inputs.acknowledgements, records), + ) + + +__all__ = ( + "DirectGameSummarySource", + "LOCK_IN_ACKNOWLEDGEMENT_KINDS", + "LOCK_IN_DECISION_TYPE", + "LOCK_IN_WARNING_TYPE", + "PostgameLockInResult", + "run_postgame_lock_in", + "summary_fingerprint", + "summary_wait_reason", +) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5b22ee4..a152a26 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -27,7 +27,7 @@ def test_policy_preset_is_overridden_by_toml(tmp_path) -> None: # type: ignore[ urgent_actions_override_quiet_hours = false [players] -protected_sleeper_ids = ["player-1"] +mapping_overrides = { "player-1" = "provider-1" } """, encoding="utf-8", ) @@ -39,7 +39,7 @@ def test_policy_preset_is_overridden_by_toml(tmp_path) -> None: # type: ignore[ assert policy.notifications.quiet_hours_start == "22:00" assert policy.notifications.quiet_hours_end == "06:30" assert policy.notifications.urgent_actions_override_quiet_hours is False - assert policy.players.protected_sleeper_ids == ("player-1",) + assert policy.players.mapping_overrides == {"player-1": "provider-1"} def test_policy_defaults_are_constructible() -> None: @@ -64,6 +64,10 @@ def test_policy_defaults_are_constructible() -> None: """ [notifications] injury_alerts = false +""", + """ +[players] +protected_sleeper_ids = ["player-1"] """, ), ) @@ -83,7 +87,7 @@ def test_to_manager_intent_translates_surviving_fields() -> None: "quiet_hours_end": "05:00", "urgent_actions_override_quiet_hours": False, }, - players={"protected_sleeper_ids": ("player-9",), "mapping_overrides": {}}, + players={"mapping_overrides": {"player-9": "provider-9"}}, ) intent = policy.to_manager_intent() @@ -93,5 +97,4 @@ def test_to_manager_intent_translates_surviving_fields() -> None: assert intent.quiet_hours_start == "21:00" assert intent.quiet_hours_end == "05:00" assert intent.urgent_actions_override_quiet_hours is False - assert intent.protected_sleeper_ids == ("player-9",) assert intent.version == policy.version diff --git a/tests/unit/test_live_lock_in_policy.py b/tests/unit/test_live_lock_in_policy.py new file mode 100644 index 0000000..8fa392d --- /dev/null +++ b/tests/unit/test_live_lock_in_policy.py @@ -0,0 +1,159 @@ +"""Live Lock-In evaluation coverage over deterministic policy scenarios.""" + +from datetime import UTC, datetime, timedelta + +from sleeper_manager.decisions.live_lock_in import ( + LiveLockInPolicyConfig, + evaluate_live_lock_in, +) +from sleeper_manager.decisions.lock_in import LockInPolicyConfig, ScoreMaximizingLockInPolicy +from sleeper_manager.domain.lock_in import LockInEvaluationKind +from sleeper_manager.domain.planning import ( + GameOpportunity, + PlanningGameStatus, + PlanningQuality, + StarterSlot, + TeamWeekState, +) +from sleeper_manager.domain.projection import ProjectionDistribution, ProjectionSnapshot + +NOW = datetime(2026, 1, 10, 18, tzinfo=UTC) + + +def _opportunity( + game_id: str, + expected: float, + *, + actual: float | None = None, + start_offset: int = 1, +) -> GameOpportunity: + """Build one finalized or scheduled opportunity for the same player.""" + + projection = ProjectionSnapshot( + player_id="player", + game_id=game_id, + available_as_of=NOW - timedelta(hours=1), + model_version="fixture", + input_version=f"projection-{game_id}", + scoring_policy_version="scoring-v1", + distribution=ProjectionDistribution.from_weighted_observations(((expected, 1.0),)), + reasons=(), + ) + finalized = actual is not None + return GameOpportunity( + sleeper_player_id="player", + provider_player_id="espn-player", + game_id=game_id, + scheduled_start=NOW + timedelta(hours=start_offset), + status=PlanningGameStatus.FINAL if finalized else PlanningGameStatus.SCHEDULED, + roster_id=1, + membership_segment="segment", + eligible_slot_indices=(0,), + eligible_positions=("PG",), + rostered_at_tipoff=True, + availability_status="available", + availability_evidence_at=NOW - timedelta(hours=1), + projection=projection, + missing_projection_reason=None, + completed_fantasy_score=actual, + finalized_at=NOW - timedelta(minutes=1) if finalized else None, + ) + + +def _state(opportunities: tuple[GameOpportunity, ...]) -> TeamWeekState: + """Build exact team-week evidence for live policy evaluation.""" + + return TeamWeekState( + league_id="league", + season="2026", + week=1, + roster_id=1, + decision_time=NOW, + starter_slots=(StarterSlot(0, "PG"),), + roster_player_ids=("player",), + observed_starters=(), + opportunities=opportunities, + fixed_slots=(), + passed_opportunities=(), + scoring_policy_version="scoring-v1", + league_configuration_version="league-v1", + manager_policy_version="manager-v1", + projection_model_version="fixture", + input_version="inputs-v1", + eligibility_quality=PlanningQuality.EXACT, + ) + + +def _evaluate( + completed: GameOpportunity, + future: GameOpportunity | None, + *, + minimum_confidence: float = 0.7, + deadline: datetime | None = None, +): + """Evaluate one fixture with the deterministic shared policy.""" + + opportunities = (completed,) if future is None else (completed, future) + return evaluate_live_lock_in( + _state(opportunities), + completed, + deadline=deadline or NOW + timedelta(hours=6), + manager_policy_version="manager-v1", + policy=ScoreMaximizingLockInPolicy( + LockInPolicyConfig(scenario_count=20, seed=7, tie_tolerance=0.01) + ), + config=LiveLockInPolicyConfig(minimum_confidence), + ) + + +def test_live_policy_reports_deterministic_lock_confidence() -> None: + """Count material scenario wins for a clearly superior completed score.""" + + result = _evaluate( + _opportunity("completed", 20, actual=20, start_offset=-3), + _opportunity("future", 1), + ) + + assert result.kind is LockInEvaluationKind.LOCK + assert result.confidence == 1.0 + assert result.alternative_percentiles == ((10, 1.0), (50, 1.0), (90, 1.0)) + + +def test_live_policy_waits_on_exact_ties() -> None: + """Keep a mean-best tie silent when no scenario beats the tolerance.""" + + result = _evaluate( + _opportunity("completed", 10, actual=10, start_offset=-3), + _opportunity("future", 10), + ) + + assert result.kind is LockInEvaluationKind.WAIT + assert result.confidence == 0.0 + assert result.decision is None + assert result.reason_codes == ("confidence_below_threshold",) + + +def test_live_policy_marks_final_eligible_game_automatic() -> None: + """Avoid unnecessary manager advice when no later player-game exists.""" + + result = _evaluate( + _opportunity("completed", 15, actual=15, start_offset=-3), + None, + ) + + assert result.kind is LockInEvaluationKind.AUTOMATIC_FINAL + assert result.observed_score == 15 + assert result.decision is None + + +def test_live_policy_refuses_advice_after_deadline() -> None: + """Return unavailable instead of producing a stale actionable decision.""" + + result = _evaluate( + _opportunity("completed", 20, actual=20, start_offset=-3), + _opportunity("future", 1), + deadline=NOW, + ) + + assert result.kind is LockInEvaluationKind.UNAVAILABLE + assert result.reason_codes == ("deadline_elapsed",) diff --git a/tests/unit/test_lock_in_contracts.py b/tests/unit/test_lock_in_contracts.py index 106bf7a..fd184dc 100644 --- a/tests/unit/test_lock_in_contracts.py +++ b/tests/unit/test_lock_in_contracts.py @@ -9,6 +9,8 @@ LockInDecision, LockInDecisionKind, LockInDecisionTrace, + LockInEvaluation, + LockInEvaluationKind, ) NOW = datetime(2026, 2, 2, 20, tzinfo=UTC) @@ -103,3 +105,51 @@ def test_trace_requires_stable_positive_ordering_identity() -> None: trace.candidate_id, 0, ) + + +def test_live_evaluation_separates_actions_from_waits() -> None: + """Keep deferred live results from masquerading as Lock/Pass decisions.""" + + actionable = LockInEvaluation( + decision_time=NOW, + kind=LockInEvaluationKind.LOCK, + player_id="player-1", + game_id="game-1", + deadline=NOW, + information_version="inputs-v1", + manager_policy_version="manager-v1", + reason_codes=("confidence_met",), + trace=(("scoring_policy_version", "scoring-v1"),), + observed_score=50.0, + alternative_expected_score=42.0, + alternative_percentiles=((10, 30.0), (50, 42.0), (90, 55.0)), + confidence=0.8, + decision=_decision(), + ) + waiting = LockInEvaluation( + decision_time=NOW, + kind=LockInEvaluationKind.WAIT, + player_id="player-1", + game_id="game-1", + deadline=NOW, + information_version="inputs-v1", + manager_policy_version="manager-v1", + reason_codes=("score_stabilizing",), + trace=(("scoring_policy_version", "scoring-v1"),), + ) + + assert actionable.decision is not None + assert waiting.decision is None + with pytest.raises(LockInContractError, match="Only actionable"): + LockInEvaluation( + decision_time=NOW, + kind=LockInEvaluationKind.WAIT, + player_id="player-1", + game_id="game-1", + deadline=NOW, + information_version="inputs-v1", + manager_policy_version="manager-v1", + reason_codes=("score_stabilizing",), + trace=(("scoring_policy_version", "scoring-v1"),), + decision=_decision(), + ) diff --git a/tests/unit/test_lock_in_evidence.py b/tests/unit/test_lock_in_evidence.py new file mode 100644 index 0000000..82ed355 --- /dev/null +++ b/tests/unit/test_lock_in_evidence.py @@ -0,0 +1,53 @@ +"""Convert terminal live Lock-In rows into later-planning acknowledgement evidence.""" + +from datetime import UTC, datetime + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.domain.planning import AcknowledgedAction, AcknowledgedDecisionEvidence +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInOpportunityKey, + LockInOpportunityRecord, +) +from sleeper_manager.workflows.lock_in_evidence import ( + evidence_from_lock_in_opportunity, + merge_lock_in_opportunity_evidence, +) + + +def test_lock_in_evidence_prefers_opportunity_score() -> None: + """Corrected opportunity scores replace recommendation-trace snapshots.""" + + now = datetime(2026, 1, 8, tzinfo=UTC) + existing = AcknowledgedDecisionEvidence( + decision_id="old", + player_id="p1", + game_id="g1", + action=AcknowledgedAction.LOCK, + decided_at=now, + provenance="repository_acknowledgement_v1", + slot_index=0, + slot_position="PG", + accepted_fantasy_score=20.0, + ) + record = LockInOpportunityRecord( + key=LockInOpportunityKey("league-1", 1, 1, "p1", "g1"), + provider_player_id="401", + scheduled_start=now, + action_deadline=now, + fantasy_week_end=now, + status=LockInOpportunityStatus.ACKNOWLEDGED_LOCKED, + next_check_at=now, + created_at=now, + updated_at=now, + slot_index=0, + slot_position="PG", + stable_score=24.0, + acknowledged_action="locked", + acknowledged_at=now, + current_recommendation_id="rec-1", + ) + merged = merge_lock_in_opportunity_evidence((existing,), (record,)) + converted = evidence_from_lock_in_opportunity(record) + assert converted is not None + assert merged == (converted,) + assert merged[0].accepted_fantasy_score == 24.0 diff --git a/tests/unit/test_lock_in_live_loop.py b/tests/unit/test_lock_in_live_loop.py new file mode 100644 index 0000000..49d5f37 --- /dev/null +++ b/tests/unit/test_lock_in_live_loop.py @@ -0,0 +1,116 @@ +"""End-to-end live Lock-In capture, advice, acknowledgement, and score correction.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from urllib.parse import parse_qs, urlparse + +from test_postgame_lock_in import ( + LATER_START, + POLICY, + POSTGAME, + SECOND_POLL, + _box, + _fetcher, + _prepare, + _summary_result, +) + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.domain.planning import PlanningGameStatus +from sleeper_manager.persistence.base import AcknowledgementAction, AcknowledgementOutcome +from sleeper_manager.persistence.lock_in_opportunities import LockInOpportunityKey +from sleeper_manager.persistence.tokens import hash_action_token +from sleeper_manager.workflows.lock_in_planning import refresh_terminal_lock_in_scores +from sleeper_manager.workflows.planning_inputs import build_live_team_week_state +from sleeper_manager.workflows.postgame_lock_in import run_postgame_lock_in + + +def test_lock_in_live_loop_ack_correction_and_later_planning(tmp_path) -> None: # type: ignore[no-untyped-def] + """Capture tipoff evidence, lock, acknowledge, then use a corrected fixed score.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + player_names={"p1": "Ann"}, + ) + notifications._clock = lambda: SECOND_POLL + notified = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + player_names={"p1": "Ann"}, + ) + assert notified.outcome == "notified" + assert len(sender.messages) == 1 + token = parse_qs(urlparse(sender.messages[0].actions[0].url).query)["token"][0] + consumed = await repository.consume_action_token( + hash_action_token(token), + AcknowledgementAction.LOCKED, + SECOND_POLL, + ) + locked = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert consumed.outcome is AcknowledgementOutcome.APPLIED + assert locked is not None + assert locked.status is LockInOpportunityStatus.ACKNOWLEDGED_LOCKED + assert locked.stable_score == 20.0 + + corrected = _summary_result(_box("401", points=24), _box("402", points=8)) + day_one = SECOND_POLL + timedelta(hours=1) + await refresh_terminal_lock_in_scores( + inputs, + repository=repository, + fetch_summary=_fetcher(corrected), + observed_at=day_one, + poll_id="daily-1", + ) + day_two = SECOND_POLL + timedelta(hours=2) + refreshed = await refresh_terminal_lock_in_scores( + inputs, + repository=repository, + fetch_summary=_fetcher(corrected), + observed_at=day_two, + poll_id="daily-2", + ) + corrected_row = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert corrected_row is not None + assert corrected_row.status is LockInOpportunityStatus.ACKNOWLEDGED_LOCKED + assert corrected_row.stable_score == 24.0 + assert corrected_row.score_revision == 2 + state = build_live_team_week_state(refreshed, decision_time=day_two) + assert not state.is_blocked + fixed = next(item for item in state.fixed_slots if item.player_id == "p1") + assert fixed.game_id == "g1" + assert fixed.accepted_fantasy_score == 24.0 + later = next( + item + for item in state.opportunities + if item.sleeper_player_id == "p1" and item.game_id == "g2" + ) + assert later.status is PlanningGameStatus.SCHEDULED + assert later.scheduled_start == LATER_START + assert sender.messages[-1].title.startswith("Lock") + assert len(sender.messages) == 1 + + asyncio.run(exercise()) diff --git a/tests/unit/test_lock_in_opportunity_persistence.py b/tests/unit/test_lock_in_opportunity_persistence.py new file mode 100644 index 0000000..fae97ef --- /dev/null +++ b/tests/unit/test_lock_in_opportunity_persistence.py @@ -0,0 +1,274 @@ +"""Shared SQLite and fake-D1 contracts for live Lock-In opportunity state.""" + +import asyncio +from dataclasses import replace +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from test_d1 import FakeD1 + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.persistence.async_sqlite import AsyncSQLiteStateRepository +from sleeper_manager.persistence.base import ( + AcknowledgementAction, + AcknowledgementOutcome, + ActionTokenRecord, + RecommendationRecord, +) +from sleeper_manager.persistence.d1 import D1_SCHEMA, D1StateRepository +from sleeper_manager.persistence.lock_in_opportunities import ( + LockInObservation, + LockInOpportunityKey, + LockInOpportunityRecord, +) +from sleeper_manager.persistence.tokens import hash_action_token + +NOW = datetime(2026, 8, 30, 18, tzinfo=UTC) + + +def _opportunity() -> LockInOpportunityRecord: + """Build one scheduled opportunity with exact pre-tipoff evidence.""" + + return LockInOpportunityRecord( + key=LockInOpportunityKey("league", 1, 7, "player", "game"), + provider_player_id="espn-player", + scheduled_start=NOW - timedelta(hours=3), + action_deadline=NOW + timedelta(hours=4), + fantasy_week_end=NOW + timedelta(days=2), + status=LockInOpportunityStatus.FINALIZING, + next_check_at=NOW, + created_at=NOW - timedelta(days=1), + updated_at=NOW, + slot_index=2, + slot_position="UTIL", + eligible_positions=("PG", "SG"), + rostered_at_tipoff=True, + roster_evidence_at=NOW - timedelta(hours=4), + league_configuration_fingerprint="league-v1", + ) + + +async def _repository(backend: str, tmp_path: Path): # type: ignore[no-untyped-def] + """Create one initialized backend behind the shared async protocol.""" + + if backend == "sqlite": + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + return repository + database = FakeD1() + await database.exec(D1_SCHEMA) + return D1StateRepository(database) + + +@pytest.mark.parametrize("backend", ("sqlite", "d1")) +def test_opportunity_guards_stabilization_corrections_and_queries( + backend: str, tmp_path: Path +) -> None: + """Keep both repositories identical across guarded lifecycle transitions.""" + + async def exercise() -> None: + repository = await _repository(backend, tmp_path) + original = _opportunity() + assert await repository.upsert_lock_in_opportunity(original) + assert not await repository.upsert_lock_in_opportunity(original) + assert await repository.get_lock_in_opportunity(original.key) == original + assert await repository.list_due_lock_in_opportunities(NOW) == (original,) + + first = await repository.record_lock_in_observation( + original.key, + LockInObservation(12.0, "summary-a", "wake-1", NOW, NOW + timedelta(minutes=5)), + expected_version=0, + ) + assert first is not None + assert first.consecutive_direct_poll_count == 1 + assert first.stable_score is None + assert ( + await repository.record_lock_in_observation( + original.key, + LockInObservation( + 12.0, + "summary-a", + "wake-1", + NOW, + NOW + timedelta(minutes=5), + ), + expected_version=first.row_version, + ) + is None + ) + + stable = await repository.record_lock_in_observation( + original.key, + LockInObservation( + 12.0, + "summary-a", + "wake-2", + NOW + timedelta(minutes=5), + NOW + timedelta(minutes=10), + ), + expected_version=first.row_version, + ) + assert stable is not None + assert stable.stable_score == 12.0 + assert stable.score_revision == 1 + assert stable.stabilized_at == NOW + timedelta(minutes=5) + + correction = await repository.record_lock_in_observation( + original.key, + LockInObservation( + 13.0, + "summary-b", + "wake-3", + NOW + timedelta(minutes=10), + NOW + timedelta(minutes=15), + ), + expected_version=stable.row_version, + ) + assert correction is not None + assert correction.stable_score == 12.0 + assert correction.consecutive_direct_poll_count == 1 + corrected = await repository.record_lock_in_observation( + original.key, + LockInObservation( + 13.0, + "summary-b", + "wake-4", + NOW + timedelta(minutes=15), + NOW + timedelta(minutes=20), + ), + expected_version=correction.row_version, + ) + assert corrected is not None + assert corrected.stable_score == 13.0 + assert corrected.score_revision == 2 + + actionable = replace( + corrected, + status=LockInOpportunityStatus.ACTIONABLE, + latest_evaluation_hash="evaluation-v2", + updated_at=NOW + timedelta(minutes=16), + ) + assert await repository.update_lock_in_opportunity( + actionable, + expected_version=corrected.row_version, + ) + assert not await repository.update_lock_in_opportunity( + actionable, + expected_version=corrected.row_version, + ) + listed = await repository.list_actionable_lock_in_opportunities("league", 1) + assert len(listed) == 1 and listed[0].latest_evaluation_hash == "evaluation-v2" + + acknowledged = replace( + listed[0], + status=LockInOpportunityStatus.ACKNOWLEDGED_LOCKED, + acknowledged_action="locked", + acknowledged_at=NOW + timedelta(minutes=17), + updated_at=NOW + timedelta(minutes=17), + ) + assert await repository.update_lock_in_opportunity( + acknowledged, + expected_version=listed[0].row_version, + ) + fixed = await repository.load_acknowledged_lock_in_opportunities("league", 1) + assert len(fixed) == 1 and fixed[0].stable_score == 13.0 + assert await repository.expire_lock_in_opportunities(NOW + timedelta(hours=4)) == 0 + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("backend", ("sqlite", "d1")) +def test_consume_acknowledgement_updates_opportunity_atomically( + backend: str, tmp_path: Path +) -> None: + """Apply recommendation and opportunity acknowledgement in one repository call.""" + + async def exercise() -> None: + repository = await _repository(backend, tmp_path) + recommendation = RecommendationRecord( + recommendation_id="rec-lock", + idempotency_key="lock-key", + league_id="league", + fantasy_week=1, + player_id="player", + game_id="game", + decision_type="live_lock_in", + title="Lock player?", + message="Lock now", + deadline=NOW + timedelta(hours=2), + policy_version="policy-1", + created_at=NOW, + ) + await repository.create_recommendation(recommendation) + original = replace( + _opportunity(), + status=LockInOpportunityStatus.ACTIONABLE, + current_recommendation_id=recommendation.recommendation_id, + current_recommendation_kind="live_lock_in", + stable_score=18.0, + ) + assert await repository.upsert_lock_in_opportunity(original) + raw_token = "lock-token" + await repository.create_action_token( + ActionTokenRecord( + token_hash=hash_action_token(raw_token), + recommendation_id=recommendation.recommendation_id, + action=AcknowledgementAction.LOCKED, + created_at=NOW, + expires_at=recommendation.deadline or NOW, + ) + ) + result = await repository.consume_action_token( + hash_action_token(raw_token), + AcknowledgementAction.LOCKED, + NOW + timedelta(minutes=1), + ) + stored = await repository.get_lock_in_opportunity(original.key) + assert result.outcome is AcknowledgementOutcome.APPLIED + assert stored is not None + assert stored.status is LockInOpportunityStatus.ACKNOWLEDGED_LOCKED + assert stored.acknowledged_action == "locked" + assert stored.stable_score == 18.0 + assert await repository.has_open_lock_in_watch("game", NOW) is False + + asyncio.run(exercise()) + + +def test_phase_six_migration_preserves_existing_scheduled_work() -> None: + """Rebuild the constrained work table without losing populated rows.""" + + database = FakeD1() + migrations = Path("infra/cloudflare/migrations") + asyncio.run(database.exec((migrations / "0001_phase3.sql").read_text())) + asyncio.run( + database.exec((migrations / "0002_acknowledged_team_week_decisions.sql").read_text()) + ) + asyncio.run(database.exec((migrations / "0003_scheduled_work.sql").read_text())) + database.connection.execute( + """ + INSERT INTO scheduled_work ( + work_id, dedupe_key, kind, due_at, status, created_at, updated_at + ) VALUES ('existing', 'daily:existing', 'daily', ?, 'pending', ?, ?) + """, + (NOW.isoformat(), NOW.isoformat(), NOW.isoformat()), + ) + database.connection.commit() + + asyncio.run(database.exec((migrations / "0004_live_lock_in.sql").read_text())) + database.connection.execute( + """ + INSERT INTO scheduled_work ( + work_id, dedupe_key, kind, due_at, status, created_at, updated_at + ) VALUES ('postgame', 'postgame:game', 'postgame', ?, 'pending', ?, ?) + """, + (NOW.isoformat(), NOW.isoformat(), NOW.isoformat()), + ) + + kinds = tuple( + row[0] + for row in database.connection.execute( + "SELECT kind FROM scheduled_work ORDER BY work_id" + ).fetchall() + ) + assert kinds == ("daily", "postgame") diff --git a/tests/unit/test_lock_in_planning.py b/tests/unit/test_lock_in_planning.py new file mode 100644 index 0000000..9a3a82a --- /dev/null +++ b/tests/unit/test_lock_in_planning.py @@ -0,0 +1,225 @@ +"""Opportunity creation and pre-tipoff evidence capture coverage.""" + +import asyncio +from dataclasses import replace +from datetime import timedelta + +from test_planning_inputs import NOW, _game, _inputs, _profile, _schedule_result + +from sleeper_manager.domain.lock_in import LockInOpportunityStatus +from sleeper_manager.domain.nba import GameStatus +from sleeper_manager.persistence.async_sqlite import AsyncSQLiteStateRepository +from sleeper_manager.persistence.base import DueWorkKind +from sleeper_manager.persistence.lock_in_opportunities import LockInOpportunityKey +from sleeper_manager.workflows.lock_in_planning import sync_live_lock_in_opportunities + + +def test_planning_creates_player_opportunities_and_one_watch_per_game(tmp_path) -> None: # type: ignore[no-untyped-def] + """Coalesce roster players into one game-level ESPN summary watch.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + first = _game("g1", start=NOW + timedelta(hours=2)) + second = _game("g2", start=NOW + timedelta(days=1)) + inputs = _inputs(schedule_results=(_schedule_result(first, second),)) + + await sync_live_lock_in_opportunities( + inputs, + repository=repository, + observed_at=NOW, + ) + + p1 = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + p2 = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p2", "g1") + ) + work = await repository.list_scheduled_work(kind=DueWorkKind.POSTGAME) + assert p1 is not None and p1.slot_index == 0 and p1.slot_position == "PG" + assert p2 is not None and p2.slot_index == 1 and p2.slot_position == "UTIL" + assert p1.roster_evidence_at == inputs.league_profile.retrieved_at + assert p1.action_deadline == second.start_time - inputs.move_lead_time + assert len(work) == 2 + assert {item.game_id for item in work} == {"g1", "g2"} + assert next(item for item in work if item.game_id == "g1").due_at == ( + first.start_time + timedelta(hours=2) + ) + + asyncio.run(exercise()) + + +def test_post_tipoff_sync_preserves_last_safe_starter_snapshot(tmp_path) -> None: # type: ignore[no-untyped-def] + """Never replace durable tipoff evidence with a later Sleeper roster view.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + game = _game("g1", start=NOW + timedelta(hours=2)) + before = _inputs( + _profile(starter_ids=("p1", "p2")), + schedule_results=(_schedule_result(game),), + ) + await sync_live_lock_in_opportunities( + before, + repository=repository, + observed_at=NOW, + ) + after_profile = replace( + _profile( + starter_ids=("p2", "p1"), + retrieved_at=game.start_time + timedelta(minutes=1), + ) + ) + after = replace(before, league_profile=after_profile) + + await sync_live_lock_in_opportunities( + after, + repository=repository, + observed_at=game.start_time + timedelta(minutes=1), + ) + + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert stored is not None + assert stored.slot_index == 0 + assert stored.slot_position == "PG" + assert stored.roster_evidence_at == before.league_profile.retrieved_at + + asyncio.run(exercise()) + + +def test_latest_pre_tipoff_nonstarter_snapshot_replaces_stale_slot(tmp_path) -> None: # type: ignore[no-untyped-def] + """Use Sleeper retrieval time when collection finishes after tipoff.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + game = _game("g1", start=NOW + timedelta(hours=2)) + before = _inputs( + _profile(starter_ids=("p1", "p2")), + schedule_results=(_schedule_result(game),), + ) + await sync_live_lock_in_opportunities( + before, + repository=repository, + observed_at=NOW, + ) + latest_profile = _profile( + starter_ids=(None, "p2"), + retrieved_at=game.start_time - timedelta(minutes=1), + ) + + await sync_live_lock_in_opportunities( + replace(before, league_profile=latest_profile), + repository=repository, + observed_at=game.start_time + timedelta(minutes=1), + ) + + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert stored is not None + assert stored.status is LockInOpportunityStatus.INELIGIBLE + assert stored.rostered_at_tipoff is False + assert stored.slot_index is None + assert stored.slot_position is None + assert stored.roster_evidence_at == latest_profile.retrieved_at + + asyncio.run(exercise()) + + +def test_missing_starter_or_conflicting_eligibility_suppresses_action(tmp_path) -> None: # type: ignore[no-untyped-def] + """Mark unsafe pre-tipoff evidence ineligible instead of inventing a slot.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + game = _game("g1", start=NOW + timedelta(hours=2)) + inputs = _inputs( + _profile(starter_ids=(None, "p2")), + schedule_results=(_schedule_result(game),), + ) + + await sync_live_lock_in_opportunities( + inputs, + repository=repository, + observed_at=NOW, + ) + + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert stored is not None + assert stored.status is LockInOpportunityStatus.INELIGIBLE + assert stored.rostered_at_tipoff is False + assert stored.slot_index is None + + asyncio.run(exercise()) + + +def test_postponed_or_canceled_games_require_reconciliation(tmp_path) -> None: # type: ignore[no-untyped-def] + """Unsafe schedule status must not be treated as a lockable player-game.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + postponed = _game("g1", start=NOW + timedelta(hours=2), status=GameStatus.POSTPONED) + canceled = _game("g2", start=NOW + timedelta(days=1), status=GameStatus.CANCELED) + inputs = _inputs(schedule_results=(_schedule_result(postponed, canceled),)) + + await sync_live_lock_in_opportunities( + inputs, + repository=repository, + observed_at=NOW, + ) + + first = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + second = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g2") + ) + assert first is not None + assert second is not None + assert first.status is LockInOpportunityStatus.RECONCILIATION_REQUIRED + assert second.status is LockInOpportunityStatus.RECONCILIATION_REQUIRED + + asyncio.run(exercise()) + + +def test_schedule_change_refreshes_start_and_deadline(tmp_path) -> None: # type: ignore[no-untyped-def] + """Daily planning must follow a moved tipoff while the watch is still open.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + original = _game("g1", start=NOW + timedelta(hours=2)) + later = _game("g2", start=NOW + timedelta(days=1)) + inputs = _inputs(schedule_results=(_schedule_result(original, later),)) + await sync_live_lock_in_opportunities( + inputs, + repository=repository, + observed_at=NOW, + ) + moved = replace(original, start_time=NOW + timedelta(hours=5)) + await sync_live_lock_in_opportunities( + replace(inputs, schedule_results=(_schedule_result(moved, later),)), + repository=repository, + observed_at=NOW + timedelta(minutes=10), + ) + + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + work = await repository.list_scheduled_work(kind=DueWorkKind.POSTGAME) + assert stored is not None + assert stored.scheduled_start == moved.start_time + assert stored.action_deadline == later.start_time - inputs.move_lead_time + assert stored.status is LockInOpportunityStatus.SCHEDULED + g1 = next(item for item in work if item.game_id == "g1") + assert g1.due_at == moved.start_time + timedelta(hours=2) + + asyncio.run(exercise()) diff --git a/tests/unit/test_lock_in_policy.py b/tests/unit/test_lock_in_policy.py index d635e22..6dfb763 100644 --- a/tests/unit/test_lock_in_policy.py +++ b/tests/unit/test_lock_in_policy.py @@ -126,6 +126,20 @@ def test_policy_locks_known_high_score_and_passes_for_future_upside() -> None: assert lock.information_version == "team-week-inputs-v1" +def test_policy_comparison_exposes_paired_scenarios_without_changing_decision() -> None: + """Expose scenario evidence while retaining the historical policy result.""" + + policy = ScoreMaximizingLockInPolicy(LockInPolicyConfig(scenario_count=20, seed=7)) + completed = _opportunity("p1", "g1", 10, actual=10, start_offset=-3) + state = _state((completed, _opportunity("p1", "g2", 1))) + + comparison = policy.compare_after_game(state, completed) + + assert comparison.decision == policy.decide_after_game(state, completed) + assert comparison.selected_terminal_scores == (10.0,) * 20 + assert comparison.counterfactual_terminal_scores == (1.0,) * 20 + + def test_policy_honors_exact_slot_indices_with_duplicate_positions() -> None: """Never place a completed score into a same-label slot it could not occupy.""" diff --git a/tests/unit/test_postgame_lock_in.py b/tests/unit/test_postgame_lock_in.py new file mode 100644 index 0000000..89e56bc --- /dev/null +++ b/tests/unit/test_postgame_lock_in.py @@ -0,0 +1,678 @@ +"""Direct ESPN postgame observation, stabilization, and live Lock-In advice.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import timedelta + +from test_notification_loop import RecordingSender +from test_planning_inputs import ( + NOW, + _freshness_policy, + _game, + _inputs, + _profile, + _schedule_result, + _snapshot, +) + +from sleeper_manager.domain.lock_in import LockInEvaluationKind, LockInOpportunityStatus +from sleeper_manager.domain.nba import ( + DataQualityReport, + DataQualityState, + GameStatus, + GameSummary, + PlayerBoxScore, + ProviderResult, + SourceMetadata, +) +from sleeper_manager.domain.projection import ProjectionDistribution +from sleeper_manager.domain.runtime_policy import default_runtime_policy +from sleeper_manager.domain.scoring import BoxScoreLine, ScoringPolicy +from sleeper_manager.notifications.dispatcher import NotificationDispatcher +from sleeper_manager.persistence.async_sqlite import AsyncSQLiteStateRepository +from sleeper_manager.persistence.base import RecommendationStatus +from sleeper_manager.persistence.lock_in_opportunities import LockInOpportunityKey +from sleeper_manager.workflows.lock_in_planning import sync_live_lock_in_opportunities +from sleeper_manager.workflows.notification_loop import NotificationLoop +from sleeper_manager.workflows.planning_inputs import LiveProjectionResult +from sleeper_manager.workflows.postgame_lock_in import ( + LOCK_IN_ACKNOWLEDGEMENT_KINDS, + LOCK_IN_DECISION_TYPE, + LOCK_IN_WARNING_TYPE, + run_postgame_lock_in, +) + +GAME_START = NOW + timedelta(hours=2) +POSTGAME = GAME_START + timedelta(hours=2) +SECOND_POLL = POSTGAME + timedelta(minutes=5) +LATER_START = NOW + timedelta(days=1) +POLICY = default_runtime_policy(history_version="history-v1") + + +def _expected_snapshot(player_id: str, game_id: str, expected: float): + """Build one projection snapshot with a deterministic expected value.""" + + base = _snapshot(player_id, game_id) + return replace( + base, + distribution=ProjectionDistribution( + expected_value=expected, + median=expected, + percentiles=((50, expected),), + lower_bound=expected, + upper_bound=expected, + variance=0, + ), + ) + + +def _live_inputs(*, scoring: ScoringPolicy | None = None): + """Return two-game live inputs with projections that admit a clear Lock.""" + + profile = _profile() if scoring is None else replace(_profile(), scoring=scoring) + first = _game("g1", start=GAME_START) + second = _game("g2", start=LATER_START) + return _inputs( + profile, + schedule_results=(_schedule_result(first, second),), + projections=( + LiveProjectionResult("p1", "g1", _expected_snapshot("p1", "g1", 20.0), None), + LiveProjectionResult("p1", "g2", _expected_snapshot("p1", "g2", 1.0), None), + LiveProjectionResult("p2", "g1", _expected_snapshot("p2", "g1", 8.0), None), + LiveProjectionResult("p2", "g2", _expected_snapshot("p2", "g2", 8.0), None), + ), + freshness_policy=_freshness_policy( + max_nba_schedule_age=timedelta(days=2), + max_availability_age=timedelta(days=2), + max_sleeper_age=timedelta(days=2), + ), + ) + + +def _box( + player_id: str, + *, + points: int = 0, + rebounds: int = 0, + did_play: bool = True, + retrieved_at=POSTGAME, +) -> PlayerBoxScore: + """Build one ESPN box-score row whose retrieval time is excluded from fingerprints.""" + + return PlayerBoxScore( + game_id="g1", + player_id=player_id, + team_id="12", + played_at=GAME_START, + started=True, + did_play=did_play, + minutes=30.0 if did_play else 0.0, + line=BoxScoreLine(points=points, rebounds=rebounds), + source=SourceMetadata(provider="espn", provider_id=player_id, retrieved_at=retrieved_at), + ) + + +def _summary_result( + *boxes: PlayerBoxScore, + status: GameStatus = GameStatus.FINAL, + quality: DataQualityState = DataQualityState.FRESH, + retrieved_at=POSTGAME, +) -> ProviderResult[GameSummary]: + """Return one direct provider summary used as a scheduled postgame poll.""" + + game = replace( + _game("g1", start=GAME_START, status=status), + finalized_at=POSTGAME if status is GameStatus.FINAL else None, + source=SourceMetadata(provider="espn", provider_id="g1", retrieved_at=retrieved_at), + ) + return ProviderResult( + GameSummary(game, boxes), + DataQualityReport( + state=quality, + resource="espn:game-summary:g1", + record_count=len(boxes), + retrieved_at=retrieved_at, + source_updated_at=None, + expires_at=None, + ), + ) + + +def _fetcher(result: ProviderResult[GameSummary]): + """Return a direct summary source that records each distinct scheduled wake.""" + + calls: list[str] = [] + + async def fetch(game_id: str) -> ProviderResult[GameSummary]: + calls.append(game_id) + return result + + fetch.calls = calls # type: ignore[attr-defined] + return fetch + + +async def _prepare(tmp_path): # type: ignore[no-untyped-def] + """Persist pre-tipoff evidence and a notification loop for postgame wakes.""" + + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + inputs = _live_inputs() + await sync_live_lock_in_opportunities(inputs, repository=repository, observed_at=NOW) + sender = RecordingSender() + notifications = NotificationLoop( + repository, + NotificationDispatcher(sender), + acknowledgement_base_url="https://example.test/ack", + clock=lambda: POSTGAME, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, + ) + return repository, inputs, notifications, sender + + +async def _run_poll(tmp_path, result, *, at=POSTGAME, poll_id="wake-1"): # type: ignore[no-untyped-def] + """Run one postgame wake against a prepared opportunity set.""" + + repository, inputs, notifications, sender = await _prepare(tmp_path) + outcome = await run_postgame_lock_in( + "g1", + inputs, + decision_time=at, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(result), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id=poll_id, + player_names={"p1": "Ann", "p2": "Ben"}, + ) + return repository, sender, outcome + + +def test_partial_or_in_progress_summary_waits_without_notification(tmp_path) -> None: # type: ignore[no-untyped-def] + """Incomplete ESPN evidence cannot count as a stable independent poll.""" + + async def exercise() -> None: + boxes = (_box("401", points=20), _box("402", points=8)) + for index, result in enumerate( + ( + _summary_result(*boxes, status=GameStatus.IN_PROGRESS), + _summary_result(*boxes, status=GameStatus.POSTPONED), + _summary_result(*boxes, status=GameStatus.CANCELED), + _summary_result(*boxes, quality=DataQualityState.PARTIAL), + _summary_result(*boxes, quality=DataQualityState.STALE), + _summary_result(_box("402", points=8)), + ) + ): + case_dir = tmp_path / str(index) + case_dir.mkdir() + repository, sender, outcome = await _run_poll(case_dir, result) + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert outcome.outcome == "wait" + assert stored is not None + assert stored.consecutive_direct_poll_count == 0 + assert sender.messages == [] + + asyncio.run(exercise()) + + +def test_two_identical_finals_notify_lock_with_acknowledgement_actions( + tmp_path, +) -> None: # type: ignore[no-untyped-def] + """Require two distinct scheduled wakes before emitting Lock advice.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + fetch = _fetcher( + _summary_result(_box("401", points=20), _box("402", points=8, did_play=False)) + ) + first = await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=fetch, + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + player_names={"p1": "Ann", "p2": "Ben"}, + ) + notifications._clock = lambda: SECOND_POLL + second = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=fetch, + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + player_names={"p1": "Ann", "p2": "Ben"}, + ) + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert first.outcome == "wait" + assert second.outcome == "notified" + assert second.evaluation is not None + assert second.evaluation.kind is LockInEvaluationKind.LOCK + assert stored is not None + assert stored.status is LockInOpportunityStatus.ACTIONABLE + assert stored.consecutive_direct_poll_count == 2 + assert stored.score_revision == 1 + assert len(sender.messages) == 1 + assert [action.label for action in sender.messages[0].actions] == [ + "Locked", + "Passed", + "Open Sleeper", + ] + assert second.recommendation is not None + assert second.recommendation.decision_type == LOCK_IN_DECISION_TYPE + notifications._clock = lambda: SECOND_POLL + timedelta(minutes=5) + retry = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL + timedelta(minutes=5), + repository=repository, + notifications=notifications, + fetch_summary=fetch, + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-3", + player_names={"p1": "Ann", "p2": "Ben"}, + ) + assert retry.outcome == "duplicate" + assert len(sender.messages) == 1 + + asyncio.run(exercise()) + + +def test_changed_fingerprint_resets_and_supersedes_unacknowledged_advice( + tmp_path, +) -> None: # type: ignore[no-untyped-def] + """A corrected box score returns to finalizing and replaces pending advice.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + first_summary = _summary_result(_box("401", points=20), _box("402", points=8)) + corrected = _summary_result(_box("401", points=24), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(first_summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + player_names={"p1": "Ann"}, + ) + notifications._clock = lambda: SECOND_POLL + await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(first_summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + player_names={"p1": "Ann"}, + ) + first_rec = ( + await repository.list_pending_recommendations( + "league-1", 1, decision_type=LOCK_IN_DECISION_TYPE + ) + )[0] + third_at = SECOND_POLL + timedelta(minutes=5) + notifications._clock = lambda: third_at + changed = await run_postgame_lock_in( + "g1", + inputs, + decision_time=third_at, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(corrected), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-3", + player_names={"p1": "Ann"}, + ) + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + superseded = await repository.get_recommendation(first_rec.recommendation_id) + assert changed.outcome == "wait" + assert stored is not None + assert stored.consecutive_direct_poll_count == 1 + assert stored.score_revision == 1 + assert stored.status is LockInOpportunityStatus.FINALIZING + assert superseded is not None + assert superseded.status is RecommendationStatus.SUPERSEDED + + asyncio.run(exercise()) + + +def test_material_policy_change_supersedes_previous_actionable_advice(tmp_path) -> None: # type: ignore[no-untyped-def] + """Keep only the recommendation produced by the current material evaluation.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + notifications._clock = lambda: SECOND_POLL + first = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + assert first.recommendation is not None + changed_policy = replace( + POLICY, + manager_intent=replace(POLICY.manager_intent, version="intent-v2"), + ) + third_at = SECOND_POLL + timedelta(minutes=5) + notifications._clock = lambda: third_at + + second = await run_postgame_lock_in( + "g1", + inputs, + decision_time=third_at, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=changed_policy, + open_sleeper_url="https://sleeper.com", + poll_id="wake-3", + ) + + old = await repository.get_recommendation(first.recommendation.recommendation_id) + pending = await repository.list_pending_recommendations( + "league-1", 1, decision_type=LOCK_IN_DECISION_TYPE + ) + assert second.recommendation is not None + assert second.recommendation.recommendation_id != first.recommendation.recommendation_id + assert old is not None and old.status is RecommendationStatus.SUPERSEDED + assert pending == (second.recommendation,) + assert len(sender.messages) == 2 + + asyncio.run(exercise()) + + +def test_unavailable_warning_omits_acknowledgement_buttons(tmp_path) -> None: # type: ignore[no-untyped-def] + """Unsafe evidence sends one warning without Locked or Passed actions.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + inputs = replace( + _live_inputs(), + projections=(), + ) + await sync_live_lock_in_opportunities(inputs, repository=repository, observed_at=NOW) + sender = RecordingSender() + notifications = NotificationLoop( + repository, + NotificationDispatcher(sender), + acknowledgement_base_url="https://example.test/ack", + clock=lambda: POSTGAME, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, + ) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + notifications._clock = lambda: SECOND_POLL + result = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + assert result.outcome == "unavailable" + assert result.evaluation is not None + assert result.evaluation.kind is LockInEvaluationKind.UNAVAILABLE + assert result.recommendation is not None + assert result.recommendation.decision_type == LOCK_IN_WARNING_TYPE + assert [action.label for action in sender.messages[0].actions] == ["Open Sleeper"] + + asyncio.run(exercise()) + + +def test_missing_current_mapping_emits_unavailable_warning(tmp_path) -> None: # type: ignore[no-untyped-def] + """Convert a disappeared live mapping into persisted non-actionable advice.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + unmapped = replace( + inputs, + identities=tuple( + replace(identity, provider_team_id=None) for identity in inputs.identities + ), + ) + notifications._clock = lambda: SECOND_POLL + + result = await run_postgame_lock_in( + "g1", + unmapped, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + + assert result.outcome == "unavailable" + assert result.evaluation is not None + assert result.evaluation.reason_codes == ("mapping_unavailable",) + assert len(sender.messages) == 2 + assert all(message.title.startswith("Lock-In unavailable") for message in sender.messages) + + asyncio.run(exercise()) + + +def test_actionable_delivery_failure_is_reported_for_retry(tmp_path) -> None: # type: ignore[no-untyped-def] + """Do not report a successful postgame attempt when delivery failed.""" + + async def exercise() -> None: + repository, inputs, notifications, sender = await _prepare(tmp_path) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + sender.fail = True + notifications._clock = lambda: SECOND_POLL + + result = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + + assert result.outcome == "delivery_failed" + assert result.recommendation is not None + assert len(sender.messages) == 1 + + asyncio.run(exercise()) + + +def test_scoring_bonuses_and_dnps_use_discovered_policy(tmp_path) -> None: # type: ignore[no-untyped-def] + """Apply Sleeper bonuses and still treat a DNP row as a present player.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + inputs = _live_inputs(scoring=ScoringPolicy(points=1, bonus_50_points=5)) + await sync_live_lock_in_opportunities(inputs, repository=repository, observed_at=NOW) + sender = RecordingSender() + notifications = NotificationLoop( + repository, + NotificationDispatcher(sender), + acknowledgement_base_url="https://example.test/ack", + clock=lambda: POSTGAME, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, + ) + summary = _summary_result( + _box("401", points=50), + _box("402", did_play=False), + ) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + notifications._clock = lambda: SECOND_POLL + await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + dnp = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p2", "g1") + ) + assert stored is not None and stored.stable_score == 55.0 + assert dnp is not None and dnp.stable_score == 0.0 + + asyncio.run(exercise()) + + +def test_final_eligible_game_is_automatic_without_notification(tmp_path) -> None: # type: ignore[no-untyped-def] + """Persist the last eligible score without sending Lock or Pass advice.""" + + async def exercise() -> None: + repository = AsyncSQLiteStateRepository(tmp_path / "state.db") + await repository.initialize() + inputs = _inputs( + schedule_results=(_schedule_result(_game("g1", start=GAME_START)),), + projections=( + LiveProjectionResult("p1", "g1", _expected_snapshot("p1", "g1", 20.0), None), + LiveProjectionResult("p2", "g1", _expected_snapshot("p2", "g1", 8.0), None), + ), + freshness_policy=_freshness_policy( + max_nba_schedule_age=timedelta(days=2), + max_availability_age=timedelta(days=2), + max_sleeper_age=timedelta(days=2), + ), + ) + await sync_live_lock_in_opportunities(inputs, repository=repository, observed_at=NOW) + sender = RecordingSender() + notifications = NotificationLoop( + repository, + NotificationDispatcher(sender), + acknowledgement_base_url="https://example.test/ack", + clock=lambda: POSTGAME, + acknowledgement_kinds=LOCK_IN_ACKNOWLEDGEMENT_KINDS, + ) + summary = _summary_result(_box("401", points=20), _box("402", points=8)) + await run_postgame_lock_in( + "g1", + inputs, + decision_time=POSTGAME, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-1", + ) + notifications._clock = lambda: SECOND_POLL + result = await run_postgame_lock_in( + "g1", + inputs, + decision_time=SECOND_POLL, + repository=repository, + notifications=notifications, + fetch_summary=_fetcher(summary), + runtime_policy=POLICY, + open_sleeper_url="https://sleeper.com", + poll_id="wake-2", + ) + stored = await repository.get_lock_in_opportunity( + LockInOpportunityKey("league-1", 1, 1, "p1", "g1") + ) + assert result.outcome == "automatic_final" + assert stored is not None + assert stored.status is LockInOpportunityStatus.AUTOMATIC_FINAL + assert stored.stable_score == 20.0 + assert sender.messages == [] + + asyncio.run(exercise()) diff --git a/tests/unit/test_runtime_policy.py b/tests/unit/test_runtime_policy.py index a767ccd..64a140e 100644 --- a/tests/unit/test_runtime_policy.py +++ b/tests/unit/test_runtime_policy.py @@ -1,3 +1,4 @@ +import json from datetime import time, timedelta import pytest @@ -48,7 +49,6 @@ def test_manager_intent_round_trips_through_runtime_policy_json() -> None: quiet_hours_start="22:30", quiet_hours_end="06:15", urgent_actions_override_quiet_hours=False, - protected_sleeper_ids=("player-1", "player-2"), version="abc123def4567890", ) policy = default_runtime_policy(history_version="history-2026").__class__( @@ -117,3 +117,26 @@ def test_manager_intent_round_trips_through_runtime_policy_json() -> None: def test_runtime_policy_rejects_invalid_payload(payload: str) -> None: with pytest.raises(RuntimePolicyError): RuntimePolicy.from_json("policy-v1", payload) + + +def test_manager_intent_accepts_empty_legacy_protected_ids() -> None: + """Permit one safe runtime resynchronization from the removed empty field.""" + + payload = default_runtime_policy(history_version="history-2026").to_json() + decoded = json.loads(payload) + decoded["manager_intent"]["protected_sleeper_ids"] = [] + + restored = RuntimePolicy.from_json("runtime-policy-v1", json.dumps(decoded)) + + assert restored.manager_intent == ManagerPolicy().to_manager_intent() + + +def test_manager_intent_rejects_nonempty_legacy_protected_ids() -> None: + """Fail closed when a removed preference would otherwise be ignored.""" + + payload = default_runtime_policy(history_version="history-2026").to_json() + decoded = json.loads(payload) + decoded["manager_intent"]["protected_sleeper_ids"] = ["player-1"] + + with pytest.raises(RuntimePolicyError, match="removed from version one"): + RuntimePolicy.from_json("runtime-policy-v1", json.dumps(decoded)) diff --git a/tests/unit/test_runtime_sync.py b/tests/unit/test_runtime_sync.py index 7db8102..f3fa046 100644 --- a/tests/unit/test_runtime_sync.py +++ b/tests/unit/test_runtime_sync.py @@ -116,7 +116,6 @@ def test_runtime_policy_for_history_translates_manager_intent(tmp_path) -> None: urgent_actions_override_quiet_hours = false [players] -protected_sleeper_ids = ["player-1"] mapping_overrides = { "sleeper-1" = "espn-1" } """, encoding="utf-8", @@ -130,5 +129,4 @@ def test_runtime_policy_for_history_translates_manager_intent(tmp_path) -> None: assert policy.manager_intent == manager_policy.to_manager_intent() assert policy.manager_intent.preset == "conservative" assert policy.manager_intent.minimum_confidence == 0.85 - assert policy.manager_intent.protected_sleeper_ids == ("player-1",) assert policy.manager_intent.version == manager_policy.version diff --git a/tests/unit/test_scheduled_dispatcher.py b/tests/unit/test_scheduled_dispatcher.py index 7328d55..f9386c6 100644 --- a/tests/unit/test_scheduled_dispatcher.py +++ b/tests/unit/test_scheduled_dispatcher.py @@ -18,12 +18,20 @@ _workflow, ) from test_notification_loop import RecordingSender +from test_postgame_lock_in import _box, _summary_result from sleeper_manager.cloudflare.dispatcher import dispatch_due_work from sleeper_manager.cloudflare.planning import CloudflarePlanningAssembly from sleeper_manager.cloudflare.runtime import run_scheduled from sleeper_manager.cloudflare.scheduler_types import FailureCategory, ScheduledRunStatus from sleeper_manager.decisions.weekly_plan import WeeklyPlanPolicyConfig +from sleeper_manager.domain.nba import ( + DataQualityReport, + DataQualityState, + GameStatus, + GameSummary, + ProviderResult, +) from sleeper_manager.domain.runtime_policy import default_runtime_policy from sleeper_manager.notifications.dispatcher import NotificationDispatcher from sleeper_manager.persistence.async_sqlite import AsyncSQLiteStateRepository @@ -180,6 +188,8 @@ async def exercise() -> None: later = next(item for item in pre_tipoff if item.game_id == "g2") assert later.status is ScheduledWorkStatus.PENDING assert later.due_at == LATER_GAME_START - POLICY.move_lead_time + postgame = await repository.list_scheduled_work(kind=DueWorkKind.POSTGAME) + assert {item.game_id for item in postgame} == {"g1", "g2"} asyncio.run(exercise()) @@ -379,3 +389,111 @@ async def fetcher(url: str): assert weekly == () asyncio.run(exercise()) + + +def test_due_postgame_watch_fetches_espn_summary_directly(tmp_path) -> None: # type: ignore[no-untyped-def] + """Claimed postgame work must poll ESPN once and keep the five-minute watch open.""" + + async def exercise() -> None: + repository, sender, notifications = _workflow(tmp_path, clock=lambda: NOW) + await repository.initialize() + await _save_policy(repository) + await _dispatch(repository, notifications, scheduled_at=NOW) + postgame_at = GAME_START + timedelta(hours=2) + fetches: list[str] = [] + + async def fetch(game_id: str) -> ProviderResult[GameSummary]: + fetches.append(game_id) + game = replace(_game(game_id), status=GameStatus.IN_PROGRESS, start_time=GAME_START) + return ProviderResult( + GameSummary(game, ()), + DataQualityReport( + state=DataQualityState.PARTIAL, + resource=f"espn:game-summary:{game_id}", + record_count=0, + retrieved_at=postgame_at, + source_updated_at=None, + expires_at=None, + ), + ) + + notifications._clock = lambda: postgame_at + collect, calls = _collector(_assembly(decision_time=postgame_at)) + summary = await dispatch_due_work( + repository, + notifications=notifications, + collect=collect, + scheduled_at=postgame_at, + correlation_id="postgame-1", + open_sleeper_url="https://sleeper.com/league", + plan_policy=PLAN_POLICY, + fetch_game_summary=fetch, + ) + postgame = await repository.list_scheduled_work(kind=DueWorkKind.POSTGAME) + g1 = next(item for item in postgame if item.game_id == "g1") + assert DueWorkKind.POSTGAME in {attempt.kind for attempt in summary.attempts} + assert fetches == ["g1"] + assert g1.status is ScheduledWorkStatus.RETRY + assert g1.due_at == postgame_at + timedelta(minutes=5) + lock_in = await repository.list_pending_recommendations( + "league-1", 1, decision_type="live_lock_in" + ) + assert lock_in == () + assert calls == [postgame_at] + + asyncio.run(exercise()) + + +def test_postgame_delivery_failure_remains_visible_and_retryable(tmp_path) -> None: # type: ignore[no-untyped-def] + """Record failed action delivery instead of reporting a successful watch.""" + + async def exercise() -> None: + repository, sender, notifications = _workflow(tmp_path, clock=lambda: NOW) + await repository.initialize() + await _save_policy(repository) + await _dispatch(repository, notifications, scheduled_at=NOW) + summary_result = _summary_result(_box("401", points=20), _box("402", points=8)) + first_at = GAME_START + timedelta(hours=2) + + async def fetch(game_id: str) -> ProviderResult[GameSummary]: + assert game_id == "g1" + return summary_result + + collect, _ = _collector(_assembly(decision_time=first_at)) + notifications._clock = lambda: first_at + await dispatch_due_work( + repository, + notifications=notifications, + collect=collect, + scheduled_at=first_at, + correlation_id="postgame-1", + open_sleeper_url="https://sleeper.com/league", + plan_policy=PLAN_POLICY, + fetch_game_summary=fetch, + ) + second_at = first_at + timedelta(minutes=5) + collect, _ = _collector(_assembly(decision_time=second_at)) + notifications._clock = lambda: second_at + sender.fail = True + + result = await dispatch_due_work( + repository, + notifications=notifications, + collect=collect, + scheduled_at=second_at, + correlation_id="postgame-2", + open_sleeper_url="https://sleeper.com/league", + plan_policy=PLAN_POLICY, + fetch_game_summary=fetch, + ) + + attempt = next(item for item in result.attempts if item.kind is DueWorkKind.POSTGAME) + work = await repository.list_scheduled_work(kind=DueWorkKind.POSTGAME) + g1 = next(item for item in work if item.game_id == "g1") + assert result.status is ScheduledRunStatus.DELIVERY_FAILED + assert attempt.outcome is ScheduledRunStatus.DELIVERY_FAILED + assert attempt.failure_category is FailureCategory.DELIVERY + assert g1.status is ScheduledWorkStatus.RETRY + assert g1.due_at == second_at + timedelta(minutes=5) + + asyncio.run(exercise())