all repos — rastro @ 6738696441c3bf8d4382193143952f0e7cdac6c5

BitTorrent tracker!

app/services/scoring.py (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from statistics import median

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models import ProbeResult, Tracker


@dataclass(slots=True)
class TrackerMetrics:
    uptime_24h: float | None
    uptime_7d: float | None
    uptime_30d: float | None
    latency_median_7d: float | None
    latency_p95_7d: float | None
    valid_rate_7d: float | None
    measurement_count: int
    measurement_count_7d: int
    tracking_days: float
    score: int | None
    provisional: bool


def _percentile(sorted_values: list[float], p: float) -> float | None:
    if not sorted_values:
        return None
    if len(sorted_values) == 1:
        return sorted_values[0]
    k = (len(sorted_values) - 1) * p
    f = int(k)
    c = min(f + 1, len(sorted_values) - 1)
    if f == c:
        return sorted_values[f]
    return sorted_values[f] + (sorted_values[c] - sorted_values[f]) * (k - f)


def uptime_ratio(results: Sequence[ProbeResult]) -> float | None:
    if not results:
        return None
    ups = sum(1 for r in results if r.status == "up" and r.response_valid)
    return ups / len(results)


def latency_score(median_ms: float | None) -> int:
    if median_ms is None:
        return 0
    if median_ms <= 150:
        return 20
    if median_ms <= 300:
        return 16
    if median_ms <= 600:
        return 10
    if median_ms <= 1000:
        return 5
    return 0


def confidence_score(count: int) -> int:
    if count < 3:
        return 0
    if count < 10:
        return 1
    if count < 20:
        return 3
    return 5


def compute_score(
    *,
    uptime_7d: float | None,
    latency_median_7d: float | None,
    valid_rate_7d: float | None,
    measurement_count: int,
) -> int | None:
    if measurement_count < 3:
        return None
    avail = int(round((uptime_7d or 0.0) * 60))
    lat = latency_score(latency_median_7d)
    valid = int(round((valid_rate_7d or 0.0) * 15))
    conf = confidence_score(measurement_count)
    return max(0, min(100, avail + lat + valid + conf))


def metrics_from_results(
    tracker: Tracker, results: Sequence[ProbeResult], *, now: datetime | None = None
) -> TrackerMetrics:
    now = now or datetime.now(UTC)

    # Normalize naive datetimes from SQLite
    def _aware(dt: datetime) -> datetime:
        return dt if dt.tzinfo else dt.replace(tzinfo=UTC)

    first_seen = _aware(tracker.first_seen_at)
    cut_24h = now - timedelta(hours=24)
    cut_7d = now - timedelta(days=7)
    cut_30d = now - timedelta(days=30)

    aware_results = []
    for r in results:
        # mutate view via checked_at comparison with aware times
        checked = _aware(r.checked_at)
        aware_results.append((checked, r))

    r24 = [r for checked, r in aware_results if checked >= cut_24h]
    r7 = [r for checked, r in aware_results if checked >= cut_7d]
    r30 = [r for checked, r in aware_results if checked >= cut_30d]

    latencies = sorted(r.latency_ms for r in r7 if r.latency_ms is not None and r.response_valid)
    valid_rate = None
    if r7:
        valid_rate = sum(1 for r in r7 if r.response_valid) / len(r7)

    count = len(results)
    provisional = count < 10
    score = compute_score(
        uptime_7d=uptime_ratio(r7),
        latency_median_7d=float(median(latencies)) if latencies else None,
        valid_rate_7d=valid_rate,
        measurement_count=count,
    )

    tracking_days = (now - first_seen).total_seconds() / 86400.0
    return TrackerMetrics(
        uptime_24h=uptime_ratio(r24),
        uptime_7d=uptime_ratio(r7),
        uptime_30d=uptime_ratio(r30),
        latency_median_7d=float(median(latencies)) if latencies else None,
        latency_p95_7d=_percentile(list(latencies), 0.95),
        valid_rate_7d=valid_rate,
        measurement_count=count,
        measurement_count_7d=len(r7),
        tracking_days=tracking_days,
        score=score,
        provisional=provisional,
    )


async def load_metrics(session: AsyncSession, tracker: Tracker) -> TrackerMetrics:
    result = await session.execute(
        select(ProbeResult)
        .where(ProbeResult.tracker_id == tracker.id)
        .order_by(ProbeResult.checked_at.desc())
        .limit(5000)
    )
    rows = list(result.scalars().all())
    return metrics_from_results(tracker, rows)