- 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>
178 lines
6.4 KiB
Python
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"]
|