"""Market lifecycle state machine. Source of truth: ``infonet-economy/RULES_SKELETON.md`` §5.2 + §3.10. Five logical statuses: PREDICTING — open for predictions; no snapshot yet. EVIDENCE — snapshot frozen; evidence window open (CONFIG['evidence_window_hours']). RESOLVING — evidence window closed; resolution staking window open (CONFIG['resolution_window_hours']). FINAL — resolution_finalize event landed with a real outcome. INVALID — resolution_finalize event landed with outcome="invalid". Transitions are decided by ``chain_majority_time`` (per RULES §3.14 Rule 3) — no single node's local clock can unilaterally advance a market. That rule keeps producers honest even when network partitions shift local time. """ from __future__ import annotations from enum import Enum from typing import Any, Iterable from services.infonet.config import CONFIG from services.infonet.markets.event_selection import ( events_for_market, finite_float, first_authoritative_event, has_valid_ordering, payload, ) class MarketStatus(str, Enum): PREDICTING = "predicting" EVIDENCE = "evidence" RESOLVING = "resolving" FINAL = "final" INVALID = "invalid" _SECONDS_PER_HOUR = 3600.0 def _snapshot_time(snapshot: dict[str, Any]) -> float | None: """Return a valid snapshot time without masking malformed metadata.""" if not has_valid_ordering(snapshot): return None raw_timestamp = snapshot.get("timestamp") # Preserve the pre-existing fallback only for an actually absent/zero # timestamp. A present-but-malformed timestamp must fail closed instead # of being disguised by a valid payload frozen_at. if raw_timestamp in (None, "", 0, 0.0): return finite_float(payload(snapshot).get("frozen_at")) return finite_float(raw_timestamp) def compute_market_status( market_id: str, chain: Iterable[dict[str, Any]], *, now: float, ) -> MarketStatus: """Return the current status of ``market_id`` at chain time ``now``. Status is derived from the chain — it's never stored. The producer that emits ``market_snapshot`` and ``resolution_finalize`` events is responsible for using the same ``now`` value (typically ``chain_majority_time(chain)``) so every node converges on the same status. """ events = events_for_market(market_id, chain) if not events: return MarketStatus.PREDICTING # treated as not-yet-existing create_event = first_authoritative_event(events, "prediction_create") if create_event is None or not has_valid_ordering(create_event): return MarketStatus.PREDICTING # The first finalize remains authoritative by chain order. A malformed # first finalize cannot exert terminal authority, and a later finalize # must not replace it; projection therefore falls through to the prior # snapshot-backed phase. finalize = first_authoritative_event(events, "resolution_finalize") if finalize is not None and has_valid_ordering(finalize): outcome = payload(finalize).get("outcome") return MarketStatus.INVALID if outcome == "invalid" else MarketStatus.FINAL # Likewise, never let a later snapshot replace the first commitment. snapshot = first_authoritative_event(events, "market_snapshot") if snapshot is None: return MarketStatus.PREDICTING snapshot_ts = _snapshot_time(snapshot) if snapshot_ts is None: return MarketStatus.PREDICTING evidence_close = snapshot_ts + float(CONFIG["evidence_window_hours"]) * _SECONDS_PER_HOUR if now < evidence_close: return MarketStatus.EVIDENCE return MarketStatus.RESOLVING def should_advance_phase( market_id: str, chain: Iterable[dict[str, Any]], *, now: float, ) -> tuple[MarketStatus, MarketStatus] | None: """If a phase advance is due, return ``(current, next)``. Else ``None``. The producer should call this on a heartbeat and emit the appropriate event when a transition is ready: - PREDICTING → EVIDENCE: emit ``market_snapshot``. - EVIDENCE → RESOLVING: just a status change (no chain event). - RESOLVING → FINAL/INVALID: emit ``resolution_finalize``. """ events = events_for_market(market_id, chain) if not events: return None create_event = first_authoritative_event(events, "prediction_create") if create_event is None or not has_valid_ordering(create_event): return None finalize = first_authoritative_event(events, "resolution_finalize") if finalize is not None: # Whether valid (terminal) or malformed (fail closed), do not let a # later finalize replace the first authoritative chain event. return None create_payload = payload(create_event) trigger_date = finite_float(create_payload.get("trigger_date")) snapshot = first_authoritative_event(events, "market_snapshot") if snapshot is None: # PREDICTING — advance to EVIDENCE iff trigger_date has passed in # majority chain time. Invalid trigger metadata cannot authorize # a phase transition. if trigger_date is not None and now >= trigger_date: return (MarketStatus.PREDICTING, MarketStatus.EVIDENCE) return None snapshot_ts = _snapshot_time(snapshot) if snapshot_ts is None: # An invalid first snapshot cannot authorize a transition, and a # later snapshot must not replace the commitment boundary. return None evidence_close = snapshot_ts + float(CONFIG["evidence_window_hours"]) * _SECONDS_PER_HOUR resolution_close = evidence_close + float(CONFIG["resolution_window_hours"]) * _SECONDS_PER_HOUR if now < evidence_close: return None # still EVIDENCE if now < resolution_close: return (MarketStatus.EVIDENCE, MarketStatus.RESOLVING) return (MarketStatus.RESOLVING, MarketStatus.FINAL) __all__ = [ "MarketStatus", "compute_market_status", "should_advance_phase", ]