diff --git a/backend/services/infonet/markets/snapshot.py b/backend/services/infonet/markets/snapshot.py index 5ab2208..5d8fa0d 100644 --- a/backend/services/infonet/markets/snapshot.py +++ b/backend/services/infonet/markets/snapshot.py @@ -24,6 +24,7 @@ from __future__ import annotations import hashlib import json +import math from typing import Any, Iterable @@ -32,6 +33,14 @@ def _payload(event: dict[str, Any]) -> dict[str, Any]: return p if isinstance(p, dict) else {} +def _finite_float(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError, OverflowError): + return None + return parsed if math.isfinite(parsed) else None + + def _events_for_market(market_id: str, chain: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for ev in chain: @@ -61,6 +70,10 @@ def build_snapshot( to advance to EVIDENCE. Pass it explicitly so the function stays pure and deterministic. """ + frozen_at_value = _finite_float(frozen_at) + if frozen_at_value is None: + raise ValueError("frozen_at must be finite") + events = _events_for_market(market_id, chain) predictor_ids: list[str] = [] @@ -79,27 +92,29 @@ def build_snapshot( side = p.get("side") if side not in ("yes", "no"): continue + + stake = p.get("stake_amount") + if stake is None: + weight = 1.0 # Free pick = 1.0 virtual stake (RULES §5.2). + staked_amount = 0.0 + else: + parsed_stake = _finite_float(stake) + if parsed_stake is None or parsed_stake <= 0: + # Invalid paid predictions must not inflate participant + # counts or poison the frozen probability state. + continue + weight = parsed_stake + staked_amount = parsed_stake + if node not in seen_predictors: seen_predictors.add(node) predictor_ids.append(node) - stake = p.get("stake_amount") - if stake is not None: - try: - a = float(stake) - except (TypeError, ValueError): - a = 0.0 - if a > 0: - total_stake += a - if side == "yes": - yes_weight += a - else: - no_weight += a + + total_stake += staked_amount + if side == "yes": + yes_weight += weight else: - # Free pick = 1.0 virtual stake (RULES §5.2). - if side == "yes": - yes_weight += 1.0 - else: - no_weight += 1.0 + no_weight += weight pool = yes_weight + no_weight if pool > 0: @@ -114,7 +129,7 @@ def build_snapshot( "frozen_total_stake": total_stake, "frozen_predictor_ids": predictor_ids, "frozen_probability_state": {"yes": yes_p, "no": no_p}, - "frozen_at": float(frozen_at), + "frozen_at": frozen_at_value, } diff --git a/backend/services/infonet/tests/test_snapshot_invalid_numerics.py b/backend/services/infonet/tests/test_snapshot_invalid_numerics.py new file mode 100644 index 0000000..cf2f7a7 --- /dev/null +++ b/backend/services/infonet/tests/test_snapshot_invalid_numerics.py @@ -0,0 +1,64 @@ +"""Regression coverage for malformed/non-finite market snapshot numerics.""" + +import math +from typing import Any + +import pytest + +from services.infonet.markets.snapshot import build_snapshot + + +def _prediction( + node: str, + side: str, + stake: Any, + *, + timestamp: float, + sequence: int, +) -> dict[str, Any]: + payload: dict[str, Any] = {"market_id": "m1", "side": side} + if stake is not None: + payload["stake_amount"] = stake + return { + "event_type": "prediction_place", + "node_id": node, + "timestamp": timestamp, + "sequence": sequence, + "payload": payload, + } + + +@pytest.mark.parametrize("invalid_stake", [float("nan"), float("inf"), "not-a-number", -1.0, 0.0]) +def test_invalid_paid_stake_does_not_poison_or_count_snapshot(invalid_stake: Any) -> None: + chain = [ + _prediction("alice", "yes", None, timestamp=100.0, sequence=1), + _prediction("mallory", "no", invalid_stake, timestamp=101.0, sequence=2), + ] + + snapshot = build_snapshot("m1", chain, frozen_at=200.0) + + assert snapshot["frozen_participant_count"] == 1 + assert snapshot["frozen_predictor_ids"] == ["alice"] + assert snapshot["frozen_total_stake"] == 0.0 + assert snapshot["frozen_probability_state"] == {"yes": 1.0, "no": 0.0} + assert all(math.isfinite(v) for v in snapshot["frozen_probability_state"].values()) + + +def test_finite_numeric_string_stake_is_preserved() -> None: + chain = [ + _prediction("alice", "yes", "2.5", timestamp=100.0, sequence=1), + _prediction("bob", "no", "7.5", timestamp=101.0, sequence=2), + ] + + snapshot = build_snapshot("m1", chain, frozen_at="200.5") + + assert snapshot["frozen_participant_count"] == 2 + assert snapshot["frozen_total_stake"] == 10.0 + assert snapshot["frozen_probability_state"] == {"yes": 0.25, "no": 0.75} + assert snapshot["frozen_at"] == 200.5 + + +@pytest.mark.parametrize("invalid_frozen_at", [float("nan"), float("inf"), "not-a-time"]) +def test_nonfinite_or_malformed_frozen_at_is_rejected(invalid_frozen_at: Any) -> None: + with pytest.raises(ValueError, match="frozen_at must be finite"): + build_snapshot("m1", [], frozen_at=invalid_frozen_at)