mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-13 09:37:53 +02:00
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Oracle rep computation — pure functions over the chain.
|
|
|
|
Source of truth: ``infonet-economy/RULES_SKELETON.md`` §3.1, §3.2, §3.11.
|
|
|
|
Constitutional anchor (``IMMUTABLE_PRINCIPLES["oracle_rep_source"] ==
|
|
"predictions_only"``): oracle rep may ONLY be minted by verified
|
|
predictions against reality. Sprint 2 enforces this by structurally
|
|
constraining the mint formula — there is no other code path that
|
|
returns a positive contribution to ``compute_oracle_rep``.
|
|
|
|
A market's prediction mints oracle rep only when ALL of the following
|
|
hold:
|
|
|
|
- The market produced a ``resolution_finalize`` event with
|
|
``outcome != "invalid"`` and ``is_provisional == False``.
|
|
- The corresponding ``market_snapshot`` shows
|
|
``frozen_participant_count >= CONFIG["min_market_participants"]`` AND
|
|
``frozen_total_stake >= CONFIG["min_market_total_stake"]``.
|
|
- The market is NOT a bootstrap-mode market (Sprint 8 will add the
|
|
bootstrap path; until then bootstrap markets contribute zero).
|
|
- The market is objective. Subjective markets mint Common Rep only
|
|
(RULES §3.1).
|
|
- The prediction's ``side`` matches the FINAL outcome.
|
|
|
|
Lost stakes from incorrect *staked* predictions reduce the running
|
|
``oracle_rep`` balance (RULES §3.2 — the staked amount is forfeited to
|
|
the winner pool). ``oracle_rep_lifetime`` is monotonically increasing
|
|
and ignores losses.
|
|
|
|
Sprint 2 does NOT yet handle:
|
|
|
|
- Dispute reversal (Sprint 5 — `dispute_resolve` with `outcome="reversed"`).
|
|
- Resolution-stake redistribution (Sprint 4/5 — `resolution_stake`
|
|
events and the loser-pool burn).
|
|
- Anti-gaming farming multipliers (Sprint 3).
|
|
|
|
These layers will be added by their owning sprints; the function
|
|
signature and return shape are stable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Iterable
|
|
|
|
from services.infonet.config import CONFIG
|
|
from services.infonet.markets.dispute import effective_outcome as _effective_outcome
|
|
from services.infonet.reputation.anti_gaming.farming import (
|
|
compute_farming_pct,
|
|
farming_multiplier,
|
|
)
|
|
|
|
|
|
def _as_event_list(chain: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Accept any iterable, return a stable list ordered by (timestamp, sequence)."""
|
|
events = [e for e in chain if isinstance(e, dict)]
|
|
events.sort(key=lambda e: (float(e.get("timestamp") or 0.0), int(e.get("sequence") or 0)))
|
|
return events
|
|
|
|
|
|
def _payload(event: dict[str, Any]) -> dict[str, Any]:
|
|
p = event.get("payload")
|
|
return p if isinstance(p, dict) else {}
|
|
|
|
|
|
@dataclass
|
|
class _MarketView:
|
|
"""Internal: chain-derived view of a single market.
|
|
|
|
Populated in one pass over the chain. Holds only what oracle_rep
|
|
needs to mint correctly per RULES §3.1/§3.2.
|
|
"""
|
|
market_id: str
|
|
market_type: str = "objective"
|
|
bootstrap_index: int | None = None
|
|
snapshot: dict[str, Any] | None = None
|
|
finalize: dict[str, Any] | None = None
|
|
finalize_ts: float = 0.0
|
|
predictions: list[dict[str, Any]] = field(default_factory=list)
|
|
farming_pct_lookup: dict[str, float] = field(default_factory=dict)
|
|
|
|
|
|
def _index_markets(events: list[dict[str, Any]]) -> dict[str, _MarketView]:
|
|
markets: dict[str, _MarketView] = {}
|
|
for ev in events:
|
|
et = ev.get("event_type")
|
|
p = _payload(ev)
|
|
mid = p.get("market_id")
|
|
if not isinstance(mid, str) or not mid:
|
|
continue
|
|
m = markets.setdefault(mid, _MarketView(market_id=mid))
|
|
if et == "prediction_create":
|
|
m.market_type = str(p.get("market_type") or "objective")
|
|
if "bootstrap_index" in p and p["bootstrap_index"] is not None:
|
|
try:
|
|
m.bootstrap_index = int(p["bootstrap_index"])
|
|
except (TypeError, ValueError):
|
|
m.bootstrap_index = None
|
|
elif et == "market_snapshot":
|
|
m.snapshot = p
|
|
elif et == "resolution_finalize":
|
|
m.finalize = p
|
|
m.finalize_ts = float(ev.get("timestamp") or 0.0)
|
|
elif et == "prediction_place":
|
|
m.predictions.append({
|
|
"node_id": ev.get("node_id"),
|
|
"side": p.get("side"),
|
|
"stake_amount": p.get("stake_amount"),
|
|
"probability_at_bet": p.get("probability_at_bet"),
|
|
"timestamp": ev.get("timestamp"),
|
|
})
|
|
return markets
|
|
|
|
|
|
def _market_passes_liquidity(market: _MarketView) -> bool:
|
|
snap = market.snapshot or {}
|
|
try:
|
|
participants = int(snap.get("frozen_participant_count") or 0)
|
|
total_stake = float(snap.get("frozen_total_stake") or 0.0)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return (
|
|
participants >= int(CONFIG["min_market_participants"])
|
|
and total_stake >= float(CONFIG["min_market_total_stake"])
|
|
)
|
|
|
|
|
|
def _market_is_mintable(market: _MarketView) -> bool:
|
|
"""Return True if the market is final, non-provisional, non-bootstrap,
|
|
objective, and passed liquidity. Mintable markets contribute oracle rep
|
|
to correct predictors.
|
|
"""
|
|
finalize = market.finalize
|
|
if not finalize:
|
|
return False
|
|
if finalize.get("is_provisional") is not False:
|
|
return False
|
|
outcome = finalize.get("outcome")
|
|
if outcome not in ("yes", "no"):
|
|
return False
|
|
if market.market_type != "objective":
|
|
return False
|
|
# Sprint 8: bootstrap markets that resolved via eligible-node-one-vote
|
|
# mint oracle rep from correct predictions, same as normal markets.
|
|
# The bootstrap mechanic only changes HOW resolution decides yes/no —
|
|
# not whether predictors get rep for being correct. RULES §3.10 step
|
|
# 0.5: "Oracle rep minted normally from correct predictions
|
|
# (constitutional)".
|
|
if not _market_passes_liquidity(market):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _free_pred_mint(probability_at_bet: float) -> float:
|
|
"""RULES §3.1 — mint = max(oracle_min_earned, 1.0 - p/100)."""
|
|
if probability_at_bet is None:
|
|
return 0.0
|
|
try:
|
|
prob = float(probability_at_bet)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
if not (0.0 <= prob <= 100.0):
|
|
return 0.0
|
|
return max(float(CONFIG["oracle_min_earned"]), 1.0 - (prob / 100.0))
|
|
|
|
|
|
def _staked_pred_settlement(
|
|
stake_amount: float,
|
|
side: str,
|
|
outcome: str,
|
|
predictions: list[dict[str, Any]],
|
|
) -> float:
|
|
"""RULES §3.2 — pool settlement for staked predictions.
|
|
|
|
Returns the *net* change to oracle rep for a single staked
|
|
prediction. Positive = winnings (returned stake + share of loser
|
|
pool). Negative = forfeited stake.
|
|
"""
|
|
winning_side = outcome
|
|
losing_side = "no" if outcome == "yes" else "yes"
|
|
winner_pool = 0.0
|
|
loser_pool = 0.0
|
|
for pred in predictions:
|
|
amt = pred.get("stake_amount")
|
|
if amt is None:
|
|
continue
|
|
try:
|
|
a = float(amt)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if a <= 0:
|
|
continue
|
|
if pred.get("side") == winning_side:
|
|
winner_pool += a
|
|
elif pred.get("side") == losing_side:
|
|
loser_pool += a
|
|
|
|
if side == winning_side:
|
|
if winner_pool == 0.0:
|
|
return float(stake_amount) # degenerate — return stake
|
|
if loser_pool == 0.0:
|
|
return float(stake_amount) # everyone won — no profit
|
|
share = float(stake_amount) / winner_pool
|
|
winnings = share * loser_pool
|
|
return float(stake_amount) + winnings
|
|
elif side == losing_side:
|
|
return -float(stake_amount)
|
|
return 0.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OracleRepBreakdown:
|
|
"""Auditable breakdown of how a node arrived at its oracle_rep balance.
|
|
|
|
Useful for the UI's reputation-history view and for invariant tests.
|
|
Sprint 4+ extensions will add resolution-stake redistribution and
|
|
dispute-reversal adjustments to this struct.
|
|
"""
|
|
free_prediction_mints: float
|
|
staked_prediction_returns: float
|
|
staked_prediction_losses: float
|
|
total: float
|
|
|
|
|
|
def compute_oracle_rep_breakdown(
|
|
node_id: str,
|
|
chain: Iterable[dict[str, Any]],
|
|
) -> OracleRepBreakdown:
|
|
"""Per-component breakdown — exposed for tests and audit trails.
|
|
|
|
Sprint 3 wiring: applies the farming multiplier (RULES §3.1) to
|
|
free-pick mints. Staked predictions are NOT farming-penalized — the
|
|
farmer is risking actual rep, which is the protocol's deterrent for
|
|
that case. Per-spec semantics.
|
|
"""
|
|
events = _as_event_list(chain)
|
|
markets = _index_markets(events)
|
|
|
|
farming_pct = compute_farming_pct(node_id, events)
|
|
farming_mult = farming_multiplier(farming_pct)
|
|
|
|
free_mint = 0.0
|
|
staked_return = 0.0
|
|
staked_loss = 0.0
|
|
|
|
for market in markets.values():
|
|
if not _market_is_mintable(market):
|
|
continue
|
|
original = market.finalize["outcome"] # type: ignore[index]
|
|
# Sprint 5 bounded reversal: a resolved dispute can flip the
|
|
# effective outcome of THIS market only — no cascade.
|
|
outcome = _effective_outcome(original, market.market_id, events)
|
|
for pred in market.predictions:
|
|
if pred.get("node_id") != node_id:
|
|
continue
|
|
stake = pred.get("stake_amount")
|
|
if stake is None:
|
|
if pred.get("side") == outcome:
|
|
free_mint += _free_pred_mint(pred.get("probability_at_bet")) * farming_mult
|
|
# Wrong free pick: oracle_rep_earned = 0 (RULES §3.1)
|
|
else:
|
|
delta = _staked_pred_settlement(
|
|
stake_amount=stake,
|
|
side=pred.get("side", ""),
|
|
outcome=outcome,
|
|
predictions=market.predictions,
|
|
)
|
|
if delta >= 0:
|
|
staked_return += delta
|
|
else:
|
|
staked_loss += -delta
|
|
|
|
total = free_mint + staked_return - staked_loss
|
|
if total < 0:
|
|
# Oracle rep is non-negative by spec (lost-stake forfeits transfer to
|
|
# winners; they never push a balance below zero in isolation, but a
|
|
# naive node-only view can underflow if the node never won
|
|
# anything). Clamp to zero — the chain analysis on the full network
|
|
# always sums to a non-negative total.
|
|
total = 0.0
|
|
return OracleRepBreakdown(
|
|
free_prediction_mints=free_mint,
|
|
staked_prediction_returns=staked_return,
|
|
staked_prediction_losses=staked_loss,
|
|
total=total,
|
|
)
|
|
|
|
|
|
def compute_oracle_rep(node_id: str, chain: Iterable[dict[str, Any]]) -> float:
|
|
"""Current oracle rep balance for ``node_id``.
|
|
|
|
Wins (free mint + staked winnings) minus losses (staked forfeits).
|
|
Clamped at zero. See ``compute_oracle_rep_breakdown`` for the full
|
|
component view.
|
|
"""
|
|
return compute_oracle_rep_breakdown(node_id, chain).total
|
|
|
|
|
|
def compute_oracle_rep_lifetime(node_id: str, chain: Iterable[dict[str, Any]]) -> float:
|
|
"""Cumulative oracle rep ever earned by ``node_id``.
|
|
|
|
Monotonically increasing (analytics / profiles only — never drives
|
|
protocol logic per RULES §2.1). Counts wins; ignores losses.
|
|
"""
|
|
bd = compute_oracle_rep_breakdown(node_id, chain)
|
|
return bd.free_prediction_mints + bd.staked_prediction_returns
|
|
|
|
|
|
def last_successful_prediction_ts(
|
|
node_id: str,
|
|
chain: Iterable[dict[str, Any]],
|
|
) -> float | None:
|
|
"""Timestamp of the node's most recent correct prediction in a
|
|
market that:
|
|
|
|
1. Reached FINAL (non-INVALID) status.
|
|
2. Was not provisional at finalize time.
|
|
3. Passed the frozen liquidity thresholds.
|
|
4. Was not later reversed by dispute (Sprint 5 — until then, no
|
|
reversal logic; this function only sees the raw outcome).
|
|
|
|
Returns ``None`` if the node has no qualifying prediction.
|
|
|
|
Used by ``governance_decay.compute_oracle_rep_active`` to determine
|
|
decay age. Per RULES §3.11 INVALID markets do NOT reset the clock —
|
|
enforced here by the ``_market_is_mintable`` filter.
|
|
"""
|
|
events = _as_event_list(chain)
|
|
markets = _index_markets(events)
|
|
|
|
best_ts: float | None = None
|
|
for market in markets.values():
|
|
if not _market_is_mintable(market):
|
|
continue
|
|
original = market.finalize["outcome"] # type: ignore[index]
|
|
# Sprint 5 bounded reversal: dispute reversal flips the
|
|
# effective outcome — predictors who picked the new winning
|
|
# side are the ones whose timestamps qualify.
|
|
outcome = _effective_outcome(original, market.market_id, events)
|
|
finalize_ts = market.finalize_ts
|
|
for pred in market.predictions:
|
|
if pred.get("node_id") != node_id:
|
|
continue
|
|
if pred.get("side") != outcome:
|
|
continue
|
|
ts = float(pred.get("timestamp") or 0.0)
|
|
# Use the LATER of prediction timestamp and finalize timestamp —
|
|
# the "successful prediction" only crystallizes when finalize lands.
|
|
ts = max(ts, finalize_ts)
|
|
if best_ts is None or ts > best_ts:
|
|
best_ts = ts
|
|
return best_ts
|
|
|
|
|
|
__all__ = [
|
|
"OracleRepBreakdown",
|
|
"compute_oracle_rep",
|
|
"compute_oracle_rep_breakdown",
|
|
"compute_oracle_rep_lifetime",
|
|
"last_successful_prediction_ts",
|
|
]
|