rmi-backend/app/catalog/reputation.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

178 lines
6.4 KiB
Python

"""T01 - Bayesian Deployer Reputation System.
Per MINIMAX_M3_TASKS.md T01. Beta-Binomial posterior replaces the
weighted-sum that conflated probabilities with volumes.
The legacy 0-100 score is kept for backward compatibility (every
existing consumer reads it). The new authoritative output is:
probability - P(rug) = alpha / (alpha + beta)
credible_interval_95 - 95% Bayesian CI from Beta distribution
observations - {successes, failures, total}
We start with a uniform prior Beta(1,1). Each rug increments beta.
Each legitimate deployment increments alpha. News sentiment < -0.3
adds 2 to beta (pessimistic prior). News sentiment > 0.3 adds 2 to
alpha (optimistic prior). Age and volume are logged but not folded
into the prior (they are orthogonal signals, not evidence).
The legacy 0-100 score is derived deterministically from probability:
score = round((1 - probability) * 100)
"""
from __future__ import annotations
import logging
import math
from app.catalog.models import Deployer, utcnow
log = logging.getLogger(__name__)
# ── Prior adjustments (Bayesian update weights) ────────────────
PRIOR_WEIGHTS: dict[str, int] = {
"prior_alpha": 1, # Beta(1,1) = uniform prior
"prior_beta": 1,
"news_pessimistic_shift": 2, # +2 to beta if avg sentiment < -0.3
"news_optimistic_shift": 2, # +2 to alpha if avg sentiment > 0.3
"news_window_hours": 720, # 30 days
"news_negative_threshold": -0.3,
"news_positive_threshold": 0.3,
}
def _beta_credible_interval_95(alpha: float, beta: float) -> tuple[float, float]:
"""Approximate 95% credible interval for Beta(alpha, beta).
Uses the normal approximation to the Beta distribution, which is
accurate for alpha+beta > 30 (our regime: typically dozens of
observations per deployer). For low-observation regimes, falls back
to a wider quantile-based interval.
"""
n = alpha + beta
if n <= 0:
return (0.0, 1.0)
if n < 30:
# Wider interval for low-data regime
mean = alpha / n
var = (alpha * beta) / (n * n * (n + 1))
sd = math.sqrt(var)
# Use 1.96 but clamp to [0,1]
lo = max(0.0, mean - 1.96 * sd)
hi = min(1.0, mean + 1.96 * sd)
return (lo, hi)
# High-data regime: tighter interval
mean = alpha / n
var = (alpha * beta) / (n * n * (n + 1))
sd = math.sqrt(var)
lo = max(0.0, mean - 1.96 * sd)
hi = min(1.0, mean + 1.96 * sd)
return (lo, hi)
async def compute_deployer_posterior(
deployer: Deployer,
catalog: CatalogService, # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
) -> dict:
"""Compute Bayesian reputation for a deployer.
Returns:
{
"probability": float, # P(rug), 0..1
"credible_interval_95": [lo, hi], # 95% Bayesian CI
"observations": {
"rugs": int, "legit": int, "total": int,
"alpha": float, "beta": float,
},
"news_sentiment": float | None, # -1..+1 if available
"score": int, # legacy 0-100 (backward compat)
"computed_at": str, # ISO8601
}
"""
cache_key = f"catalog:deployer_rep:v2:{deployer.wallet_id}"
if catalog._health.redis:
try:
cached = await catalog._redis.get(cache_key)
if cached:
import json as _json
return _json.loads(cached)
except Exception:
pass
# ── Update prior from observations ──
alpha = float(PRIOR_WEIGHTS["prior_alpha"])
beta = float(PRIOR_WEIGHTS["prior_beta"])
rugs = max(0, deployer.rug_count)
legit = max(0, len(deployer.deployments) - rugs)
alpha += legit
beta += rugs
# ── News sentiment prior adjustment ──
news_sentiment = None
if catalog._health.postgres:
try:
async with catalog._pg_pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT sentiment_score FROM news_items
WHERE $1 = ANY(wallets_mentioned)
AND published_at > NOW() - make_interval(hours => $2)
LIMIT 20""",
deployer.wallet_id,
PRIOR_WEIGHTS["news_window_hours"],
)
scores = [r["sentiment_score"] for r in rows if r["sentiment_score"] is not None]
if scores:
news_sentiment = sum(scores) / len(scores)
if news_sentiment < PRIOR_WEIGHTS["news_negative_threshold"]:
beta += PRIOR_WEIGHTS["news_pessimistic_shift"]
elif news_sentiment > PRIOR_WEIGHTS["news_positive_threshold"]:
alpha += PRIOR_WEIGHTS["news_optimistic_shift"]
except Exception as e:
log.debug("reputation_news_fail: %s", e)
# ── Posterior ──
total = alpha + beta
probability = alpha / total if total > 0 else 0.5
lo, hi = _beta_credible_interval_95(alpha, beta)
result = {
"probability": round(probability, 4),
"credible_interval_95": [round(lo, 4), round(hi, 4)],
"observations": {
"rugs": int(rugs),
"legit": int(legit),
"total": int((deployer.total_volume_usd and len(deployer.deployments)) or 0),
"alpha": alpha,
"beta": beta,
},
"news_sentiment": round(news_sentiment, 4) if news_sentiment is not None else None,
# Legacy 0-100 score: probability of legitness scaled to 0..100
# probability = P(rug), so legitness = 1 - probability
"score": round((1.0 - probability) * 100),
"computed_at": utcnow().isoformat(),
}
if catalog._health.redis:
try:
import json as _json
await catalog._redis.setex(cache_key, 3600, _json.dumps(result))
except Exception:
pass
return result
# ── Backward-compatible wrapper (returns just the int score) ──
async def compute_deployer_reputation(
deployer: Deployer,
catalog: CatalogService, # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue
) -> int:
"""Legacy 0-100 reputation score.
Returns the integer score derived from the Bayesian posterior.
New code should call compute_deployer_posterior() directly for the
full probability + CI.
"""
posterior = await compute_deployer_posterior(deployer, catalog)
return posterior["score"]