mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 17:42:29 +02:00
326 lines
14 KiB
Python
326 lines
14 KiB
Python
"""Sprint 4 — resolution + predictor exclusion + first-submitter bonus.
|
|
|
|
Maps to IMPLEMENTATION_PLAN §7.1 Sprint 4 row:
|
|
- "Predictor cannot stake in resolution."
|
|
- "Snapshot is immutable." (covered in test_4_snapshot.py)
|
|
- "First evidence per side gets bonus."
|
|
- "Zero evidence → INVALID."
|
|
- "Winning-side evidence required for FINAL."
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from services.infonet.markets import (
|
|
collect_resolution_stakes,
|
|
evidence_content_hash,
|
|
excluded_predictor_ids,
|
|
is_predictor_excluded,
|
|
resolve_market,
|
|
submission_hash,
|
|
)
|
|
from services.infonet.tests._chain_factory import make_event
|
|
|
|
|
|
def _create(market_id: str, base_ts: float, *, market_type: str = "objective",
|
|
bootstrap_index: int | None = None) -> dict:
|
|
payload = {"market_id": market_id, "market_type": market_type,
|
|
"question": "?", "trigger_date": base_ts + 100, "creation_bond": 3}
|
|
if bootstrap_index is not None:
|
|
payload["bootstrap_index"] = bootstrap_index
|
|
return make_event("prediction_create", "creator", payload, timestamp=base_ts, sequence=1)
|
|
|
|
|
|
def _place(market_id: str, node_id: str, side: str, *, ts: float, seq: int,
|
|
stake: float | None = None, prob: float = 50.0) -> dict:
|
|
payload = {"market_id": market_id, "side": side, "probability_at_bet": prob}
|
|
if stake is not None:
|
|
payload["stake_amount"] = stake
|
|
return make_event("prediction_place", node_id, payload, timestamp=ts, sequence=seq)
|
|
|
|
|
|
def _snapshot(market_id: str, frozen_at: float, *, predictors: list[str], seq: int = 50) -> dict:
|
|
return make_event(
|
|
"market_snapshot", "creator",
|
|
{"market_id": market_id, "frozen_participant_count": len(predictors),
|
|
"frozen_total_stake": 20.0, "frozen_predictor_ids": list(predictors),
|
|
"frozen_probability_state": {"yes": 0.5, "no": 0.5},
|
|
"frozen_at": frozen_at},
|
|
timestamp=frozen_at, sequence=seq,
|
|
)
|
|
|
|
|
|
def _evidence(market_id: str, node_id: str, outcome: str, *,
|
|
ts: float, seq: int, bond: float = 2.0,
|
|
hashes: list[str] | None = None, desc: str = "src") -> dict:
|
|
h = hashes if hashes is not None else [f"ev-{node_id}-{outcome}"]
|
|
chash = evidence_content_hash(market_id, outcome, h, desc)
|
|
shash = submission_hash(chash, node_id, ts)
|
|
return make_event(
|
|
"evidence_submit", node_id,
|
|
{"market_id": market_id, "claimed_outcome": outcome,
|
|
"evidence_hashes": h, "source_description": desc,
|
|
"evidence_content_hash": chash, "submission_hash": shash, "bond": bond},
|
|
timestamp=ts, sequence=seq,
|
|
)
|
|
|
|
|
|
def _stake(market_id: str, node_id: str, side: str, amount: float, *,
|
|
ts: float, seq: int, rep_type: str = "oracle") -> dict:
|
|
return make_event(
|
|
"resolution_stake", node_id,
|
|
{"market_id": market_id, "side": side, "amount": amount, "rep_type": rep_type},
|
|
timestamp=ts, sequence=seq,
|
|
)
|
|
|
|
|
|
# ── Predictor exclusion ─────────────────────────────────────────────────
|
|
|
|
def test_predictor_in_snapshot_is_excluded():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_place("m1", "alice", "yes", ts=10, seq=2, stake=10.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=["alice"]),
|
|
]
|
|
assert is_predictor_excluded("alice", "m1", chain)
|
|
assert not is_predictor_excluded("bob", "m1", chain)
|
|
|
|
|
|
def test_rotation_descendant_inherits_exclusion():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_place("m1", "alice", "yes", ts=10, seq=2, stake=10.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=["alice"]),
|
|
# alice rotates to alice2 AFTER snapshot. The rotation is signed
|
|
# by the new identity per spec.
|
|
make_event("identity_rotate", "alice2",
|
|
{"old_node_id": "alice", "old_public_key": "pk",
|
|
"old_public_key_algo": "ed25519",
|
|
"new_public_key": "pk2", "new_public_key_algo": "ed25519",
|
|
"old_signature": "sig"},
|
|
timestamp=200.0, sequence=99),
|
|
]
|
|
excluded = excluded_predictor_ids("m1", chain)
|
|
assert "alice" in excluded
|
|
assert "alice2" in excluded
|
|
assert is_predictor_excluded("alice2", "m1", chain)
|
|
|
|
|
|
def test_resolution_stake_from_excluded_predictor_dropped():
|
|
base = 0.0
|
|
chain = [
|
|
_create("m1", base),
|
|
_place("m1", "alice", "yes", ts=10, seq=2, stake=10.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=["alice"]),
|
|
# alice tries to stake on her own market.
|
|
_stake("m1", "alice", "yes", 5.0, ts=200, seq=3),
|
|
# bob is a clean external resolver.
|
|
_stake("m1", "bob", "yes", 15.0, ts=201, seq=4),
|
|
]
|
|
stakes = collect_resolution_stakes("m1", chain, exclude_predictors=True)
|
|
nodes = {s.node_id for s in stakes}
|
|
assert "alice" not in nodes
|
|
assert "bob" in nodes
|
|
|
|
|
|
# ── Zero-evidence INVALID ────────────────────────────────────────────────
|
|
|
|
def test_zero_evidence_resolves_to_invalid():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_place("m1", "alice", "yes", ts=10, seq=2, stake=10.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=["alice"]),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "no_evidence"
|
|
|
|
|
|
def test_zero_evidence_returns_resolution_stakes():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_stake("m1", "bob", "yes", 5.0, ts=200, seq=3),
|
|
_stake("m1", "carol", "no", 5.0, ts=201, seq=4),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.stake_returns[("bob", "oracle")] == 5.0
|
|
assert result.stake_returns[("carol", "oracle")] == 5.0
|
|
|
|
|
|
# ── Winning-side evidence required ───────────────────────────────────────
|
|
|
|
def test_no_winning_side_evidence_resolves_to_invalid():
|
|
"""Resolution stakers reach 100% yes, but only "no" evidence exists."""
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "no", ts=110, seq=10),
|
|
_stake("m1", "bob", "yes", 25.0, ts=200, seq=20),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "no_winning_side_evidence"
|
|
|
|
|
|
def test_winning_side_evidence_present_resolves_final():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
_stake("m1", "bob", "yes", 25.0, ts=200, seq=20),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "yes"
|
|
assert result.reason.startswith("supermajority_")
|
|
|
|
|
|
# ── Below min resolution stake ───────────────────────────────────────────
|
|
|
|
def test_below_min_resolution_stake_is_invalid():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
# Only 5.0 oracle staked — below default min 20.0.
|
|
_stake("m1", "bob", "yes", 5.0, ts=200, seq=20),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "below_min_resolution_stake"
|
|
|
|
|
|
def test_no_supermajority_is_invalid():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
_evidence("m1", "ev2", "no", ts=120, seq=11),
|
|
_stake("m1", "bob", "yes", 12.0, ts=200, seq=20),
|
|
_stake("m1", "carol", "no", 12.0, ts=201, seq=21),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "no_supermajority"
|
|
|
|
|
|
# ── First-submitter bonus ────────────────────────────────────────────────
|
|
|
|
def test_first_submitter_gets_bonus_capped_at_losing_pool():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
# Two yes-side evidences, alice first.
|
|
_evidence("m1", "alice", "yes", ts=110, seq=10, bond=2.0),
|
|
_evidence("m1", "bob", "yes", ts=111, seq=11, bond=2.0),
|
|
# One losing-side evidence (no) — bond becomes the bonus pool.
|
|
_evidence("m1", "carol", "no", ts=112, seq=12, bond=2.0),
|
|
# Heavy yes resolution stakes.
|
|
_stake("m1", "dan", "yes", 25.0, ts=200, seq=20),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "yes"
|
|
# alice is the first yes-evidence submitter — eligible for bonus
|
|
# capped by losing pool (2.0) and CONFIG['evidence_first_bonus'] (0.5).
|
|
assert "alice" in result.first_submitter_bonuses
|
|
assert result.first_submitter_bonuses["alice"] == 0.5
|
|
# bob is NOT first.
|
|
assert "bob" not in result.first_submitter_bonuses
|
|
# carol's losing bond is forfeited.
|
|
assert result.bond_forfeits.get("carol") == 2.0
|
|
|
|
|
|
def test_first_submitter_bonus_capped_when_losing_pool_empty():
|
|
"""If no losing-side evidence exists, the bonus pool is empty and
|
|
the first submitter receives 0 bonus (NOT minted)."""
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "alice", "yes", ts=110, seq=10, bond=2.0),
|
|
_stake("m1", "bob", "yes", 25.0, ts=200, seq=20),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "yes"
|
|
# alice's bond is returned but no bonus paid.
|
|
assert result.bond_returns.get("alice") == 2.0
|
|
assert "alice" not in result.first_submitter_bonuses
|
|
|
|
|
|
# ── Stake distribution + 2% loser burn ───────────────────────────────────
|
|
|
|
def test_winning_stakes_split_loser_pool_with_2pct_burn():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
# 30 yes vs 8 no → 30/38 ≈ 0.789 > 0.75 supermajority.
|
|
_stake("m1", "alice", "yes", 30.0, ts=200, seq=20),
|
|
_stake("m1", "loser", "no", 8.0, ts=201, seq=21),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "yes"
|
|
# Loser pool 8. Burn 2% = 0.16. Distributable 7.84. alice has 100%
|
|
# of winner pool (30/30) → alice winnings = 7.84.
|
|
assert abs(result.stake_winnings.get(("alice", "oracle"), 0.0) - 7.84) < 1e-9
|
|
assert result.stake_returns.get(("alice", "oracle"), 0.0) == 30.0
|
|
# loser doesn't get returns.
|
|
assert ("loser", "oracle") not in result.stake_returns
|
|
assert abs(result.burned_amount - 0.16) < 1e-9
|
|
|
|
|
|
# ── Subjective markets resolve but mint no oracle rep ────────────────────
|
|
|
|
def test_subjective_market_resolves_but_oracle_rep_gates_zero():
|
|
"""resolve_market returns the outcome for subjective markets, but
|
|
oracle_rep._market_is_mintable should still return False (Sprint 2
|
|
invariant). Cross-check: the reputation view stays at zero."""
|
|
from services.infonet.reputation import compute_oracle_rep
|
|
chain = [
|
|
_create("m1", 0.0, market_type="subjective"),
|
|
_place("m1", "alice", "yes", ts=10, seq=2),
|
|
_snapshot("m1", frozen_at=100.0, predictors=["alice"]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
_stake("m1", "bob", "yes", 25.0, ts=200, seq=20),
|
|
# Producer would emit resolution_finalize here based on result.
|
|
make_event("resolution_finalize", "creator",
|
|
{"market_id": "m1", "outcome": "yes",
|
|
"is_provisional": False, "snapshot_event_hash": "h"},
|
|
timestamp=300.0, sequence=99),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "yes" # subjective still resolves
|
|
assert compute_oracle_rep("alice", chain) == 0 # but mints zero
|
|
|
|
|
|
# ── Bootstrap markets defer to Sprint 8 ──────────────────────────────────
|
|
|
|
def test_bootstrap_market_without_votes_is_below_min_participation():
|
|
"""Sprint 8: bootstrap markets resolve via eligible-node-one-vote.
|
|
A bootstrap market with no votes fails the min_market_participants
|
|
gate → INVALID with reason='bootstrap_below_min_participation'.
|
|
"""
|
|
chain = [
|
|
_create("m1", 0.0, bootstrap_index=1),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "bootstrap_below_min_participation"
|
|
|
|
|
|
# ── DA threshold detection ───────────────────────────────────────────────
|
|
|
|
def test_data_unavailable_threshold_invalidates():
|
|
chain = [
|
|
_create("m1", 0.0),
|
|
_snapshot("m1", frozen_at=100.0, predictors=[]),
|
|
_evidence("m1", "ev1", "yes", ts=110, seq=10),
|
|
# 35% DA in oracle stake — above default 33% threshold.
|
|
_stake("m1", "da1", "data_unavailable", 10.0, ts=200, seq=20),
|
|
_stake("m1", "yes1", "yes", 19.0, ts=201, seq=21),
|
|
]
|
|
result = resolve_market("m1", chain)
|
|
assert result.outcome == "invalid"
|
|
assert result.reason == "data_unavailable"
|