Files
Shadowbroker/backend/services/infonet/markets/lifecycle.py
T

166 lines
5.9 KiB
Python

"""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",
]