"""The reclaim score (§6). Implemented twice on purpose: once as a SQL expression, because weights change on every slider drag and storing the score would mean recomputing 5,700 rows per drag; and once in Python, for CSV export and for tests. Two implementations of one formula is a real risk, so test_scoring.py asserts they agree to within 0.01 on a generated corpus. Components are each normalized to [0, 1] where 1 means "better deletion candidate". The weighted mean uses only the components AVAILABLE for a row, so a missing component renormalizes instead of dragging every score down. """ from __future__ import annotations import math from dataclasses import dataclass DAY = 86400.0 COMPONENTS = ("size", "staleness", "unpopularity", "solitude", "age", "rejection") DEFAULT_WEIGHTS = { "size": 0.28, "staleness": 0.24, "unpopularity": 0.22, "solitude": 0.10, "age": 0.10, "rejection": 0.06, } @dataclass class ScoreContext: """Per-request constants. max_size is computed once in a CTE, not per row.""" now: int max_size_bytes: int stale_horizon_days: int = 730 age_horizon_days: int = 1095 popular_at: int = 3 rejected_at: int = 2 solitude_at: int = 3 grace_days: int = 30 recent_days: int = 90 has_completion_data: bool = True def normalize_weights(weights: dict | None, *, has_completion_data: bool = True) -> dict: w = dict(DEFAULT_WEIGHTS) if weights: for k, v in weights.items(): if k in DEFAULT_WEIGHTS: try: w[k] = max(0.0, float(v)) except (TypeError, ValueError): pass if not has_completion_data: # No percent_complete anywhere => rejection is not merely zero, it is # unavailable, and must not dilute the remaining components (§4.11). w["rejection"] = 0.0 if sum(w.values()) <= 0: w = dict(DEFAULT_WEIGHTS) return w # ─────────────────────────────── python ────────────────────────────────── def components(row: dict, ctx: ScoreContext) -> dict[str, float | None]: """Component values for one row. None means 'not available for this row'.""" size = int(row.get("size_bytes") or 0) added_at = row.get("added_at") last_watched = row.get("last_watched_at") watch_count = int(row.get("watch_count") or 0) abandoned = int(row.get("abandoned_count") or 0) watchers = int(row.get("distinct_watcher_count") or 0) episodes = int(row.get("episode_count") or 0) kind = row.get("kind") or "movie" pre_history = bool(row.get("pre_history")) # size — log scale: 2GB vs 4GB matters more than 60GB vs 62GB max_size = max(int(ctx.max_size_bytes or 0), 1) c_size = math.log10(1 + size) / math.log10(1 + max_size) if size > 0 else 0.0 c_size = min(max(c_size, 0.0), 1.0) # staleness — never watched scores 1.0 if last_watched: days = max(0.0, (ctx.now - last_watched) / DAY) c_stale = min(days / max(ctx.stale_horizon_days, 1), 1.0) else: c_stale = 1.0 if pre_history and not last_watched: # Might have been watched before history coverage began; capping keeps a # 2009 film that was watched in 2015 from scoring as never-watched (§4.11) c_stale = min(c_stale, 0.75) # unpopularity — TV normalized per-episode so it compares to a movie if kind == "season" and episodes > 0: normalized_watches = watch_count / episodes else: normalized_watches = float(watch_count) c_unpop = 1.0 - min(normalized_watches / max(ctx.popular_at, 1), 1.0) # age if added_at: days = max(0.0, (ctx.now - added_at) / DAY) c_age = min(days / max(ctx.age_horizon_days, 1), 1.0) else: c_age = 0.0 # solitude c_sol = 1.0 - min(watchers / max(ctx.solitude_at, 1), 1.0) # rejection — zeroed the moment anyone completes a view if not ctx.has_completion_data: c_rej = None elif watch_count > 0: c_rej = 0.0 else: c_rej = min(abandoned / max(ctx.rejected_at, 1), 1.0) return { "size": c_size, "staleness": c_stale, "unpopularity": c_unpop, "solitude": c_sol, "age": c_age, "rejection": c_rej, } def round_half_up(x: float, places: int = 2) -> float: """Match SQLite's ROUND(), which rounds half away from zero. Python's built-in round() uses banker's rounding, so the two disagree at exactly x.xx5 — the SQL grid would show 53.13 where the CSV export showed 53.12. Harmless, but the kind of inconsistency that costs an afternoon when someone notices the two disagree and assumes the formula differs. """ factor = 10 ** places return math.floor(abs(x) * factor + 0.5) / factor * (1 if x >= 0 else -1) def score_row(row: dict, ctx: ScoreContext, weights: dict | None = None) -> dict: """Returns {score, score_raw, components, grace}. score_raw is unrounded — tests compare it against the SQL expression so a real formula divergence is not masked by rounding, and vice versa. """ w = normalize_weights(weights, has_completion_data=ctx.has_completion_data) comps = components(row, ctx) num = 0.0 den = 0.0 for name in COMPONENTS: value = comps.get(name) weight = w.get(name, 0.0) if value is None or weight <= 0: continue num += weight * value den += weight score = 100.0 * (num / den) if den > 0 else 0.0 grace = None now = ctx.now added_at = row.get("added_at") last_watched = row.get("last_watched_at") if added_at and (now - added_at) < ctx.grace_days * DAY: score = 0.0 grace = "new" elif last_watched and (now - last_watched) < ctx.recent_days * DAY: score = min(score, 25.0) grace = "recent" return { "score": round_half_up(score, 2), "score_raw": score, "components": {k: (None if v is None else round(v, 4)) for k, v in comps.items()}, "grace": grace, } # ──────────────────────────────── sql ──────────────────────────────────── def sql_expression(ctx: ScoreContext, weights: dict | None = None, *, rounded: bool = True) -> tuple[str, dict]: """Return (expression, params) computing the score for media_item rows. Expects a CTE or join providing `maxsize` as the library-wide max size. Mirrors components()/score_row() exactly — see test_scoring.py. Pass rounded=False to compare the raw formula without rounding in the way. """ w = normalize_weights(weights, has_completion_data=ctx.has_completion_data) p = { "now": ctx.now, "stale_h": max(ctx.stale_horizon_days, 1), "age_h": max(ctx.age_horizon_days, 1), "popular_at": max(ctx.popular_at, 1), "rejected_at": max(ctx.rejected_at, 1), "solitude_at": max(ctx.solitude_at, 1), "grace_s": ctx.grace_days * 86400, "recent_s": ctx.recent_days * 86400, "w_size": w["size"], "w_stale": w["staleness"], "w_unpop": w["unpopularity"], "w_sol": w["solitude"], "w_age": w["age"], "w_rej": w["rejection"], } c_size = ( "MIN(MAX(CASE WHEN i.size_bytes > 0 THEN " " (LOG(1 + i.size_bytes) / LOG(1 + MAX(s.maxsize, 1))) ELSE 0.0 END, 0.0), 1.0)" ) c_stale_raw = ( "CASE WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0" " THEN MIN(MAX(CAST(:now - i.last_watched_at AS REAL) / 86400.0, 0.0)" " / :stale_h, 1.0)" " ELSE 1.0 END" ) # pre_history cap applies only when there is no recorded watch at all c_stale = ( "CASE WHEN i.pre_history = 1 AND (i.last_watched_at IS NULL OR i.last_watched_at = 0)" " THEN MIN(%s, 0.75) ELSE %s END" % (c_stale_raw, c_stale_raw) ) normalized_watches = ( "CASE WHEN i.kind = 'season' AND i.episode_count > 0" " THEN CAST(i.watch_count AS REAL) / i.episode_count" " ELSE CAST(i.watch_count AS REAL) END" ) c_unpop = "(1.0 - MIN((%s) / :popular_at, 1.0))" % normalized_watches c_age = ( "CASE WHEN i.added_at IS NOT NULL AND i.added_at > 0" " THEN MIN(MAX(CAST(:now - i.added_at AS REAL) / 86400.0, 0.0) / :age_h, 1.0)" " ELSE 0.0 END" ) c_sol = "(1.0 - MIN(CAST(i.distinct_watcher_count AS REAL) / :solitude_at, 1.0))" if ctx.has_completion_data: c_rej = ( "CASE WHEN i.watch_count > 0 THEN 0.0" " ELSE MIN(CAST(i.abandoned_count AS REAL) / :rejected_at, 1.0) END" ) rej_num = ":w_rej * (%s)" % c_rej rej_den = ":w_rej" else: rej_num = "0.0" rej_den = "0.0" numerator = ( f":w_size * ({c_size}) + :w_stale * ({c_stale}) + :w_unpop * ({c_unpop})" f" + :w_sol * ({c_sol}) + :w_age * ({c_age}) + {rej_num}" ) denominator = f"(:w_size + :w_stale + :w_unpop + :w_sol + :w_age + {rej_den})" raw = f"CASE WHEN {denominator} > 0 THEN 100.0 * ({numerator}) / {denominator} ELSE 0.0 END" graced = ( "CASE" " WHEN i.added_at IS NOT NULL AND i.added_at > 0" " AND (:now - i.added_at) < :grace_s THEN 0.0" " WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0" " AND (:now - i.last_watched_at) < :recent_s THEN MIN(%s, 25.0)" " ELSE %s END" % (raw, raw) ) return ("ROUND(%s, 2)" % graced if rounded else graced), p GRACE_SQL = ( "CASE" " WHEN i.added_at IS NOT NULL AND i.added_at > 0" " AND (:now - i.added_at) < :grace_s THEN 'new'" " WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0" " AND (:now - i.last_watched_at) < :recent_s THEN 'recent'" " ELSE NULL END" ) def register_sqlite_functions(conn) -> None: """SQLite has no LOG() by default; add it (and a MIN/MAX-safe guard).""" def _log10(x): try: x = float(x) except (TypeError, ValueError): return None return math.log10(x) if x > 0 else 0.0 conn.create_function("LOG", 1, _log10, deterministic=True)