From 09391495a2be404d279624f737b900b1662961bf Mon Sep 17 00:00:00 2001 From: Patrick Veilleux Date: Thu, 4 Jun 2026 09:31:15 -0400 Subject: [PATCH 1/3] refactor 1 --- ALGORITHM.md | 148 ++-- README.md | 11 +- optimizarr/defaults.toml | 139 ++-- optimizarr/features/optimizer/config.py | 91 ++- optimizarr/features/optimizer/decision.py | 32 +- optimizarr/features/optimizer/topsis.py | 138 ++-- .../20260602-logistic-score-normalization.md | 66 ++ .../20260602-quality-preset-retune.md | 41 ++ .../20260602-score-curve-refit-250movies.md | 65 ++ .../20260603-2axis-trapezoid-size-model.md | 52 ++ tests/test_config.py | 20 +- tests/test_decision.py | 25 +- tests/test_swap.py | 12 +- tests/test_topsis.py | 87 ++- tools/cutoff_viz.py | 6 +- tools/diagnose.py | 661 ++++++++++++++++++ tools/score_curve_fit.py | 203 ++++++ tools/size_band_lab.py | 339 +++++++++ tools/weight_lab.py | 23 +- 19 files changed, 1862 insertions(+), 297 deletions(-) create mode 100644 test-results/20260602-logistic-score-normalization.md create mode 100644 test-results/20260602-quality-preset-retune.md create mode 100644 test-results/20260602-score-curve-refit-250movies.md create mode 100644 test-results/20260603-2axis-trapezoid-size-model.md create mode 100644 tools/diagnose.py create mode 100644 tools/score_curve_fit.py create mode 100644 tools/size_band_lab.py diff --git a/ALGORITHM.md b/ALGORITHM.md index 4794bf1..f85faea 100644 --- a/ALGORITHM.md +++ b/ALGORITHM.md @@ -10,73 +10,84 @@ anything better than the current file*, never merely *"we triggered a grab."* There are two big ideas: -- **Per-preset absolute size tables.** Each preset carries its own `{floor, target, bloat}` band - per resolution (GiB/h). `floor` and `bloat` are hard gates (out-of-band releases are dropped - before scoring); `target` is where the size score plateaus. The curve is **one-sided**, so a - file is never inflated to "reach" a target. -- **A single closeness-gain swap rule.** Releases are scored by TOPSIS *closeness* (a number - derived from the release alone), and a candidate is grabbed only if it raises closeness past a - small margin, plus a resolution guard. Because closeness is file-independent and strictly - increases on every swap, the optimizer **cannot oscillate**, for cycles of any length. +- **Per-preset 5-point size bands.** Each preset carries its own + `{floor, lo, target, hi, ceiling}` trapezoid per resolution (GiB/h). `floor` and `ceiling` are + hard gates (out-of-band releases are dropped before scoring); `lo … hi` is the "good size" band + where score drives the pick; `target` is the peak, placed at a per-profile size percentile, so + each profile aims at a different size and the five profiles pick different releases. +- **A single closeness-gain swap rule.** Releases are scored by TOPSIS *closeness* over two axes + (score, size; resolution is a hard guard, not an axis), and a candidate is grabbed only if it + raises closeness past a small margin, doesn't drop resolution below target, and isn't bigger at a + lower-or-equal score (a larger file is grabbed only on a real score upgrade). Because closeness is + file-independent and strictly increases on every swap, the optimizer **cannot oscillate**. --- -## 1. The size model: per-preset tables, one-sided curve +## 1. The size model: per-preset tables, 5-point trapezoid Each preset defines its own `[optimizer.topsis.presets..reference]` table: one -`{floor, target, bloat}` entry per resolution, in GiB/h (`GB = 1024³`, so GiB). The shipped -tables (floor / target / bloat): - -| preset | 2160 | 1080 | 720 | 480 | -| --- | --- | --- | --- | --- | -| Remux | 15 / 35 / 80 | 8 / 18 / 40 | 2 / 5 / 12 | 0.5 / 1.5 / 4 | -| Quality | 5 / 11 / 20 | 1.5 / 5 / 10 | 0.5 / 2 / 5 | 0.25 / 0.8 / 3 | -| Balanced | 4.5 / 9 / 16 | 1.5 / 3.8 / 8 | 0.5 / 1.5 / 4 | 0.2 / 0.6 / 2.5 | -| Efficient | 3.5 / 6.5 / 14 | 1 / 2.6 / 7 | 0.4 / 1 / 3.5 | 0.2 / 0.45 / 2 | -| Compact | 3 / 5 / 12 | 1 / 2 / 6 | 0.35 / 0.8 / 3 | 0.2 / 0.35 / 2 | - -- **floor**: below this a release is illegitimate for the resolution (an upscale, a mislabeled - lower resolution, or an encode too soft to deliver it). Dropped before scoring. -- **target**: where the size score plateaus. A *good* release for this preset sits here or below. -- **bloat**: above this is bloat. Dropped before scoring, and size desirability is 0 at `bloat`. - -The size-desirability curve is **one-sided**: +`{floor, lo, target, hi, ceiling}` entry per resolution, in GiB/h (`GB = 1024³`, so GiB). The +shipped 2160p and 1080p bands (floor / lo / target / hi / ceiling); 720p and 480p follow the same +shape, scaled down: + +| preset | 2160p | 1080p | +| --- | --- | --- | +| Remux | 15 / 28 / 38 / 60 / 90 | 8 / 15 / 20 / 32 / 48 | +| Quality | 6.5 / 10.7 / 11.5 / 13.5 / 30 | 3 / 5 / 6.3 / 8.7 / 16 | +| Balanced | 5 / 8.1 / 9.3 / 11 / 20 | 2 / 3.8 / 4.5 / 5.3 / 10 | +| Efficient | 4 / 7.1 / 7.8 / 8.8 / 15 | 1.5 / 2.8 / 3.5 / 4.3 / 7.5 | +| Compact | 3.5 / 5.9 / 6.8 / 7.6 / 12 | 1.2 / 1.9 / 2.6 / 3.2 / 6 | + +- **floor / ceiling**: legitimacy bounds. Outside `[floor, ceiling]` a release is dropped before + scoring (too soft / fake below floor, bloated above ceiling), and `n_size = 0` there. +- **lo … hi**: the "good size" band. `n_size = size_shoulder` (default 0.85) at lo and hi, so + inside the band size barely moves the decision and **score drives** the pick. +- **target**: the peak (`n_size = 1.0`), placed at a per-profile percentile of real release sizes + (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, Remux at remux bitrates). Slide + `target` within the band to nudge a profile lower or higher. + +The size-desirability curve is a **trapezoid** (a flat-topped tent): ``` -n_size(gbh) = 1.0 if gbh ≤ target (plateau, smaller never punished) - = (bloat − gbh)/(bloat − target) if target < gbh < bloat (linear ramp down) - = 0.0 if gbh ≥ bloat +n_size(gbh) = 0 if gbh ≤ floor or gbh ≥ ceiling + = shoulder·(gbh−floor)/(lo−floor) if floor < gbh < lo (rise to the band) + = shoulder + (1−shoulder)·(gbh−lo)/(target−lo) if lo ≤ gbh ≤ target + = 1 − (1−shoulder)·(gbh−target)/(hi−target) if target < gbh ≤ hi + = shoulder·(ceiling−gbh)/(ceiling−hi) if hi < gbh < ceiling (fall past the band) ``` -Two consequences make this safe for a space-saving tool: - -- **Nothing is ever inflated.** A file already at or below `target` scores `n_size = 1.0`, so - TOPSIS has no reason to swap it for a bigger one. A tiny, good-scoring file stays. -- **Among files at/below `target`, size ties at 1.0, so score breaks the tie**, i.e. "small - enough, then best score," which is exactly "good *and* small." - -Tables differ by taste: size-leaning presets (Efficient, Compact) sit low, so they admit and aim -at lean encodes; Remux sits at remux bitrates, so its floor rejects ordinary encodes. +Each profile aims at a **different** band, so the five profiles pick different releases for the +same movie. Unlike the old one-sided curve, a too-small file is penalized (it sits on the rising +shoulder, not a plateau) — but it is never actually *grown* unless that is a genuine score upgrade +(§3). A lone tiny release among bigger same-score peers (usually a bad encode) is dropped by an +outlier prefilter: anything below `outlier_frac` (default 0.5) × the median GiB/h of the +gap-cut cluster. "A good encode has corroborating peers." --- -## 2. Scoring: TOPSIS over three axes +## 2. Scoring: TOPSIS over two axes -Each surviving release is described by three attributes, all normalized to `[0, 1]`: +Resolution is **not** a scored axis: Profilarr already folds it into the score, and a hard guard +forbids dropping below the profile target (§3). So each surviving release has two normalized +attributes: ``` -n_score = clamp( (score − score_anti_ideal) / (score_ideal − score_anti_ideal), 0, 1 ) -n_resolution = clamp( (height − res_anti_ideal) / (target_height − res_anti_ideal), 0, 1 ) -n_size = one-sided curve above (uses the preset's target + bloat for this resolution) +n_score = 1 / (1 + exp(-(score − score_center) / score_width)) # logistic (default) +n_size = trapezoid above (the preset's floor/lo/target/hi/ceiling for this resolution) ``` -Score normalizes on a **fixed** scale (`[0, 1,000,000]` by default) so good releases stay -comparable across items. Resolution climbs toward the profile's target height (low weight on -purpose, since Profilarr already folds resolution into the score, so this axis mostly avoids -double-counting). +Score normalizes through a **fixed** transform (a function of the release alone, so closeness +stays comparable across items and the no-oscillation guarantee in §3 holds). The default is a +**logistic** S-curve: Profilarr scores bunch tightly near the top (real releases top out around +~950k and cluster there) then fall off fast, so a logistic concentrates the `[0,1]` range where +releases actually compete. A linear ramp over `[0, 1,000,000]` wastes ~80% of the axis on scores +no release reaches, which lets a small real score difference at the top get drowned out by the +size axis; the logistic fixes that and, having no hard floor, still separates a library whose +scores all sit near one value. `score_center` (n_score = 0.5) and `score_width` (slope) are set +in `[optimizer.topsis]`; `score_norm = "linear"` restores the old fixed ramp. -A profile's **weights** (summing to 1.0) combine the axes into a TOPSIS *closeness*, the distance -to the ideal point `(1,1,1)` vs the anti-ideal `(0,0,0)`: +A profile's **weights** (score + size, summing to 1.0) combine the axes into a TOPSIS *closeness*, +the distance to the ideal point `(1,1)` vs the anti-ideal `(0,0)`: ``` d_ideal = √( Σ wₖ·(1 − aₖ)² ) @@ -86,16 +97,17 @@ closeness = d_anti / (d_ideal + d_anti) # 1 = ideal, 0 = anti-ideal Shipped weights and pick method: -| Profile | score | resolution | size | pick method | -| --- | --- | --- | --- | --- | -| Remux | 0.80 | 0.15 | 0.05 | `max_score` | -| Quality | 0.65 | 0.15 | 0.20 | `topsis` | -| Balanced | 0.50 | 0.10 | 0.40 | `topsis` | -| Efficient | 0.40 | 0.10 | 0.50 | `topsis` | -| Compact | 0.20 | 0.10 | 0.70 | `min_size` | +| Profile | score | size | pick method | +| --- | --- | --- | --- | +| Remux | 0.94 | 0.06 | `topsis` | +| Quality | 0.86 | 0.14 | `topsis` | +| Balanced | 0.56 | 0.44 | `topsis` | +| Efficient | 0.44 | 0.56 | `topsis` | +| Compact | 0.22 | 0.78 | `topsis` | -`max_score` (Remux) and `min_size` (Compact) are deterministic single-axis picks among the legal -candidates; the middle three blend score vs size, which is what TOPSIS is for. +All presets pick by `topsis` (highest closeness); the profiles diverge because their **bands** sit +at different sizes, not because of the pick method. `max_score` and `min_size` remain available for +profile overrides. --- @@ -106,13 +118,14 @@ flowchart TD A["Item selected"] --> RP["Resolve profile → preset
(name-keyword match or override)"] RP --> B["GET /api/v3/release"] B --> C["Pre-filter 1: drop hard rejections
blocklisted · unparseable · wrong item · dead"] - C --> D["Pre-filter 2: drop outside the preset's size band
gbh < floor (fake/upscale) or gbh > bloat (bloat)"] + C --> D["Pre-filter 2: drop outside the preset's size band
gbh < floor (fake/upscale) or gbh > ceiling (bloat)"] D --> E["Pre-filter 3: gap-cut on score
keep the top cluster; cut at first drop > score_gap"] - E --> SC["Score survivors (TOPSIS closeness)"] - SC --> GATE["SWAP RULE
keep candidates that raise closeness ≥ min_closeness_gain
and don't drop resolution below target"] + E --> O["Pre-filter 4: drop lone-small size outliers
gbh < outlier_frac × cluster median"] + O --> SC["Score survivors (TOPSIS closeness over score + size)"] + SC --> GATE["SWAP RULE
raise closeness ≥ min_closeness_gain,
don't drop resolution below target,
not bigger at a lower-or-equal score"] GATE --> LEFT{"any legal candidate?"} LEFT -->|no| HOLD["HOLD: mark satisfied"] - LEFT -->|yes| PICK["PICK best survivor
topsis / max_score / min_size"] + LEFT -->|yes| PICK["PICK best survivor (topsis: max closeness)"] PICK --> ACT["ACT: POST grab {guid, indexerId}"] ``` @@ -176,14 +189,13 @@ default_preset = "Balanced" # used when a profile name matches no preset keyw min_closeness_gain = 0.02 # global swap margin (per-preset overridable) [optimizer.topsis.presets.Efficient] -score = 0.40 # weights (sum 1.0) -resolution = 0.10 -size = 0.50 +score = 0.44 # weights (score + size, sum 1.0) +size = 0.56 pick = "topsis" # topsis | max_score | min_size min_closeness_gain = 0.02 # optional per-preset override [optimizer.topsis.presets.Efficient.reference] -"2160" = { floor = 3.5, target = 6.5, bloat = 14 } # GiB/h per resolution -"1080" = { floor = 1.0, target = 2.6, bloat = 7 } +"2160" = { floor = 4.0, lo = 7.1, target = 7.8, hi = 8.8, ceiling = 15 } # GiB/h per resolution +"1080" = { floor = 1.5, lo = 2.8, target = 3.5, hi = 4.3, ceiling = 7.5 } # … 720 / 480 … ``` @@ -191,7 +203,7 @@ A Radarr/Sonarr profile attaches to the preset whose name is a case-insensitive the profile name (`2160p Quality` → Quality). Pin or customize an exact profile with `[optimizer.topsis.profiles.""]`, which may set `preset`, `weights`, `pick`, `reference`, or `min_closeness_gain` (anything omitted is inherited from the matched preset). Validation at load -time enforces weights-sum-to-1, `0 ≤ min_closeness_gain < 1`, `floor < target ≤ bloat`, and a +time enforces weights-sum-to-1, `0 ≤ min_closeness_gain < 1`, `floor < lo ≤ target ≤ hi < ceiling`, and a known `pick` method. --- diff --git a/README.md b/README.md index e7a94aa..1c7bea1 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ It does two jobs, both optional and independent: number of days after release, so Radarr/Sonarr stop grabbing "new" releases off RSS just because they appeared. -It is safe by design: it **never inflates a file** to hit a target, it **cannot oscillate** -(every swap strictly raises a single quality score, so a file is never revisited), and -"optimized" means *the algorithm can no longer find anything better*, never merely "we triggered a -grab" (grabs fail to download all the time, and it handles that). +It is safe by design: it **never grows a file except on a real score upgrade** (a bigger, +lower-or-equal-score release is never grabbed), it **cannot oscillate** (every swap strictly raises +a closeness score computed from the release alone, so a file is never revisited), and "optimized" +means *the algorithm can no longer find anything better*, never merely "we triggered a grab" (grabs +fail to download all the time, and it handles that). > Want the details: the per-preset size tables, the swap rule, the TOPSIS formulas, the config > model, and the worker loop? See **[ALGORITHM.md](ALGORITHM.md)**. @@ -124,7 +125,7 @@ inline in [`defaults.toml`](optimizarr/defaults.toml)): | `[optimizer.] allow_size_increase` | `false` blocks any bigger file (also blocks resolution upgrades). | | `[optimizer.] allow_quality_downgrade` | `false` blocks lower-score releases. **Turn this off if you only ever want upgrades.** Leaving it on is what lets Efficient/Compact realign downward. | | `[unmonitor.] days` / `release_type` / `require_cutoff_met` | When to unmonitor after release, and whether to wait for the quality cutoff first. | -| `[optimizer.topsis]` | The selection engine: per-preset size **tables** (floor/target/bloat), **presets**, and tuning. You rarely need this. See [ALGORITHM.md](ALGORITHM.md). | +| `[optimizer.topsis]` | The selection engine: per-preset 5-point size **bands** (floor/lo/target/hi/ceiling), **presets**, and tuning. You rarely need this. See [ALGORITHM.md](ALGORITHM.md). | The optimizer selects items by **`hasFile`**, regardless of monitored state. It improves the existing library, and the unmonitor job deliberately strips monitoring once a file exists. diff --git a/optimizarr/defaults.toml b/optimizarr/defaults.toml index 774671c..2eea258 100644 --- a/optimizarr/defaults.toml +++ b/optimizarr/defaults.toml @@ -93,12 +93,29 @@ auto_import_downgrades = true # ===== TOPSIS ===== [optimizer.topsis] +# Score-axis normalization: how a raw customFormatScore becomes the [0,1] "score" attribute. +# logistic (default): S-curve, n_score = 1 / (1 + exp(-(score - score_center) / score_width)). +# Profilarr scores bunch tightly near the top (a "perfect" release is ~1,000,000 but real +# releases top out lower and cluster there) and fall off fast below. A logistic puts the +# axis's resolution where releases actually compete, so a small real score difference at the +# top is not drowned out by the size axis; and there is no hard cutoff, so a library whose +# scores all sit near one value is still spread out. Defaults are fit to observed release scores. +# linear: the old fixed ramp over [score_anti_ideal, score_ideal]. Kept for simple setups. +score_norm = "logistic" +score_center = 825000 # score that maps to n_score = 0.5 (fit to real post-prefilter candidates) +score_width = 87500 # slope; roughly the spread of the competitive score cluster (bigger = gentler) +# Used only when score_norm = "linear": score_ideal = 1000000 score_anti_ideal = 0 -resolution_ideal = 2160 # fallback resolution target when a profile exposes none -resolution_anti_ideal = 480 # gap-cut inclusion: keep the top score cluster down to the first relative drop greater than this. score_gap = 0.20 +# n_size value at the band shoulders (lo/hi). Higher = flatter "good size" band, so score drives +# harder inside it; lower = more pull toward the exact target. 1.0 = a hard top-hat band. +size_shoulder = 0.85 +# Outlier drop: discard a candidate whose GiB/h is below outlier_frac x the median GiB/h of the +# comparable-score (gap-cut) cluster -- a lone tiny release among bigger ones is usually a bad +# encode. "A good encode has corroborating peers." 0 disables it. +outlier_frac = 0.5 # Preset used when a profile name matches no preset by keyword. default_preset = "Balanced" # Swap margin: a candidate is grabbed only if it raises TOPSIS closeness by at least this much @@ -107,85 +124,89 @@ default_preset = "Balanced" min_closeness_gain = 0.02 # ----- Presets ----- -# Each preset = TOPSIS weights (score/resolution/size, sum 1.0) + a `pick` method -# (topsis | max_score | min_size) + its OWN absolute size table [.reference] + an optional -# `min_closeness_gain`. Profiles attach to a preset whose name is a (case-insensitive) substring -# of the profile name; e.g. "2160p Quality" -> Quality, "2160p Remux" -> Remux. Add presets freely. +# Each preset = TOPSIS weights (score + size, sum 1.0) + a `pick` method (topsis | max_score | +# min_size) + its OWN absolute size table [.reference] + an optional `min_closeness_gain`. Profiles +# attach to a preset whose name is a (case-insensitive) substring of the profile name; e.g. +# "2160p Quality" -> Quality, "2160p Remux" -> Remux. Add presets freely. (Resolution is no longer +# a scored axis -- Profilarr folds it into the score, and a hard guard in decision.py forbids +# dropping below the profile target, so the two axes are score and size.) # -# The size table is per-resolution `{floor, target, bloat}` in GiB/h (GB = 1024^3): -# - floor : below this a release is illegitimate for the resolution (upscale / mislabeled / -# too soft) -> hard pre-filter DROP. -# - target: where the size score plateaus (n_size = 1.0 at/below target). Smaller than the -# target is never penalized, so nothing is inflated to "reach" it. -# - bloat : above this is bloat -> hard pre-filter DROP (and n_size = 0 here). -# The swap rule (decision.py): grab a candidate only if it raises closeness by >= min_closeness_gain -# and does not drop resolution below the profile target. A lower-score, bigger-or-equal file can -# never raise closeness, so "no bigger file for a lower score" falls out for free. +# The size table is a per-resolution 5-point trapezoid `{floor, lo, target, hi, ceiling}` in GiB/h: +# - floor / ceiling : the legitimacy bounds. Outside [floor, ceiling] a release is dropped before +# scoring (too soft / fake below floor, bloated above ceiling) and n_size = 0. +# - lo .. hi : the "good size" band. n_size = `size_shoulder` at lo/hi, so inside the band +# size barely moves the pick and SCORE drives it. +# - target : the peak (n_size = 1.0). Placed at a percentile of real release sizes per +# profile (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, Remux +# remux-range). Nudge a profile by sliding target within [lo, hi]. +# Each profile aims at a different size band, so they pick DIFFERENT releases. A too-small file now +# scores low on the size axis, BUT a bigger file is only ever grabbed on a genuine score upgrade: +# decision.py forbids "bigger and lower-or-equal score", so the band preference can never degrade +# into "drop the score to inflate the file". The swap rule also requires a closeness gain +# >= min_closeness_gain and never drops resolution below the profile target. [optimizer.topsis.presets.Remux] -# Untouched UHD/BluRay remux: score is everything, size barely weighs. Table sits at remux sizes. -score = 0.80 -resolution = 0.15 -size = 0.05 -pick = "max_score" +# Untouched UHD/BluRay remux: score is nearly everything; the band sits at remux bitrates (no remux +# releases in the training sample, so these are hand-set, not data-derived). +score = 0.94 +size = 0.06 +pick = "topsis" [optimizer.topsis.presets.Remux.reference] -"2160" = { floor = 15.0, target = 35.0, bloat = 80.0 } -"1080" = { floor = 8.0, target = 18.0, bloat = 40.0 } -"720" = { floor = 2.0, target = 5.0, bloat = 12.0 } -"480" = { floor = 0.5, target = 1.5, bloat = 4.0 } +"2160" = { floor = 15.0, lo = 28.0, target = 38.0, hi = 60.0, ceiling = 90.0 } +"1080" = { floor = 8.0, lo = 15.0, target = 20.0, hi = 32.0, ceiling = 48.0 } +"720" = { floor = 2.0, lo = 4.0, target = 5.5, hi = 9.0, ceiling = 14.0 } +"480" = { floor = 0.5, lo = 1.2, target = 1.8, hi = 3.0, ceiling = 5.0 } [optimizer.topsis.presets.Quality] -# Max score, cares a little about size: targets the high end of real encodes. -score = 0.65 -resolution = 0.15 -size = 0.20 +# Score-first, aims high in the encode range (~P77). High floor rejects suspiciously-small encodes; +# high ceiling keeps legitimately-big high-score files (e.g. a grain-heavy 2160p that runs hot). +score = 0.86 +size = 0.14 pick = "topsis" [optimizer.topsis.presets.Quality.reference] -"2160" = { floor = 5.0, target = 11.0, bloat = 20.0 } -"1080" = { floor = 1.5, target = 5.0, bloat = 10.0 } -"720" = { floor = 0.5, target = 2.0, bloat = 5.0 } -"480" = { floor = 0.25, target = 0.8, bloat = 3.0 } +"2160" = { floor = 6.5, lo = 10.7, target = 11.5, hi = 13.5, ceiling = 30.0 } +"1080" = { floor = 3.0, lo = 5.0, target = 6.3, hi = 8.7, ceiling = 16.0 } +"720" = { floor = 1.2, lo = 2.0, target = 2.6, hi = 3.5, ceiling = 7.0 } +"480" = { floor = 0.5, lo = 0.9, target = 1.1, hi = 1.5, ceiling = 3.5 } [optimizer.topsis.presets.Balanced] -# Good score AND smaller, leaning score. Target = the median real encode (training data). -score = 0.50 -resolution = 0.10 -size = 0.40 +# Good score AND a sensible size: target = the median real encode (~P50). +score = 0.56 +size = 0.44 pick = "topsis" [optimizer.topsis.presets.Balanced.reference] -"2160" = { floor = 4.5, target = 9.0, bloat = 16.0 } -"1080" = { floor = 1.5, target = 3.8, bloat = 8.0 } -"720" = { floor = 0.5, target = 1.5, bloat = 4.0 } -"480" = { floor = 0.2, target = 0.6, bloat = 2.5 } +"2160" = { floor = 5.0, lo = 8.1, target = 9.3, hi = 11.0, ceiling = 20.0 } +"1080" = { floor = 2.0, lo = 3.8, target = 4.5, hi = 5.3, ceiling = 10.0 } +"720" = { floor = 0.8, lo = 1.5, target = 1.8, hi = 2.2, ceiling = 4.5 } +"480" = { floor = 0.3, lo = 0.6, target = 0.7, hi = 0.9, ceiling = 2.5 } [optimizer.topsis.presets.Efficient] -# Good score AND smaller, leaning size. -score = 0.40 -resolution = 0.10 -size = 0.50 +# Lean but legitimate: target around the lower-quartile encode (~P30), not the smallest. +score = 0.44 +size = 0.56 pick = "topsis" [optimizer.topsis.presets.Efficient.reference] -"2160" = { floor = 3.5, target = 6.5, bloat = 14.0 } -"1080" = { floor = 1.0, target = 2.6, bloat = 7.0 } -"720" = { floor = 0.4, target = 1.0, bloat = 3.5 } -"480" = { floor = 0.2, target = 0.45, bloat = 2.0 } +"2160" = { floor = 4.0, lo = 7.1, target = 7.8, hi = 8.8, ceiling = 15.0 } +"1080" = { floor = 1.5, lo = 2.8, target = 3.5, hi = 4.3, ceiling = 7.5 } +"720" = { floor = 0.6, lo = 1.1, target = 1.4, hi = 1.7, ceiling = 3.5 } +"480" = { floor = 0.25, lo = 0.45, target = 0.55, hi = 0.7, ceiling = 2.0 } [optimizer.topsis.presets.Compact] -# Smallest viable: lowest target that is still a legitimate encode for the resolution. -score = 0.20 -resolution = 0.10 -size = 0.70 -pick = "min_size" +# Smallest viable, but NOT the absolute smallest: target near the bottom decile (~P10), with the +# floor still rejecting outright fakes/bad encodes below it. +score = 0.22 +size = 0.78 +pick = "topsis" [optimizer.topsis.presets.Compact.reference] -"2160" = { floor = 3.0, target = 5.0, bloat = 12.0 } -"1080" = { floor = 1.0, target = 2.0, bloat = 6.0 } -"720" = { floor = 0.35, target = 0.8, bloat = 3.0 } -"480" = { floor = 0.2, target = 0.35, bloat = 2.0 } +"2160" = { floor = 3.5, lo = 5.9, target = 6.8, hi = 7.6, ceiling = 12.0 } +"1080" = { floor = 1.2, lo = 1.9, target = 2.6, hi = 3.2, ceiling = 6.0 } +"720" = { floor = 0.4, lo = 0.8, target = 1.0, hi = 1.3, ceiling = 3.0 } +"480" = { floor = 0.2, lo = 0.35, target = 0.45, hi = 0.6, ceiling = 2.0 } # Per-profile-name overrides. Reference a preset, or override weights / pick / reference / margin: # [optimizer.topsis.profiles."2160p Remux"] # preset = "Remux" # [optimizer.topsis.profiles."My Custom 1080p"] -# weights = { score = 0.5, resolution = 0.1, size = 0.4 } +# weights = { score = 0.6, size = 0.4 } # [optimizer.topsis.profiles."My Custom 1080p".reference] -# "1080" = { floor = 1.5, target = 4.0, bloat = 9.0 } +# "1080" = { floor = 1.5, lo = 3.5, target = 4.0, hi = 5.0, ceiling = 9.0 } diff --git a/optimizarr/features/optimizer/config.py b/optimizarr/features/optimizer/config.py index 9075a53..18cb008 100644 --- a/optimizarr/features/optimizer/config.py +++ b/optimizarr/features/optimizer/config.py @@ -4,17 +4,19 @@ (defaults.toml + the user's config.toml) — there are no magic defaults baked into this module. The shared loader (optimizarr.config) delegates to parse_optimizer() here. -Size model: each preset carries its OWN absolute `reference` table, one `{floor, target, bloat}` -entry per resolution, in GiB/h: - - floor : below this the encode is illegitimate for the resolution (upscale / mislabeled / - too soft) -> hard pre-filter drop. - - target: where the TOPSIS size score plateaus (n_size = 1.0 at/below target). - - bloat : above this is bloat -> hard pre-filter drop (and n_size = 0 at bloat). - +Size model: each preset carries its OWN absolute `reference` table, one 5-point trapezoid +`{floor, lo, target, hi, ceiling}` entry per resolution, in GiB/h: + - floor / ceiling : legitimacy bounds. Outside [floor, ceiling] a release is dropped before + scoring (too small / fake below floor, bloated above ceiling), n_size = 0. + - lo .. hi : the "good size" band; n_size = `size_shoulder` at lo/hi, so inside it score + drives the pick and `target` placement only nudges the preference. + - target : the peak (n_size = 1.0), placed at a per-profile percentile of real sizes. + +Scoring is TWO axes (score, size); resolution is a hard guard in decision.py, not a weighted axis. The swap rule is a single closeness-gain test (see decision.py): a candidate is taken only if it -raises TOPSIS closeness by at least `min_closeness_gain`, plus a resolution guard. That makes the -optimizer provably non-oscillating (closeness is file-independent and strictly increases), so the -old relative size/score "much/slightly" transition matrix is gone. +raises TOPSIS closeness by at least `min_closeness_gain`, plus the resolution guard. Closeness is a +fixed function of the release alone, so the optimizer is provably non-oscillating (every swap +strictly increases it). """ from __future__ import annotations @@ -36,17 +38,19 @@ } PICK_METHODS = {"topsis", "max_score", "min_size"} -# resolution -> (floor, target, bloat) GiB/h. -Reference = dict[int, tuple[float, float, float]] +# resolution -> (floor, lo, target, hi, ceiling) GiB/h. A 5-point trapezoid: n_size is 0 outside +# [floor, ceiling], rises to `shoulder` at lo, peaks at 1.0 at target, falls back to `shoulder` at +# hi. The flat-ish [lo, hi] band is the "good size" region where score drives the pick. +Reference = dict[int, tuple[float, float, float, float, float]] @dataclass class Preset: """A named bundle: TOPSIS weights + a pick method + an absolute size table + swap margin.""" - weights: dict[str, float] # keys: score, resolution, size (sum 1.0) + weights: dict[str, float] # keys: score, size (sum 1.0) pick: str # "topsis" | "max_score" | "min_size" - reference: Reference # res -> (floor, target, bloat) GiB/h + reference: Reference # res -> (floor, lo, target, hi, ceiling) GiB/h min_closeness_gain: float # swap only if closeness improves by at least this @@ -75,12 +79,23 @@ class ResolvedProfile: class TopsisConfig: score_ideal: int score_anti_ideal: int - resolution_ideal: int - resolution_anti_ideal: int score_gap: float default_preset: str default_min_closeness_gain: float presets: dict[str, Preset] + # Score-axis normalization. "logistic" (default) maps raw score through an S-curve centered + # at score_center with slope set by score_width, concentrating the [0,1] range where releases + # actually cluster. "linear" uses the fixed score_anti_ideal..score_ideal ramp. + score_norm: str = "logistic" + score_center: float = 825000.0 + score_width: float = 87500.0 + # n_size value at the band shoulders (lo/hi). Higher = flatter band = score drives harder + # inside it. 1.0 collapses the trapezoid to a hard top-hat. + size_shoulder: float = 0.85 + # Outlier prefilter: drop a candidate whose GiB/h is below outlier_frac x the median GiB/h of + # the comparable-score (gap-cut) cluster. Encodes "a good release has corroborating peers"; + # 0 disables it. + outlier_frac: float = 0.5 profiles: dict[str, ProfileOverride] = field(default_factory=dict) @@ -119,8 +134,8 @@ class OptimizerConfig: def _weights(raw: dict, where: str) -> dict[str, float]: - w = {k: float(raw[k]) for k in ("score", "resolution", "size") if k in raw} - missing = {"score", "resolution", "size"} - w.keys() + w = {k: float(raw[k]) for k in ("score", "size") if k in raw} + missing = {"score", "size"} - w.keys() if missing: raise ValueError(f"{where}: missing weight keys {sorted(missing)}") total = sum(w.values()) @@ -136,17 +151,22 @@ def _reference(raw: dict, where: str) -> Reference: res_int = int(res) except (TypeError, ValueError) as e: raise ValueError(f"{where}: key {res!r} is not an integer resolution") from e - if not isinstance(entry, dict) or not {"floor", "target", "bloat"} <= entry.keys(): - raise ValueError(f"{where}.{res}: expected {{floor, target, bloat}}, got {entry!r}") + need = {"floor", "lo", "target", "hi", "ceiling"} + if not isinstance(entry, dict) or not need <= entry.keys(): + raise ValueError( + f"{where}.{res}: expected {{floor, lo, target, hi, ceiling}}, got {entry!r}" + ) floor = float(entry["floor"]) + lo = float(entry["lo"]) target = float(entry["target"]) - bloat = float(entry["bloat"]) - if not (floor < target <= bloat): + hi = float(entry["hi"]) + ceiling = float(entry["ceiling"]) + if not (floor < lo <= target <= hi < ceiling): raise ValueError( - f"{where}.{res}: must satisfy floor < target <= bloat, " - f"got floor={floor} target={target} bloat={bloat}" + f"{where}.{res}: must satisfy floor < lo <= target <= hi < ceiling, " + f"got floor={floor} lo={lo} target={target} hi={hi} ceiling={ceiling}" ) - out[res_int] = (floor, target, bloat) + out[res_int] = (floor, lo, target, hi, ceiling) if not out: raise ValueError(f"{where} is empty (a preset must define its size table)") return out @@ -216,15 +236,32 @@ def _parse_topsis(raw: dict) -> TopsisConfig: for name, ov in profiles.items(): if ov.preset is not None and ov.preset not in presets: raise ValueError(f"profiles.{name}.preset {ov.preset!r} is not a defined preset") + score_norm = str(raw.get("score_norm", "logistic")) + if score_norm not in {"logistic", "linear"}: + raise ValueError( + f"optimizer.topsis.score_norm must be 'logistic' or 'linear', got {score_norm!r}" + ) + score_width = float(raw.get("score_width", 85000)) + if score_width <= 0: + raise ValueError(f"optimizer.topsis.score_width must be > 0, got {score_width}") + size_shoulder = float(raw.get("size_shoulder", 0.85)) + if not (0.0 <= size_shoulder <= 1.0): + raise ValueError(f"optimizer.topsis.size_shoulder must be in [0, 1], got {size_shoulder}") + outlier_frac = float(raw.get("outlier_frac", 0.5)) + if not (0.0 <= outlier_frac < 1.0): + raise ValueError(f"optimizer.topsis.outlier_frac must be in [0, 1), got {outlier_frac}") return TopsisConfig( score_ideal=int(raw["score_ideal"]), score_anti_ideal=int(raw["score_anti_ideal"]), - resolution_ideal=int(raw["resolution_ideal"]), - resolution_anti_ideal=int(raw["resolution_anti_ideal"]), score_gap=float(raw["score_gap"]), default_preset=default_preset, default_min_closeness_gain=default_min_gain, presets=presets, + score_norm=score_norm, + score_center=float(raw.get("score_center", 805000)), + score_width=score_width, + size_shoulder=size_shoulder, + outlier_frac=outlier_frac, profiles=profiles, ) diff --git a/optimizarr/features/optimizer/decision.py b/optimizarr/features/optimizer/decision.py index 1fed738..3372909 100644 --- a/optimizarr/features/optimizer/decision.py +++ b/optimizarr/features/optimizer/decision.py @@ -4,11 +4,13 @@ (HOLD) — never merely "we triggered a grab". The decision is: 1. **Prefilter + score** (topsis.py): drop hard rejections, drop outside the preset's size band - (floor..bloat), gap-cut the score tail, then score the survivors by TOPSIS closeness. + (floor..ceiling), gap-cut the score tail, drop lone-small outliers, then score the survivors + by TOPSIS closeness over two axes (score, size). 2. **Swap rule** (here): a candidate is legal iff it raises closeness by at least the preset's - `min_closeness_gain` AND it does not drop resolution below the profile target. Because - closeness is computed from the release alone (not the current file), every accepted swap - strictly increases it, so the optimizer is provably non-oscillating. + `min_closeness_gain`, does not drop resolution below the profile target, AND is not "bigger at + a lower-or-equal score" (a larger file is grabbed only on a genuine score upgrade — the peaked + size curve no longer guarantees that for free). Because closeness is computed from the release + alone, every accepted swap strictly increases it, so the optimizer is provably non-oscillating. 3. **Pick** (topsis.py): choose among the legal candidates by the profile's pick method. ACT iff at least one candidate is legal; otherwise HOLD (and the worker marks the item satisfied). @@ -68,13 +70,17 @@ def decide( current_file: dict | None, allow_size_increase: bool = True, allow_quality_downgrade: bool = True, + allow_larger_at_lower_score: bool = False, ) -> Decision: """Pure decision: score the candidates, then keep those that raise closeness past the margin without dropping resolution below target, and pick the best survivor. Two optional pre-filters apply before scoring (per-app policy): - allow_size_increase=False drops releases bigger than the current file; - - allow_quality_downgrade=False drops releases with a lower customFormatScore.""" + - allow_quality_downgrade=False drops releases with a lower customFormatScore. + allow_larger_at_lower_score=True drops the "never grow at a lower-or-equal score" swap guard, + letting the size bands realign even when the only nearer-the-band release scores lower (at the + cost of score regressions on bigger encodes).""" cur = current_file or {} cur_size = cur.get("size") if not allow_size_increase and isinstance(cur_size, int) and cur_size > 0: @@ -94,6 +100,22 @@ def decide( legal: list[tuple[dict, dict, float]] = [] for rel, attrs, clo in scored: + # Never grow a file without a score upgrade. Under the old one-sided size curve "no bigger + # file for a lower score" fell out for free; the peaked trapezoid can rank a bigger, + # nearer-the-band release above a too-small current file, so we restore the invariant + # explicitly: a larger file is accepted only on a genuine score upgrade. This stops the + # "too small smells bad" penalty from degrading into "drop the score to inflate the file". + cand_score = attrs["raw"]["score"] + if ( + not allow_larger_at_lower_score + and cur_score is not None + and cand_score is not None + and cand_score <= cur_score + and isinstance(cur_size, int) + and cur_size > 0 + and rel.get("size", 0) > cur_size + ): + continue if swap_allowed( current_closeness, clo, diff --git a/optimizarr/features/optimizer/topsis.py b/optimizarr/features/optimizer/topsis.py index d3f7077..12b1c5e 100644 --- a/optimizarr/features/optimizer/topsis.py +++ b/optimizarr/features/optimizer/topsis.py @@ -1,19 +1,22 @@ """TOPSIS-based release scorer + per-profile pickers. -Multi-objective release scoring. Three axes, each normalized to [0,1]: - - score: Profilarr customFormatScore, fixed scale [anti_ideal, ideal] (higher better) - - resolution: pixel height toward the profile target (higher better, low weight — Profilarr - already folds resolution into score, so this axis mostly avoids double-counting) - - size: GiB/h on a ONE-SIDED curve — n_size = 1.0 at/below the preset's `target`, - ramping to 0 at the preset's `bloat`. Smaller than the target is never penalized, - so nothing is ever inflated to "reach" a target. - -Inclusion (before scoring): drop hard rejections, drop outside the preset's per-resolution size -band (gbh < floor = fakes/upscales, gbh > bloat = bloat), then gap-cut the score tail. - -The size table `{floor, target, bloat}` is now PER-PRESET (config-driven): each resolved profile -carries its own table + weights + pick + a `min_closeness_gain` swap margin. The swap rule lives -in decision.py (a closeness-gain test); this module scores and picks among the survivors. +Multi-objective release scoring on TWO axes, each normalized to [0,1]: + - score: Profilarr customFormatScore through a fixed transform (logistic by default), higher + better. Resolution is NOT a scored axis — Profilarr folds it into the score, and a hard + guard in decision.py forbids dropping below the profile target. + - size: GiB/h on a 5-point TRAPEZOID `{floor, lo, target, hi, ceiling}` — n_size is 0 outside + [floor, ceiling], rises to `size_shoulder` at lo, peaks at 1.0 at target, falls back to + `size_shoulder` at hi. The flat-ish [lo, hi] band is the profile's "good size" region: + inside it score drives the pick; outside it the steep shoulders pull back toward the + band. A too-small file IS now penalized (so it can be replaced by one nearer the band). + +Inclusion (before scoring): drop hard rejections, drop outside the per-resolution size band +(gbh < floor or gbh > ceiling), gap-cut the score tail, then drop lone-small size outliers. + +The size table is PER-PRESET (config-driven): each resolved profile carries its own table + +weights + pick + a `min_closeness_gain` swap margin. The swap rule lives in decision.py (a +closeness-gain test); closeness is a fixed function of the release alone, so the optimizer is +provably non-oscillating (every swap strictly raises it). This module scores and picks survivors. """ from __future__ import annotations @@ -96,8 +99,10 @@ def resolve_profile(self, profile_name: str | None) -> ResolvedProfile: weights=weights, pick=pick, reference=reference, min_closeness_gain=min_gain ) - def reference_for(self, res: int, reference: Reference) -> tuple[float, float, float]: - """A preset's (floor, target, bloat) for a resolution; nearest-defined-at-or-below.""" + def reference_for( + self, res: int, reference: Reference + ) -> tuple[float, float, float, float, float]: + """(floor, lo, target, hi, ceiling) for a resolution; nearest defined at or below.""" if res in reference: return reference[res] keys = sorted(reference) @@ -110,15 +115,30 @@ def filter_by_size_band( self, releases: list[dict], runtime_h: float, reference: Reference ) -> list[dict]: """Drop releases outside the preset's per-resolution size band: below `floor` (fakes / - upscales / too-soft-for-the-resolution) or above `bloat` (bloated).""" + upscales / too-soft-for-the-resolution) or above `ceiling` (bloated).""" keep = [] for r in releases: - floor, _target, bloat = self.reference_for(_release_resolution(r), reference) + floor, _lo, _target, _hi, ceiling = self.reference_for( + _release_resolution(r), reference + ) gbh = _release_gbh(r, runtime_h) - if floor <= gbh <= bloat: + if floor <= gbh <= ceiling: keep.append(r) return keep + def drop_size_outliers(self, releases: list[dict], runtime_h: float) -> list[dict]: + """Drop a release whose GiB/h is a lone outlier below the comparable-score cluster: below + `outlier_frac x median(cluster GiB/h)`. "A good encode has corroborating peers", so a single + suspiciously-small release among bigger ones (likely a bad encode) is never targeted. Run on + the gap-cut survivors so "comparable-score" holds. Disabled when outlier_frac <= 0.""" + frac = self.cfg.outlier_frac + if frac <= 0 or len(releases) < 3: + return releases + gbhs = sorted(_release_gbh(r, runtime_h) for r in releases) + mid = len(gbhs) // 2 + median = gbhs[mid] if len(gbhs) % 2 else (gbhs[mid - 1] + gbhs[mid]) / 2 + return [r for r in releases if _release_gbh(r, runtime_h) >= frac * median] + def filter_by_score_gap(self, releases: list[dict]) -> list[dict]: """Keep the top score cluster: sort desc, scan high->low, cut at the first consecutive relative drop greater than score_gap. Negatives are always dropped.""" @@ -144,60 +164,66 @@ def apply_prefilters( diag["after_hard_rejections"] = len(after_hard) after_band = self.filter_by_size_band(after_hard, runtime_h, reference) diag["after_size_band"] = len(after_band) - kept = self.filter_by_score_gap(after_band) - diag["after_score_gap"] = len(kept) - diag["inclusion"] = f"size band + gap-cut (>{self.cfg.score_gap:.0%} drop)" + after_gap = self.filter_by_score_gap(after_band) + diag["after_score_gap"] = len(after_gap) + kept = self.drop_size_outliers(after_gap, runtime_h) + diag["after_outlier_drop"] = len(kept) + diag["inclusion"] = f"size band + gap-cut (>{self.cfg.score_gap:.0%} drop) + outlier drop" return kept, diag # ----- normalization ----- def normalize_score(self, s: float) -> float: cfg = self.cfg + if cfg.score_norm == "logistic": + # S-curve centered at score_center. Profilarr scores bunch near the top (~950k) and + # fall off below, so a logistic puts the axis's resolution where releases actually + # compete, instead of wasting most of [0,1] on scores no release reaches. No hard + # cutoff: a library whose scores all sit near one value still gets spread out. + z = (s - cfg.score_center) / cfg.score_width + if z <= -60: + return 0.0 + if z >= 60: + return 1.0 + return 1.0 / (1.0 + math.exp(-z)) if s >= cfg.score_ideal: return 1.0 if s <= cfg.score_anti_ideal: return 0.0 return (s - cfg.score_anti_ideal) / (cfg.score_ideal - cfg.score_anti_ideal) - def normalize_resolution(self, r: int, target: int | None = None) -> float: - cfg = self.cfg - ideal = target if target else cfg.resolution_ideal - if r >= ideal: - return 1.0 - if r <= cfg.resolution_anti_ideal: + def normalize_size( + self, gbh: float, floor: float, lo: float, target: float, hi: float, ceiling: float + ) -> float: + """5-point trapezoid: 0 outside [floor, ceiling], rising to `size_shoulder` at lo, peaking + at 1.0 at target, falling back to `size_shoulder` at hi. The flat-ish [lo, hi] band is the + "good size" region for the profile; inside it score drives the pick, and the gentle slope to + target lets `target` placement nudge the preference. Unlike the old one-sided curve, a + too-small file IS penalized (it can be replaced by one nearer the band).""" + sh = self.cfg.size_shoulder + if gbh <= floor or gbh >= ceiling: return 0.0 - return (r - cfg.resolution_anti_ideal) / (ideal - cfg.resolution_anti_ideal) - - def normalize_size(self, gbh: float, target: float, bloat: float) -> float: - """One-sided cost curve: 1.0 at or below `target`, linear down to 0 at `bloat`. Smaller - than the target is never penalized — that is what keeps the optimizer from ever inflating - a file to reach a target.""" + if gbh < lo: + return sh * (gbh - floor) / (lo - floor) if lo > floor else sh if gbh <= target: - return 1.0 - if gbh >= bloat or bloat <= target: - return 0.0 - return (bloat - gbh) / (bloat - target) + return sh + (1 - sh) * (gbh - lo) / (target - lo) if target > lo else 1.0 + if gbh <= hi: + return 1.0 - (1 - sh) * (gbh - target) / (hi - target) if hi > target else 1.0 + return sh * (ceiling - gbh) / (ceiling - hi) if ceiling > hi else sh def _attrs( - self, - score: float, - res: int, - gbh: float, - size_gb: float, - resolved: ResolvedProfile, - target_resolution: int | None, + self, score: float, res: int, gbh: float, size_gb: float, resolved: ResolvedProfile ) -> dict: - floor, target, bloat = self.reference_for(res, resolved.reference) + floor, lo, target, hi, ceiling = self.reference_for(res, resolved.reference) return { "n_score": self.normalize_score(score or 0), - "n_resolution": self.normalize_resolution(res, target_resolution), - "n_size": self.normalize_size(gbh, target, bloat), + "n_size": self.normalize_size(gbh, floor, lo, target, hi, ceiling), "raw": { "score": score, "resolution": res, "gbh": gbh, "size_gb": size_gb, - "reference": (floor, target, bloat), + "reference": (floor, lo, target, hi, ceiling), "target": target, }, } @@ -209,7 +235,8 @@ def attributes_for( resolved: ResolvedProfile, target_resolution: int | None = None, ) -> dict: - """Normalized [0,1] attributes + raw values for one release.""" + """Normalized [0,1] attributes + raw values for one release. (target_resolution is accepted + for call-site symmetry but unused: resolution is a guard in decision.py, not an axis.)""" size_bytes = release.get("size", 0) return self._attrs( release.get("customFormatScore", 0), @@ -217,16 +244,11 @@ def attributes_for( _release_gbh(release, runtime_h), size_bytes / GB, resolved, - target_resolution, ) def closeness(self, attrs: dict, weights: dict[str, float]) -> float: - """TOPSIS closeness in [0,1]. 1 = ideal, 0 = anti-ideal.""" - w = { - "n_score": weights["score"], - "n_resolution": weights["resolution"], - "n_size": weights["size"], - } + """TOPSIS closeness in [0,1] over the two axes (score, size). 1 = ideal, 0 = anti-ideal.""" + w = {"n_score": weights["score"], "n_size": weights["size"]} d_ideal = math.sqrt(sum(w[k] * (1.0 - attrs[k]) ** 2 for k in w)) d_anti = math.sqrt(sum(w[k] * attrs[k] ** 2 for k in w)) total = d_ideal + d_anti @@ -254,7 +276,7 @@ def current_attributes( size_gb = size / GB gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 res = self._current_resolution(movie_file) - return self._attrs(score, res, gbh, size_gb, resolved, target_resolution) + return self._attrs(score, res, gbh, size_gb, resolved) def closeness_for_current_file( self, diff --git a/test-results/20260602-logistic-score-normalization.md b/test-results/20260602-logistic-score-normalization.md new file mode 100644 index 0000000..b773c5d --- /dev/null +++ b/test-results/20260602-logistic-score-normalization.md @@ -0,0 +1,66 @@ +# Logistic score normalization (fix flat top-end of the score axis) + +Date: 2026-06-02 + +## Problem + +Linear `n_score` over `[0, 1,000,000]` made the score axis nearly flat where real releases live. +The gap-cut keeps only candidates within `score_gap` (20%) of the top, so survivors sit in +~`[800k, 1M]`, i.e. the top 0.20 of the axis, while size/resolution use the full `[0,1]`. +Effective discriminating power (`weight x range-used`) for the Quality preset was score ~0.13 vs +size ~0.20, so size out-voted score despite a 3x higher weight. This drove the 2160p Quality +downgrades (Dune: 949,600/46.2 GB wanting 920,000/14 GB). + +## Data (reports/training_data_radarr.jsonl, 52 movies, 9,684 releases) + +- No release reaches 1,000,000; real max is **952,600**, so linear wastes the top ~5% of the axis. +- Gap-cut survivors are **bimodal** (~720k tier and ~850-952k top tier), per-item competitive + spread ~245k (median). The axis needs resolution across ~700k..952k, not a hard window. +- Best-fit logistic to the survivor CDF: **center 805,000, width 85,000**. + +Top-end resolution (Dune is 949.6k vs 920k): + +| gap | linear /1M | logistic 805k/85k | +| --- | --- | --- | +| 950k - 920k | +0.030 | **+0.051** | + +## Why a fixed curve, not relative-to-candidate-list scoring + +Relative (within-set) scoring would make a release's closeness depend on what the indexers +returned that minute, breaking the invariant that closeness is a fixed function of the release +alone. That invariant is what guarantees no oscillation. Relative scoring can therefore churn and +even downgrade (a weak set `{700k, 740k}` normalizes 740k to 1.0 and can replace a 920k file). +The logistic keeps the "resolution where releases cluster" benefit while staying a fixed, +release-only transform, so non-oscillation still holds. + +## Change + +- `optimizarr/features/optimizer/topsis.py`: `normalize_score` gains a logistic branch. +- `optimizarr/features/optimizer/config.py`: `TopsisConfig` gains `score_norm` (default + `"logistic"`), `score_center` (805000), `score_width` (85000); `"linear"` still available. +- `optimizarr/defaults.toml`: documents and sets the above. +- `ALGORITHM.md`: scoring section updated. +- Quality preset retune from the prior change (2160p target 16 / bloat 30, weights + 0.73/0.15/0.12) is kept: the curve fixes score discrimination globally; the preset still sets + what 2160p file sizes the profile accepts. + +## Validation (real `decide()` via `default_topsis()`, "2160p Quality", Dune runtime 2.567 h) + +| outcome | scenario | notes | +| --- | --- | --- | +| HOLD | 949,600/46.2 GB vs 920,000/14 GB | the report; was ACT | +| HOLD | 949,600/46.2 GB vs 935,000/20.5 GB (~8 GiB/h) | small score drop refused | +| HOLD | 949,600/46.2 GB vs 949,600/20.5 GB | same-score shrink; Quality is quality-first | +| ACT | 920,000/30 GB vs 949,600/25 GB | genuine score upgrade, also smaller | +| ACT | 700,000/46 GB vs 949,600/25 GB | upgrade a poor file | +| ACT | 949,600/80 GB vs 949,600/40 GB | real bloat trim at equal score | +| HOLD | 924,600/30 GB vs 720,000/12 GB | low-tier downgrade now correctly refused | + +`ruff format` + `ruff check`: clean. `pytest -q`: 137 passed. + +## Tunables + +`score_center` / `score_width` in `[optimizer.topsis]`. Smaller width = steeper = more +resolution at the very top (920 vs 950) but flatter tier separation; larger = gentler. The +same-score-shrink HOLD above is a Quality choice (size weight 0.12); raise the Quality `size` +weight to re-enable free same-quality shrinks (the curve keeps score safe either way). diff --git a/test-results/20260602-quality-preset-retune.md b/test-results/20260602-quality-preset-retune.md new file mode 100644 index 0000000..9380a01 --- /dev/null +++ b/test-results/20260602-quality-preset-retune.md @@ -0,0 +1,41 @@ +# Quality preset retune: stop downgrading high-score 2160p files + +Date: 2026-06-02 + +## Problem + +The shipped **Quality** preset was downgrading large, near-perfect 2160p files. Reported case: +Dune (2021), current file score 949,600 / 46.2 GB (~17.9 GiB/h), optimizer wanted a +920,000 / 14 GB release. Generally 2160p Quality files were dropping from ~18 GiB/h to ~8. + +Root cause: at the top of the Profilarr score scale every good release normalizes to ~0.92-0.95 +(linear over `[0, 1,000,000]`), so the score axis is nearly flat and the old size weight (0.20) +plus a low 2160p band (`target 11 / bloat 20`) let the size axis dominate. A 46 GB file read as +near-bloat (`n_size = 0.236`) and almost any smaller in-band release looked like a big win. + +## Change ([defaults.toml](../optimizarr/defaults.toml)) + +| | weights (score/res/size) | 2160p floor/target/bloat | +| --- | --- | --- | +| before | 0.65 / 0.15 / 0.20 | 5 / 11 / 20 | +| after | 0.73 / 0.15 / 0.12 | 5 / 16 / 30 | + +Lower resolutions raised in step to keep the preset coherent: 1080p 1.5/7/14, 720p 0.5/2.5/6, +480p 0.25/1/3.5. Docs updated in [ALGORITHM.md](../ALGORITHM.md). + +## Validation (real `decide()` via `default_topsis()`, Dune runtime 2.567 h) + +| outcome | scenario | current closeness | best/pick | Δ | +| --- | --- | --- | --- | --- | +| HOLD | 949,600 / 46.2 GB vs 920,000 / 14 GB (the report) | 0.935 | (no legal swap) | - | +| HOLD | 949,600 / 46.2 GB vs 935,000 / 20.5 GB (~8 GiB/h) | 0.935 | (no legal swap) | - | +| ACT | 949,600 / 46.2 GB vs 949,600 / 20.5 GB (equal score, leaner) | 0.935 | 0.957 | +0.022 | +| ACT | 700,000 / 46 GB vs 1,000,000 / 30 GB (true upgrade) | 0.748 | 1.000 | +0.252 | +| ACT | 949,600 / 80 GB (~31 GiB/h) vs 949,600 / 40 GB (real bloat trim) | 0.720 | 0.957 | +0.237 | + +Net: score-losing downgrades of high-score 2160p files now HOLD; equal-or-better-score leaner +files and genuine bloat trims still ACT. + +## Test suite + +`pytest -q`: 137 passed. diff --git a/test-results/20260602-score-curve-refit-250movies.md b/test-results/20260602-score-curve-refit-250movies.md new file mode 100644 index 0000000..c047b21 --- /dev/null +++ b/test-results/20260602-score-curve-refit-250movies.md @@ -0,0 +1,65 @@ +# Score curve re-fit on 250 movies + library-wide downgrade replay + +Date: 2026-06-02 + +## Why + +The logistic score curve and the Quality preset were first tuned on a 52-movie sample whose top +score maxed at 952,600. Re-harvested a larger random sample to confirm the fit, excluding +sub-zero releases from the analysis (they are dropped by the gap-cut before scoring anyway). + +## New dataset + +`tools/gather_training_data.py --app radarr --count 250` (seed 0, superset of the old sample). +250 movies, 46,635 releases (28,056 with score >= 0; 18,579 negative, excluded from the fit). + +- Release scores now reach **993,060** (vs 952,600 before), p99 = 986k. Confirms scores do climb + near the 1,000,000 "perfect" anchor; the small sample undersold the top. +- Gap-cut survivors (the releases `n_score` actually ranks), >= 0: 13,756. Still multi-tier + (~700k / ~860k / ~920-993k), competitive spread wide. + +## Re-fit + +Best-fit logistic to the survivor CDF: **center 792,500, width 87,500** — essentially the same +as the small-sample fit (805k/85k). Quality 2160p target lowered 16 -> 14 per operator preference +(still HOLDs the Dune case; now also takes equal-score leaner files). + +**Final adopted value: center 825,000, width 87,500.** A follow-up tool, `tools/score_curve_fit.py`, +re-fit the curve on three candidate populations: all releases >= 0 (degenerate, grid-railed), +post gap-cut (~790k, ~ the survivor fit), and the **real** candidates that survive the full +prefilters (eligible + per-item size band + score gap), which fit center **825,000**. The real +population is the right basis (it is exactly what `n_score` ranks); the size band strips the +low-score junk that survives the gap-cut, so the center sits higher. Practical impact is small +(31 score-downgrades either way in replay), but it gives marginally sharper top-tier +discrimination. Set in `[optimizer.topsis]` and the `TopsisConfig` fallback. Note: many movies +had their profiles changed recently, so current_file may be far from target; that only affects +the replay sanity-check, not the curve fit (which is over release scores). + +## Library-wide replay (the real test) + +Replayed `decide()` over all 250 movies with shipped defaults (allow_size_increase=true, +allow_quality_downgrade=true), counting ACT decisions where the picked release scores LOWER than +the current file (a score-downgrade). + +| normalization | ACT | score-downgrades | >= 20k drop | +| --- | --- | --- | --- | +| logistic (new default) | 140 | 31 | 1 | +| linear [0, 1M] (old) | 135 | 33 | 1 | + +Downgrades by profile (logistic): **28 of 31 are `2160p Efficient`** (size-leaning, by design), +2 are `1080p Efficient`, 1 is `2160p Balanced`. **Zero on any Quality profile** — the reported +regression is gone. + +The remaining Efficient downgrades are the feature working: ~0.5-1% score traded for roughly +halving the file, e.g. + +| profile | title | score | size | +| --- | --- | --- | --- | +| 2160p Efficient | The Batman (2022) | 989,040 -> 984,000 | 35.8 -> 16.6 GB | +| 2160p Efficient | Kingdom of the Planet of the Apes | 991,000 -> 980,000 | 25.5 -> 13.3 GB | +| 2160p Efficient | Spider-Man: No Way Home | 993,060 -> 987,000 | 27.8 -> 14.8 GB | + +## Checks + +`ruff format` + `ruff check`: clean. `pytest -q`: 137 passed. Dune (949,600/46.2 GB vs +920,000/14 GB) under `2160p Quality`: HOLD. diff --git a/test-results/20260603-2axis-trapezoid-size-model.md b/test-results/20260603-2axis-trapezoid-size-model.md new file mode 100644 index 0000000..e85ba4c --- /dev/null +++ b/test-results/20260603-2axis-trapezoid-size-model.md @@ -0,0 +1,52 @@ +# 2-axis scoring + 5-point trapezoid size model (Phase 2) + +Date: 2026-06-03 + +## What changed + +Replaced the 3-axis TOPSIS (score, resolution, size) + one-sided size curve with: + +- **Two axes** (score, size). Resolution is no longer scored: Profilarr folds it into the score and + a hard guard in decision.py already forbids dropping below the profile target. Weights are now + `{score, size}` summing to 1.0. +- **5-point trapezoid size band** `{floor, lo, target, hi, ceiling}` (renamed `bloat` -> `ceiling`). + `n_size` is 0 outside `[floor, ceiling]`, rises to `size_shoulder` (0.85) at lo, peaks at 1.0 at + target, falls back to the shoulder at hi. Inside `[lo, hi]` score drives the pick; `target` sits + at a per-profile size percentile (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, + Remux remux-range), so the five profiles aim at different sizes and pick different releases. +- **Outlier prefilter**: drop a candidate below `outlier_frac` (0.5) x the gap-cut cluster median + GiB/h ("a good encode has corroborating peers"). +- **No-inflate-at-score-loss invariant**: decision.py forbids grabbing a release that is bigger AND + lower-or-equal score. The old one-sided curve gave this for free; the peaked trapezoid does not, + so it is now explicit. A larger file is grabbed only on a genuine score upgrade. +- All presets pick by `topsis` (divergence comes from band placement, not the pick method); + `max_score`/`min_size` remain for overrides. + +## Why these choices (Phase 1 experiment, tools/size_band_lab.py) + +- **Relative percentile bands were rejected**: 1796 re-grabs in a resampled-set oscillation walk + vs **0** for absolute bands. Absolute bands + outlier prefilter is provably stable. +- Shoulder swept 0.6-0.9; 0.85 gives the best in-band score-driving without disturbing where the + size-leaning profiles land. + +## Validation (real engine over 250-movie set) + +- **Divergence** (distinct picks across the 5 presets, 2160p movies): 38 all-5, 22 of 4, 7 of 3, + 1 of 2, 1 of 1. +- **Score-downgrades under each item's own profile**: 19, all on size-leaning profiles (17 Efficient + 2160p, 1 Efficient 1080p, 1 Balanced) and **all shrink the file** (the intended score-for-size + trade). **Zero** on Quality or Remux. +- **Zero grow-at-lower-score swaps** (was 52 before the invariant; the worst was a 220k-point drop + to grow 1.9 -> 3.8 GB, now blocked). +- **Dune** (949,600 / 46.2 GB vs 920,000 / 14 GB under 2160p Quality): HOLD. +- Weights sweep confirmed raising score weight does NOT fix the grows (71 -> 63 only, and hurts + divergence) because the current files sit below floor (n_size 0); the invariant is the right fix. + +## Tests / tooling + +- `pytest -q`: 137 passed (incl. the no-oscillation convergence test and the restored + `test_bigger_at_no_score_gain_is_never_grabbed`). `ruff format` + `ruff check`: clean. +- Updated: topsis.py, config.py, decision.py, defaults.toml, ALGORITHM.md, README.md, + tools/weight_lab.py, tools/cutoff_viz.py. New: tools/size_band_lab.py. +- Reports: reports/size_band_lab_20260602_223602.md (model choice), + reports/score_curve_fit_20260602_162909.md (score curve). diff --git a/tests/test_config.py b/tests/test_config.py index 53de663..d37868c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -186,15 +186,15 @@ def test_rejects_unknown_pick_method(monkeypatch, tmp_path): load_config(path) -def test_rejects_reference_target_above_bloat(monkeypatch, tmp_path): +def test_rejects_reference_out_of_order(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://x") monkeypatch.setenv("RADARR_API_KEY", "k") path = _write( tmp_path, "[optimizer.topsis.presets.Balanced.reference]\n" - '"2160" = { floor = 3, target = 20, bloat = 18 }\n', + '"2160" = { floor = 3, lo = 5, target = 20, hi = 10, ceiling = 18 }\n', ) - with pytest.raises(ValueError, match="floor < target <= bloat"): + with pytest.raises(ValueError, match="floor < lo <= target <= hi < ceiling"): load_config(path) @@ -318,7 +318,7 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): preset = "Remux" [optimizer.topsis.profiles."Custom 1080p"] - weights = { score = 0.5, resolution = 0.1, size = 0.4 } + weights = { score = 0.6, size = 0.4 } """, ) @@ -329,12 +329,12 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): assert t.score_gap == 0.30 # shipped presets survive the deep-merge assert {"Remux", "Quality", "Balanced", "Efficient", "Compact"} <= set(t.presets) - assert t.presets["Compact"].weights["size"] == 0.70 - assert t.presets["Compact"].pick == "min_size" - # per-preset absolute size tables (floor, target, bloat) - assert t.presets["Balanced"].reference[2160] == (4.5, 9.0, 16.0) - assert t.presets["Efficient"].reference[2160] == (3.5, 6.5, 14.0) - assert t.presets["Remux"].reference[2160] == (15.0, 35.0, 80.0) + assert t.presets["Compact"].weights["size"] == 0.78 + assert t.presets["Compact"].pick == "topsis" + # per-preset 5-point size tables (floor, lo, target, hi, ceiling) + assert t.presets["Balanced"].reference[2160] == (5.0, 8.1, 9.3, 11.0, 20.0) + assert t.presets["Efficient"].reference[2160] == (4.0, 7.1, 7.8, 8.8, 15.0) + assert t.presets["Remux"].reference[2160] == (15.0, 28.0, 38.0, 60.0, 90.0) # swap margin default assert t.default_min_closeness_gain == 0.02 assert t.presets["Balanced"].min_closeness_gain == 0.02 diff --git a/tests/test_decision.py b/tests/test_decision.py index 5dab129..c1408a4 100644 --- a/tests/test_decision.py +++ b/tests/test_decision.py @@ -95,24 +95,27 @@ def test_decide_hold_on_bigger_file_without_score_gain(): assert d.reason == "nothing better" -def test_decide_remux_refuses_lower_score_efficient_takes_it(): - # Lower score, smaller file: Efficient (size-leaning) gains closeness and takes it; Remux - # (score-dominated) does not, and the lean encode is also below Remux's size floor. - current = _file(score=900_000, resolution=2160, size_gb=20.0) # 10 GiB/h - leaner = _release(guid="lean", score=850_000, resolution=2160, size_gb=9.0) # 4.5 GiB/h +def test_decide_efficient_pulls_toward_band_remux_does_not(): + # A release nearer Efficient's band (lower bitrate) at a slightly lower score raises closeness + # for Efficient (size-leaning, band ~7.8 GiB/h); Remux refuses it (8 GiB/h is below Remux's + # floor, so it is dropped before scoring). + current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h, above Efficient band + leaner = _release(guid="lean", score=850_000, resolution=2160, size_gb=16.0) # 8 GiB/h, in band d_eff = decide(_topsis(), [leaner], 2.0, "2160p Efficient", 2160, current_file=current) assert d_eff.action == "ACT" d_remux = decide(_topsis(), [leaner], 2.0, "2160p Remux", 2160, current_file=current) assert d_remux.action == "HOLD" -def test_decide_compact_picks_smallest_legal(): - current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h - a = _release(guid="a", score=900_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h - b = _release(guid="b", score=880_000, resolution=2160, size_gb=8.0) # 4 GiB/h, slightly lower - d = decide(_topsis(), [a, b], 2.0, "Compact", 2160, current_file=current) +def test_decide_compact_picks_near_its_band_not_the_smallest(): + # Sweet-spot model: Compact aims at its band (~P10, 6.8 GiB/h at 2160), not the absolute + # smallest. A near-peak release beats a tinier one that has dropped below the band. + current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h, above the band + near = _release(guid="near", score=900_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h, in band + tiny = _release(guid="tiny", score=880_000, resolution=2160, size_gb=8.0) # 4 GiB/h, below band + d = decide(_topsis(), [near, tiny], 2.0, "Compact", 2160, current_file=current) assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "b" # min_size among legal survivors + assert d.release is not None and d.release["guid"] == "near" def test_decide_drops_bigger_releases_when_size_increase_disallowed(): diff --git a/tests/test_swap.py b/tests/test_swap.py index f06f1e2..5e40b6b 100644 --- a/tests/test_swap.py +++ b/tests/test_swap.py @@ -71,16 +71,18 @@ def test_swap_resolution_guard_overrides_a_big_gain(): # ----- the "no senseless swap" property falls out of closeness ----- -def test_lower_score_bigger_or_equal_is_never_grabbed(): - """Same resolution, score <= current and size >= current cannot raise closeness, so it is - never grabbed — for any preset. (No explicit rule needed; it follows from the gate.)""" +def test_bigger_at_no_score_gain_is_never_grabbed(): + """A bigger file with a lower-OR-equal score is never grabbed, for any preset and any current + size. The peaked trapezoid no longer guarantees this for free (a bigger, nearer-the-band + release can out-score a too-small current file), so decide() enforces it explicitly: you only + accept a larger file when it is a genuine score upgrade.""" rnd = random.Random(7) for profile in PRESETS: for _ in range(400): cur_score = rnd.randint(0, 1_000_000) - cur_gbh = round(rnd.uniform(4.0, 12.0), 2) + cur_gbh = round(rnd.uniform(0.6, 12.0), 2) # anywhere, including below the band cand_score = rnd.randint(0, cur_score) # <= current - cand_gbh = round(rnd.uniform(cur_gbh, 18.0), 2) # >= current + cand_gbh = round(rnd.uniform(cur_gbh + 0.5, 22.0), 2) # strictly bigger cur = _file(cur_score, 2160, cur_gbh * 2) # runtime 2h: gbh = size_gb / 2 cand = _release(cand_score, 2160, cand_gbh * 2) d = decide(T, [cand], 2.0, f"2160p {profile}", 2160, current_file=cur) diff --git a/tests/test_topsis.py b/tests/test_topsis.py index b445d29..40db3c6 100644 --- a/tests/test_topsis.py +++ b/tests/test_topsis.py @@ -1,3 +1,7 @@ +from dataclasses import replace + +import pytest + from optimizarr.features.optimizer.config import default_topsis from optimizarr.features.optimizer.topsis import GB, Topsis, eligible @@ -28,31 +32,37 @@ def test_eligible_drops_blocklisted_and_temp(): assert len(keep) == 1 -def test_size_band_drops_below_floor_and_above_bloat(): +def test_size_band_drops_below_floor_and_above_ceiling(): t = _topsis() - ref = t.resolve_profile("2160p Balanced").reference # 2160 -> (4.5, 9.0, 16.0) - fake = _release(resolution=2160, size_gb=4.0) # 2.0 GiB/h < floor 4.5 + ref = t.resolve_profile("2160p Balanced").reference # 2160 -> (5.0, 8.1, 9.3, 11.0, 20.0) + fake = _release(resolution=2160, size_gb=4.0) # 2.0 GiB/h < floor 5.0 real = _release(resolution=2160, size_gb=20.0) # 10 GiB/h, in band - bloated = _release(resolution=2160, size_gb=40.0) # 20 GiB/h > bloat 16 + bloated = _release(resolution=2160, size_gb=44.0) # 22 GiB/h > ceiling 20 kept = t.filter_by_size_band([fake, real, bloated], 2.0, ref) assert kept == [real] -def test_normalize_size_one_sided_plateau_then_ramp(): - t = _topsis() - # target=9, bloat=16 (2160 Balanced-ish) - assert t.normalize_size(3.0, 9.0, 16.0) == 1.0 # below target: never penalized - assert t.normalize_size(9.0, 9.0, 16.0) == 1.0 # at target: still 1.0 - assert t.normalize_size(16.0, 9.0, 16.0) == 0.0 # at bloat - mid = t.normalize_size(12.5, 9.0, 16.0) # halfway target->bloat - assert abs(mid - 0.5) < 1e-9 - assert t.normalize_size(25.0, 9.0, 16.0) == 0.0 # past bloat +def test_normalize_size_trapezoid(): + t = _topsis() # size_shoulder = 0.85 + f, lo, tg, hi, c = 4.0, 8.0, 10.0, 12.0, 20.0 + assert t.normalize_size(4.0, f, lo, tg, hi, c) == 0.0 # at floor + assert t.normalize_size(3.0, f, lo, tg, hi, c) == 0.0 # below floor -> too small, penalized + assert t.normalize_size(8.0, f, lo, tg, hi, c) == pytest.approx(0.85) # at lo = shoulder + assert t.normalize_size(10.0, f, lo, tg, hi, c) == 1.0 # at target = peak + assert t.normalize_size(12.0, f, lo, tg, hi, c) == pytest.approx(0.85) # at hi = shoulder + assert t.normalize_size(20.0, f, lo, tg, hi, c) == 0.0 # at ceiling + assert t.normalize_size(25.0, f, lo, tg, hi, c) == 0.0 # above ceiling + assert t.normalize_size(6.0, f, lo, tg, hi, c) == pytest.approx(0.425) # midway floor->lo + assert t.normalize_size(9.0, f, lo, tg, hi, c) == pytest.approx(0.925) # midway lo->target -def test_normalize_size_degenerate_bloat_equals_target(): +def test_normalize_size_degenerate_zero_width_peak(): t = _topsis() - assert t.normalize_size(9.0, 9.0, 9.0) == 1.0 # at/below target - assert t.normalize_size(9.5, 9.0, 9.0) == 0.0 # above a zero-width ramp + # lo == target == hi: a sharp peak, shoulders still ramp to floor/ceiling + f, lo, tg, hi, c = 4.0, 9.0, 9.0, 9.0, 16.0 + assert t.normalize_size(9.0, f, lo, tg, hi, c) == 1.0 + assert t.normalize_size(6.5, f, lo, tg, hi, c) == pytest.approx(0.85 * (6.5 - 4) / (9 - 4)) + assert t.normalize_size(12.5, f, lo, tg, hi, c) == pytest.approx(0.85 * (16 - 12.5) / (16 - 9)) def test_score_gap_keeps_cluster_drops_tail_and_negatives(): @@ -90,12 +100,12 @@ def test_resolve_profile_override_reference_pick_and_gain(): cfg.profiles["Special"] = ProfileOverride( preset="Efficient", pick="min_size", - reference={2160: (3.0, 6.0, 11.0)}, + reference={2160: (3.0, 5.0, 6.0, 8.0, 11.0)}, min_closeness_gain=0.05, ) rp = Topsis(cfg).resolve_profile("Special") assert rp.pick == "min_size" - assert rp.reference == {2160: (3.0, 6.0, 11.0)} + assert rp.reference == {2160: (3.0, 5.0, 6.0, 8.0, 11.0)} assert rp.min_closeness_gain == 0.05 assert rp.weights == cfg.presets["Efficient"].weights # inherited @@ -103,7 +113,7 @@ def test_resolve_profile_override_reference_pick_and_gain(): def test_score_candidates_orders_by_closeness(): t = _topsis() rp = t.resolve_profile("2160p Quality") - good = _release(score=1_000_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h, in band + good = _release(score=1_000_000, resolution=2160, size_gb=23.0) # 11.5 GiB/h = Quality target weak = _release(score=950_000, resolution=1080, size_gb=14.0) # lower res, 7 GiB/h scored, diag = t.score_candidates([weak, good], 2.0, rp, target_resolution=2160) assert scored[0][0] is good @@ -111,28 +121,33 @@ def test_score_candidates_orders_by_closeness(): assert diag["input"] == 2 -def test_select_max_score_for_remux(): +def test_compact_aims_at_its_band_not_the_smallest(): + # Sweet-spot model: Compact targets its band (~P10), not the absolute smallest. A near-peak + # release with a strong score beats a smaller one that has fallen below the band. t = _topsis() - rp = t.resolve_profile("2160p Remux") # 2160 band 15..80 - big = _release(score=1_000_000, resolution=2160, size_gb=60.0) # 30 GiB/h - lean = _release(score=900_000, resolution=2160, size_gb=36.0) # 18 GiB/h, in band - scored, _ = t.score_candidates([lean, big], 2.0, rp, 2160) + rp = t.resolve_profile("Compact") # 2160 band floor 3.5, lo 5.9, target 6.8, hi 7.6, ceiling 12 + below = _release(score=850_000, resolution=2160, size_gb=9.0) # 4.5 GiB/h, below lo + inband = _release(score=1_000_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h, near peak + scored, _ = t.score_candidates([below, inband], 2.0, rp, 2160) selected = t.select(scored, rp) - assert selected is not None - rel, _attrs, _clo = selected - assert rel is big # highest score wins regardless of size + assert selected is not None and selected[0] is inband -def test_select_min_size_for_compact(): +def test_select_pick_methods(): + # select() still supports all three pick methods for profile overrides, applied to already-legal + # candidates: max_score = highest score, min_size = smallest GiB/h, topsis = highest closeness. t = _topsis() - rp = t.resolve_profile("Compact") # 2160 band 4..12 - small = _release(score=850_000, resolution=2160, size_gb=9.0) # 4.5 GiB/h, in band - bigger = _release(score=1_000_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h - scored, _ = t.score_candidates([bigger, small], 2.0, rp, 2160) - selected = t.select(scored, rp) - assert selected is not None - rel, _attrs, _clo = selected - assert rel is small # smallest wins + rp = t.resolve_profile("Balanced") + a = _release(score=900_000, resolution=2160, size_gb=18.0) # 9 GiB/h + b = _release(score=950_000, resolution=2160, size_gb=22.0) # 11 GiB/h + + def attrs(r): + return {"raw": {"score": r["customFormatScore"], "gbh": r["size"] / GB / 2}} + + cands = [(a, attrs(a), 0.50), (b, attrs(b), 0.60)] + assert t.select(cands, replace(rp, pick="max_score"))[0] is b # higher score + assert t.select(cands, replace(rp, pick="min_size"))[0] is a # smaller GiB/h + assert t.select(cands, replace(rp, pick="topsis"))[0] is b # higher closeness def test_select_empty_returns_none(): diff --git a/tools/cutoff_viz.py b/tools/cutoff_viz.py index 04a4ffc..0015276 100644 --- a/tools/cutoff_viz.py +++ b/tools/cutoff_viz.py @@ -139,9 +139,11 @@ def analyze( elif rid not in id_hard: status = f"drop: hard reject ({_hard_reject_reason(r)})" elif rid not in id_band: - floor, _t, bloat = topsis.reference_for(cand_res, resolved.reference) + floor, _lo, _t, _hi, ceiling = topsis.reference_for(cand_res, resolved.reference) g = attrs["raw"]["gbh"] - where = f"{g:.2f} < floor {floor:.2f}" if g < floor else f"{g:.2f} > bloat {bloat:.2f}" + where = ( + f"{g:.2f} < floor {floor:.2f}" if g < floor else f"{g:.2f} > ceiling {ceiling:.2f}" + ) status = f"drop: outside size band ({where})" elif rid not in id_gap: if (r.get("customFormatScore") or 0) < 0: diff --git a/tools/diagnose.py b/tools/diagnose.py new file mode 100644 index 0000000..7f8f734 --- /dev/null +++ b/tools/diagnose.py @@ -0,0 +1,661 @@ +"""Unified diagnostic + balance report for the optimizer, run offline against a harvested dataset. + +Drives the REAL engine (decide / Topsis / default_topsis) over the training JSONL and writes three +markdown reports to reports/ (shared timestamp). Read-only; never touches Radarr/Sonarr or config. + + diagnose_picks_.md one row per movie: the current file + what each preset would pick. + Compact overview. Full release titles, never cropped. + diagnose_full_.md per-movie drill-down: the current file, every release (score / res / + GiB-h / size, uncropped title), and each preset's ACT/HOLD + reason + + deltas. This is the "why is X misbehaving" view. + diagnose_stats_.md aggregate balance stats per preset: ACT/HOLD, grow vs shrink, size and + score deltas with dispersion, library size shift (inflate guard on/off), + and the swaps the no-bigger-at-lower-score rule blocked. + diagnose_.html a single self-contained interactive page (Picks / Releases / Stats tabs) + with click-to-sort, per-table text filter, and no pagination. Open it in + a browser; markdown viewers choke on the wide tables. + + uv run python tools/diagnose.py --in reports/training_data_radarr.jsonl +""" + +from __future__ import annotations + +import argparse +import html +import json +from datetime import datetime +from pathlib import Path + +from optimizarr.features.optimizer.config import default_topsis +from optimizarr.features.optimizer.decision import decide, resolution_ok +from optimizarr.features.optimizer.topsis import Topsis + + +def _cand(r: dict) -> dict: + return { + "guid": r.get("title"), + "indexerId": 1, + "title": r.get("title") or "?", + "customFormatScore": r.get("score"), + "quality": {"quality": {"resolution": r.get("resolution") or 0}}, + "size": r.get("size_bytes") or 0, + "rejections": r.get("rejections") or [], + "temporarilyRejected": bool(r.get("temporarily_rejected")), + } + + +def _cur(cf: dict | None) -> dict | None: + if not cf or cf.get("score") is None: + return None + return { + "id": 1, + "customFormatScore": cf.get("score"), + "size": cf.get("size_bytes") or 0, + "quality": {"quality": {"resolution": cf.get("resolution") or 0}}, + } + + +def _pct(vals: list[float], p: float) -> float: + if not vals: + return float("nan") + s = sorted(vals) + k = (len(s) - 1) * (p / 100.0) + f = int(k) + c = min(f + 1, len(s) - 1) + return s[f] + (s[c] - s[f]) * (k - f) + + +def _best_unconstrained(t: Topsis, releases, runtime, preset, target_res, cur): + """Highest-closeness candidate clearing the resolution guard + margin, IGNORING the + no-bigger-at-lower-score rule. Returns (rel, attrs, clo, cur_clo) or None.""" + resolved = t.resolve_profile(preset) + scored, _ = t.score_candidates(releases, runtime, resolved, target_res) + cur_clo, cur_raw = t.closeness_for_current_file(cur, runtime, resolved, target_res) + cur_res = cur_raw.get("resolution", 0) or 0 + for rel, attrs, clo in scored: # sorted best-first + if not resolution_ok(cur_res, attrs["raw"]["resolution"], target_res): + continue + if cur_clo is not None and clo < cur_clo + resolved.min_closeness_gain: + continue + return rel, attrs, clo, cur_clo + return None + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) + ap.add_argument("--out-dir", default="reports", type=Path) + args = ap.parse_args() + + rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] + t = Topsis(default_topsis()) + presets = list(t.cfg.presets) # Remux, Quality, Balanced, Efficient, Compact + + # ----- run the engine: per movie, per preset ----- + # records[i] = {title, cur, runtime, target_res, releases_raw, picks{preset: info}} + records = [] + for r in rows: + cf = r.get("current_file") + cur = _cur(cf) + rels = [_cand(x) for x in r["releases"]] + runtime = r["runtime_h"] + target_res = r["profile"]["target_resolution"] or (cf or {}).get("resolution") or 0 + picks = {} + for p in presets: + d = decide(t, rels, runtime, p, target_res, current_file=cur) + info = {"action": d.action, "reason": d.reason} + if d.action == "ACT" and d.pick: + info["pick"] = d.pick + # would the no-inflate rule have blocked a bigger-lower-score swap? + bu = _best_unconstrained(t, rels, runtime, p, target_res, cur) if cur else None + if bu and cf: + rel, attrs, clo, _cc = bu + bigger = (rel.get("size", 0)) > (cf.get("size_bytes") or 0) + lower = (attrs["raw"]["score"] or 0) <= (cf.get("score") or 0) + if bigger and lower: + info["blocked_inflation"] = { + "title": rel.get("title"), + "score": attrs["raw"]["score"], + "res": attrs["raw"]["resolution"], + "size_gb": attrs["raw"]["size_gb"], + "res_upgrade": attrs["raw"]["resolution"] > (cf.get("resolution") or 0), + } + picks[p] = info + + # Library-shift: decide under the movie's OWN profile, with and without the inflate guard. + own = r["profile"]["name"] + d_on = decide(t, rels, runtime, own, target_res, current_file=cur) + d_off = decide( + t, rels, runtime, own, target_res, current_file=cur, allow_larger_at_lower_score=True + ) + records.append( + { + "title": r["title"], + "profile": own, + "target_res": target_res, + "cur": cf, + "releases": r["releases"], + "picks": picks, + "own_on": d_on.pick if d_on.action == "ACT" else None, + "own_off": d_off.pick if d_off.action == "ACT" else None, + } + ) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + args.out_dir.mkdir(exist_ok=True) + _write_picks(records, presets, args.out_dir / f"diagnose_picks_{ts}.md") + _write_full(records, presets, args.out_dir / f"diagnose_full_{ts}.md") + _write_stats(records, presets, args.out_dir / f"diagnose_stats_{ts}.md") + _write_html(records, presets, args.out_dir / f"diagnose_{ts}.html") + print(f"wrote diagnose_{{picks,full,stats}}_{ts}.md + diagnose_{ts}.html to {args.out_dir}/") + + +def _fmt_cur(cf: dict | None) -> str: + if not cf or cf.get("score") is None: + return "no current file" + return ( + f"score={cf['score']:,} res={cf.get('resolution') or '?'}p " + f"size={cf.get('size_gb', 0):.1f}GB ({cf.get('gbh', 0):.2f} GiB/h)" + ) + + +def _pick_cell(info: dict) -> str: + if info["action"] != "ACT" or "pick" not in info: + flag = " [inflation blocked]" if "blocked_inflation" in info else "" + return f"HOLD{flag}" + p = info["pick"] + return f"{p.get('score', 0):,} / {p.get('size_gb', 0):.1f}GB / {p.get('gbh', 0):.2f}gbh" + + +def _write_picks(records, presets, out: Path) -> None: + lines = [ + "# Diagnose: picks overview", + f"\n{len(records)} movies. Each cell = the pick (score / size / GiB-h), or HOLD.\n", + "| movie | profile | current | " + " | ".join(presets) + " |", + "| --- | --- | --- | " + " | ".join("---" for _ in presets) + " |", + ] + for rec in records: + cells = " | ".join(_pick_cell(rec["picks"][p]) for p in presets) + lines.append(f"| {rec['title']} | {rec['profile']} | {_fmt_cur(rec['cur'])} | {cells} |") + out.write_text("\n".join(lines) + "\n") + + +def _write_full(records, presets, out: Path) -> None: + lines = ["# Diagnose: full drill-down", ""] + for rec in records: + lines.append( + f"## {rec['title']} · profile: {rec['profile']} (target {rec['target_res']}p)" + ) + lines.append(f"\n**Current file:** {_fmt_cur(rec['cur'])}\n") + # releases (score >= 0), sorted by score desc, uncropped titles + rels = sorted( + (x for x in rec["releases"] if (x.get("score") or -1) >= 0), + key=lambda x: -(x.get("score") or 0), + ) + neg = sum(1 for x in rec["releases"] if (x.get("score") or 0) < 0) + lines.append(f"**Releases** ({len(rels)} with score>=0, {neg} negative hidden):\n") + lines.append("| score | res | GiB/h | size | rej | title |") + lines.append("| ---: | ---: | ---: | ---: | ---: | --- |") + for x in rels: + rj = "Y" if (x.get("rejections") or x.get("temporarily_rejected")) else "" + lines.append( + f"| {x.get('score', 0):,} | {x.get('resolution') or '?'}p | " + f"{x.get('gbh', 0):.2f} | {x.get('size_gb', 0):.1f}GB | {rj} | {x.get('title')} |" + ) + lines.append("\n**Per-preset decision:**\n") + lines.append("| preset | action | pick | Δscore | Δsize | note |") + lines.append("| --- | --- | --- | ---: | ---: | --- |") + cf = rec["cur"] or {} + for p in presets: + info = rec["picks"][p] + note = "" + if "blocked_inflation" in info: + bi = info["blocked_inflation"] + kind = "RES-UPGRADE" if bi["res_upgrade"] else "same-res" + note = ( + f"would inflate ({kind}): {bi['score']:,}/{bi['size_gb']:.1f}GB {bi['title']}" + ) + if info["action"] == "ACT" and "pick" in info: + pk = info["pick"] + ds = (pk.get("score") or 0) - (cf.get("score") or 0) + dz = (pk.get("size_gb") or 0) - (cf.get("size_gb") or 0) + lines.append( + f"| {p} | ACT | {pk.get('title', '?')} | {ds:+,} | {dz:+.1f}GB | {note} |" + ) + else: + lines.append(f"| {p} | HOLD | - | - | - | {note} |") + lines.append("") + out.write_text("\n".join(lines) + "\n") + + +def _gb(x: float) -> str: + return f"{x / 1024:.2f} TB" if abs(x) >= 1024 else f"{x:.0f} GB" + + +# ----- stat computation (shared by the markdown + HTML renderers) ----- + + +def _shift_data(records) -> dict: + """Library size change per movie under its OWN profile, inflate guard ON vs OFF.""" + d = dict(cur=0.0, on=0.0, off=0.0, on_g=0.0, on_s=0.0, off_g=0.0, off_s=0.0, movies=0, pts=0) + examples = [] + for rec in records: + cf = rec["cur"] + if not cf or cf.get("score") is None: + continue + cz = cf.get("size_gb") or 0 + on_gb = (rec["own_on"] or {}).get("size_gb", cz) if rec["own_on"] else cz + off_gb = (rec["own_off"] or {}).get("size_gb", cz) if rec["own_off"] else cz + d["cur"] += cz + d["on"] += on_gb + d["off"] += off_gb + d["on_g"] += max(0.0, on_gb - cz) + d["on_s"] += max(0.0, cz - on_gb) + d["off_g"] += max(0.0, off_gb - cz) + d["off_s"] += max(0.0, cz - off_gb) + if ( + rec["own_off"] + and off_gb > cz + and (rec["own_off"].get("score") or 0) < (cf["score"] or 0) + and off_gb > on_gb + 1e-6 + ): + d["movies"] += 1 + drop = (cf["score"] or 0) - (rec["own_off"].get("score") or 0) + d["pts"] += drop + examples.append((drop, off_gb - cz, rec["title"])) + d["examples"] = sorted(examples, reverse=True) + return d + + +def _preset_data(records, presets) -> list[dict]: + out = [] + for p in presets: + act = hold = grew = shrank = 0 + ds: list[float] = [] + dsc: list[float] = [] + for rec in records: + info = rec["picks"][p] + cf = rec["cur"] + if info["action"] != "ACT" or "pick" not in info: + hold += 1 + continue + act += 1 + if not cf: + continue + pk = info["pick"] + cz, ps = cf.get("size_gb") or 0, cf.get("score") or 0 + if pk.get("size_gb", 0) > cz: + grew += 1 + elif pk.get("size_gb", 0) < cz: + shrank += 1 + if cz > 0: + ds.append(100 * (pk.get("size_gb", 0) - cz) / cz) + dsc.append((pk.get("score") or 0) - ps) + out.append( + dict(preset=p, act=act, hold=hold, grew=grew, shrank=shrank, dsize=ds, dscore=dsc) + ) + return out + + +def _inflation_data(records, presets): + rows = [] + ups = [] + for p in presets: + tot = same = up = 0 + for rec in records: + bi = rec["picks"][p].get("blocked_inflation") + if not bi: + continue + tot += 1 + if bi["res_upgrade"]: + up += 1 + ups.append((p, rec, bi)) + else: + same += 1 + rows.append(dict(preset=p, total=tot, same=same, up=up)) + return rows, ups + + +def _trip(v: list[float], fmt: str) -> str: + return "-" if not v else f"{_pct(v, 10):{fmt}} / {_pct(v, 50):{fmt}} / {_pct(v, 90):{fmt}}" + + +def _write_stats(records, presets, out: Path) -> None: + lines = ["# Diagnose: balance stats", f"\n{len(records)} movies.\n"] + sh = _shift_data(records) + cur = sh["cur"] or 1 + + def pct(x: float) -> str: + return f"{100 * x / cur:+.1f}%" + + lines.append("## Library size shift (every movie realigned to its OWN profile)\n") + lines.append(f"Current library (movies with a file): **{_gb(sh['cur'])}**\n") + lines.append("| inflate guard | new total | net shift | total grown | total shrunk |") + lines.append("| --- | ---: | ---: | ---: | ---: |") + lines.append( + f"| ON (ship default) | {_gb(sh['on'])} | {_gb(sh['on'] - sh['cur'])} " + f"({pct(sh['on'] - sh['cur'])}) | +{_gb(sh['on_g'])} | -{_gb(sh['on_s'])} |" + ) + lines.append( + f"| OFF (drop the rule) | {_gb(sh['off'])} | {_gb(sh['off'] - sh['cur'])} " + f"({pct(sh['off'] - sh['cur'])}) | +{_gb(sh['off_g'])} | -{_gb(sh['off_s'])} |" + ) + lines.append( + f"\n**Cost of dropping the rule:** +{_gb(sh['off'] - sh['on'])} extra growth, across " + f"{sh['movies']} movies that would grow at a lower score " + f"(total score regression {sh['pts']:,} points).\n" + ) + if sh["examples"]: + lines.append("Biggest score regressions the rule prevents (drop / growth / movie):\n") + for drop, grow, title in sh["examples"][:15]: + lines.append(f"- −{drop:,} pts, +{grow:.1f} GB — {title}") + + lines.append("\n## Per-preset picks (vs the current file)\n") + lines.append( + "| preset | ACT | HOLD | grew | shrank | Δsize% p10/p50/p90 | Δscore p10/p50/p90 |" + ) + lines.append("| --- | ---: | ---: | ---: | ---: | --- | --- |") + for d in _preset_data(records, presets): + lines.append( + f"| {d['preset']} | {d['act']} | {d['hold']} | {d['grew']} | {d['shrank']} | " + f"{_trip(d['dsize'], '+.0f')} | {_trip(d['dscore'], '+,.0f')} |" + ) + + rows, ups = _inflation_data(records, presets) + lines.append("\n## Swaps blocked by the no-bigger-at-lower-score rule\n") + lines.append( + "| preset | total blocked | same-res (correctly blocked) | RES-UPGRADE (maybe allow) |" + ) + lines.append("| --- | ---: | ---: | ---: |") + for d in rows: + lines.append(f"| {d['preset']} | {d['total']} | {d['same']} | {d['up']} |") + if ups: + lines.append("\n### Resolution-upgrade cases the rule blocks (the legitimate ones)\n") + lines.append("| preset | movie | current | blocked candidate |") + lines.append("| --- | --- | --- | --- |") + for p, rec, bi in ups[:40]: + lines.append( + f"| {p} | {rec['title']} | {_fmt_cur(rec['cur'])} | " + f"{bi['score']:,} / {bi['res']}p / {bi['size_gb']:.1f}GB {bi['title']} |" + ) + out.write_text("\n".join(lines) + "\n") + + +# ----- interactive HTML ----- + +_HTML_HEAD = """ + +Optimizarr diagnostics +

Optimizarr diagnostics

+
{n} movies · generated {ts}
+
{tabbtns}
+""" + +_HTML_TAIL = """""" + + +def _cell(disp, sort=None, cls="") -> str: + klass = f' class="{cls}"' if cls else "" + ds = f' data-sort="{html.escape(str(sort))}"' if sort is not None else "" + return f"{html.escape(str(disp))}" + + +def _tbl(tid: str, cols: list[tuple[str, bool]], rows: list[list[str]]) -> str: + head = "".join(f'{html.escape(c)}' for c, num in cols) + body = "".join("" + "".join(r) + "" for r in rows) + return ( + f'' + f'{head}{body}
' + ) + + +def _write_html(records, presets, out: Path) -> None: + initials = {p: p[0] for p in presets} + + # --- Picks tab --- + pcols = [ + ("Movie", False), + ("Profile", False), + ("Cur score", True), + ("Cur res", True), + ("Cur GB", True), + ("Cur GiB/h", True), + ] + [(p, True) for p in presets] + prows = [] + for rec in records: + cf = rec["cur"] or {} + cells = [ + _cell(rec["title"], cls="txt"), + _cell(rec["profile"]), + _cell(f"{cf.get('score'):,}" if cf.get("score") is not None else "-", cf.get("score")), + _cell(f"{cf.get('resolution') or '?'}p", cf.get("resolution") or 0, "num"), + _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), + _cell(f"{cf.get('gbh', 0):.2f}", cf.get("gbh") or 0, "num"), + ] + for p in presets: + info = rec["picks"][p] + if info["action"] == "ACT" and "pick" in info: + pk = info["pick"] + cells.append( + _cell( + f"{pk.get('score', 0):,} / {pk.get('size_gb', 0):.1f}GB", + pk.get("score") or 0, + "num", + ) + ) + else: + blk = "blocked_inflation" in info + cells.append( + _cell("HOLD*" if blk else "HOLD", -1, "num holdblk" if blk else "num hold") + ) + prows.append(cells) + picks_html = _tbl("picks-tbl", pcols, prows) + + # --- Releases tab (flat, score >= 0) --- + rcols = [ + ("Movie", False), + ("Profile", False), + ("Cur GB", True), + ("Cur score", True), + ("Rel score", True), + ("Res", True), + ("GiB/h", True), + ("Rel GB", True), + ("Rej", False), + ("Picked by", False), + ("Title", False), + ] + rrows = [] + for rec in records: + cf = rec["cur"] or {} + for x in rec["releases"]: + if (x.get("score") or -1) < 0: + continue + pby = "".join( + initials[p] + for p in presets + if rec["picks"][p].get("pick", {}).get("title") == x.get("title") + ) + rrows.append( + [ + _cell(rec["title"], cls="txt"), + _cell(rec["profile"]), + _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), + _cell(f"{cf.get('score') or 0:,}", cf.get("score") or 0, "num"), + _cell(f"{x.get('score', 0):,}", x.get("score") or 0, "num"), + _cell(f"{x.get('resolution') or '?'}p", x.get("resolution") or 0, "num"), + _cell(f"{x.get('gbh', 0):.2f}", x.get("gbh") or 0, "num"), + _cell(f"{x.get('size_gb', 0):.1f}", x.get("size_gb") or 0, "num"), + _cell("Y" if (x.get("rejections") or x.get("temporarily_rejected")) else ""), + _cell(pby), + _cell(x.get("title"), cls="txt"), + ] + ) + releases_html = _tbl("rel-tbl", rcols, rrows) + + # --- Stats tab --- + sh = _shift_data(records) + cur = sh["cur"] or 1 + net_on, net_off = sh["on"] - sh["cur"], sh["off"] - sh["cur"] + cards = ( + f"
" + f"
current library
{_gb(sh['cur'])}
" + f'
guard ON
{_gb(net_on)}
' + f"{100 * net_on / cur:+.1f}%
" + f'
guard OFF
{_gb(net_off)}
' + f"{100 * net_off / cur:+.1f}%
" + f"
cost of dropping rule" + f'
+{_gb(sh["off"] - sh["on"])}
' + f"{sh['movies']} movies · {sh['pts']:,} pts regression
" + ) + shift_tbl = _tbl( + "shift-tbl", + [ + ("inflate guard", False), + ("new total", True), + ("net shift", True), + ("net %", True), + ("grown", True), + ("shrunk", True), + ], + [ + [ + _cell("ON (ship default)"), + _cell(_gb(sh["on"]), sh["on"], "num"), + _cell(_gb(net_on), net_on, "num"), + _cell(f"{100 * net_on / cur:+.1f}%", net_on, "num"), + _cell("+" + _gb(sh["on_g"]), sh["on_g"], "num"), + _cell("-" + _gb(sh["on_s"]), sh["on_s"], "num"), + ], + [ + _cell("OFF (drop the rule)"), + _cell(_gb(sh["off"]), sh["off"], "num"), + _cell(_gb(net_off), net_off, "num"), + _cell(f"{100 * net_off / cur:+.1f}%", net_off, "num"), + _cell("+" + _gb(sh["off_g"]), sh["off_g"], "num"), + _cell("-" + _gb(sh["off_s"]), sh["off_s"], "num"), + ], + ], + ) + ex = "".join( + f"
  • −{d:,} pts, +{g:.1f} GB — {html.escape(t)}
  • " for d, g, t in sh["examples"][:25] + ) + pp = _tbl( + "pp-tbl", + [ + ("preset", False), + ("ACT", True), + ("HOLD", True), + ("grew", True), + ("shrank", True), + ("Δsize% p50", True), + ("Δscore p50", True), + ], + [ + [ + _cell(d["preset"]), + _cell(d["act"], d["act"], "num"), + _cell(d["hold"], d["hold"], "num"), + _cell(d["grew"], d["grew"], "num"), + _cell(d["shrank"], d["shrank"], "num"), + _cell(_trip(d["dsize"], "+.0f"), _pct(d["dsize"], 50) if d["dsize"] else 0, "num"), + _cell( + _trip(d["dscore"], "+,.0f"), _pct(d["dscore"], 50) if d["dscore"] else 0, "num" + ), + ] + for d in _preset_data(records, presets) + ], + ) + inf_rows, ups = _inflation_data(records, presets) + inf = _tbl( + "inf-tbl", + [("preset", False), ("total blocked", True), ("same-res", True), ("RES-UPGRADE", True)], + [ + [ + _cell(d["preset"]), + _cell(d["total"], d["total"], "num"), + _cell(d["same"], d["same"], "num"), + _cell(d["up"], d["up"], "num"), + ] + for d in inf_rows + ], + ) + stats_html = ( + "

    Library size shift (every movie realigned to its own profile)

    " + + cards + + shift_tbl + + ( + "

    Biggest score regressions the rule prevents

      " + ex + "
    " + if ex + else "" + ) + + "

    Per-preset picks (vs current file)

    " + + pp + + "

    Swaps blocked by the no-bigger-at-lower-score rule

    " + + inf + ) + + tabs = [ + ("picks", "Picks", picks_html), + ("releases", "Releases", releases_html), + ("stats", "Stats", stats_html), + ] + tabbtns = "".join(f'' for tid, label, _ in tabs) + head = _HTML_HEAD.format( + n=len(records), ts=datetime.now().strftime("%Y-%m-%d %H:%M"), tabbtns=tabbtns + ) + body = "".join( + f'' for tid, _, content in tabs + ) + out.write_text(head + body + _HTML_TAIL) + + +if __name__ == "__main__": + main() diff --git a/tools/score_curve_fit.py b/tools/score_curve_fit.py new file mode 100644 index 0000000..436a0df --- /dev/null +++ b/tools/score_curve_fit.py @@ -0,0 +1,203 @@ +"""Explore logistic score-curve fits on different candidate populations (read-only, offline). + +The shipped `score_center` / `score_width` were fit to the pooled distribution of every release +with score >= 0. That includes a lot of releases the optimizer never actually has to choose +between (they fail the size band, or fall outside the per-item score-gap cluster). This tool +re-fits the logistic on tighter populations so we can see whether the curve should be calibrated +to what `n_score` really ranks: + + all_pos : every release with customFormatScore >= 0 (the current shipped basis). + gapcut : after eligible() + filter_by_score_gap() -- the post gap-cut candidates. + real : after the FULL prefilters (eligible + per-item size band + score gap), using each + item's resolved preset -- the real candidates that get scored and can be picked. + +For each population it fits a logistic to the empirical CDF, then replays decide() over the whole +dataset under that fitted curve and counts score-downgrades, so the practical effect is visible. +It NEVER touches Radarr/Sonarr or writes config; it only reads the training JSONL and writes a +markdown report. + + uv run python tools/score_curve_fit.py --in reports/training_data_radarr.jsonl +""" + +from __future__ import annotations + +import argparse +import json +import math +from dataclasses import replace +from datetime import datetime +from pathlib import Path + +from optimizarr.features.optimizer.config import default_topsis +from optimizarr.features.optimizer.decision import decide +from optimizarr.features.optimizer.topsis import Topsis, eligible + +KEY_SCORES = [600_000, 700_000, 800_000, 860_000, 900_000, 920_000, 949_600, 976_000, 993_000] + + +def _release_dict(r: dict) -> dict: + """Reconstruct the Radarr-shaped release dict decide()/Topsis expect from a training row.""" + return { + "guid": r.get("title"), + "indexerId": 1, + "title": r.get("title"), + "customFormatScore": r.get("score"), + "quality": {"quality": {"resolution": r.get("resolution") or 0}}, + "size": r.get("size_bytes") or 0, + "rejections": r.get("rejections") or [], + "temporarilyRejected": bool(r.get("temporarily_rejected")), + } + + +def _current_dict(cf: dict | None) -> dict | None: + if not cf or cf.get("score") is None: + return None + return { + "id": 1, + "customFormatScore": cf.get("score"), + "size": cf.get("size_bytes") or 0, + "quality": {"quality": {"resolution": cf.get("resolution") or 0}}, + } + + +def populations(rows: list[dict], topsis: Topsis) -> dict[str, list[int]]: + """Pooled candidate scores for each population definition.""" + all_pos: list[int] = [] + gapcut: list[int] = [] + real: list[int] = [] + for r in rows: + rels = [_release_dict(x) for x in r["releases"]] + rt = r["runtime_h"] + all_pos += [x["customFormatScore"] for x in rels if (x["customFormatScore"] or -1) >= 0] + gap = topsis.filter_by_score_gap(eligible(rels)) + gapcut += [x["customFormatScore"] for x in gap] + resolved = topsis.resolve_profile(r["profile"]["name"]) + kept, _ = topsis.apply_prefilters(rels, rt, resolved.reference) + real += [x["customFormatScore"] for x in kept] + return {"all_pos": sorted(all_pos), "gapcut": sorted(gapcut), "real": sorted(real)} + + +def _pct(a: list[int], p: float) -> float: + if not a: + return float("nan") + k = (len(a) - 1) * p + f = int(k) + c = min(f + 1, len(a) - 1) + return a[f] + (a[c] - a[f]) * (k - f) + + +def fit_logistic(scores: list[int]) -> tuple[int, int]: + """Grid least-squares fit of n=1/(1+exp(-(s-c)/w)) to the population's empirical CDF.""" + import bisect + + n = len(scores) + lo, hi = scores[0], scores[-1] + xs = list(range(int(lo), int(hi) + 1, 5000)) or [lo] + emp = [bisect.bisect_right(scores, x) / n for x in xs] + best: tuple[float, int, int] | None = None + for c in range(650_000, 960_001, 2500): + for w in range(20_000, 140_001, 2500): + sse = sum( + (1 / (1 + math.exp(-(x - c) / w)) - e) ** 2 for x, e in zip(xs, emp, strict=True) + ) + if best is None or sse < best[0]: + best = (sse, c, w) + assert best is not None + return best[1], best[2] + + +def logistic(s: float, c: float, w: float) -> float: + return 1 / (1 + math.exp(-(s - c) / w)) + + +def replay_downgrades(rows: list[dict], topsis: Topsis) -> tuple[int, int, int]: + """Replay decide() over the dataset; return (ACT, score-downgrades, downgrades >= 20k).""" + act = dn = big = 0 + for r in rows: + cur = _current_dict(r.get("current_file")) + if cur is None: + continue + d = decide( + topsis, + [_release_dict(x) for x in r["releases"]], + r["runtime_h"], + r["profile"]["name"], + r["profile"]["target_resolution"], + cur, + ) + if d.action != "ACT": + continue + act += 1 + ps, cs = (d.pick or {}).get("score"), cur["customFormatScore"] + if ps is not None and cs is not None and ps < cs: + dn += 1 + if cs - ps >= 20_000: + big += 1 + return act, dn, big + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) + ap.add_argument("--out", type=Path) + args = ap.parse_args() + + rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] + cfg = default_topsis() + topsis = Topsis(cfg) + pops = populations(rows, topsis) + + lines: list[str] = [] + w = lines.append + w("# Score-curve fit per candidate population\n") + w(f"Source: `{args.inp}` ({len(rows)} movies) | generated {datetime.now():%Y-%m-%d %H:%M}\n") + w(f"Shipped default: center={int(cfg.score_center):,} width={int(cfg.score_width):,}\n") + + w("\n## Population sizes and percentiles\n") + w("| population | n | p25 | p50 | p75 | p90 | p95 | max |") + w("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") + for name, sc in pops.items(): + w( + f"| {name} | {len(sc):,} | " + + " | ".join(f"{_pct(sc, p):,.0f}" for p in (0.25, 0.5, 0.75, 0.90, 0.95, 1.0)) + + " |" + ) + + fits = {name: fit_logistic(sc) for name, sc in pops.items()} + w("\n## Fitted logistic per population\n") + w("| population | center | width |") + w("| --- | ---: | ---: |") + for name, (c, wd) in fits.items(): + w(f"| {name} | {c:,} | {wd:,} |") + + w("\n## n_score at key scores (each fit + shipped default)\n") + header = "| score | " + " | ".join(fits) + " | shipped |" + w(header) + w("| ---: " + "| ---: " * (len(fits) + 1) + "|") + for s in KEY_SCORES: + row = f"| {s:,} | " + row += " | ".join(f"{logistic(s, c, wd):.3f}" for c, wd in fits.values()) + row += f" | {logistic(s, cfg.score_center, cfg.score_width):.3f} |" + w(row) + + w("\n## Replay impact (decide() over the whole dataset)\n") + w("| curve | center | width | ACT | score-downgrades | >=20k |") + w("| --- | ---: | ---: | ---: | ---: | ---: |") + variants = {**fits, "shipped": (int(cfg.score_center), int(cfg.score_width))} + for name, (c, wd) in variants.items(): + t = Topsis(replace(cfg, score_center=float(c), score_width=float(wd))) + act, dn, big = replay_downgrades(rows, t) + w(f"| {name} | {c:,} | {wd:,} | {act} | {dn} | {big} |") + tl = Topsis(replace(cfg, score_norm="linear")) + act, dn, big = replay_downgrades(rows, tl) + w(f"| linear[0,1M] | - | - | {act} | {dn} | {big} |") + + out = args.out or Path("reports") / f"score_curve_fit_{datetime.now():%Y%m%d_%H%M%S}.md" + out.parent.mkdir(exist_ok=True) + out.write_text("\n".join(lines) + "\n") + print(f"wrote {out}") + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/tools/size_band_lab.py b/tools/size_band_lab.py new file mode 100644 index 0000000..cac8f64 --- /dev/null +++ b/tools/size_band_lab.py @@ -0,0 +1,339 @@ +"""Phase-1 experiment for the size-model redesign (read-only, offline). NO engine change. + +Compares two candidate size models, both 2-axis TOPSIS (logistic n_score from the shipped config + +a 5-point trapezoid n_size, resolution dropped to a guard) over the harvested 250-movie set: + + A. absolute bands + outlier prefilter - per-resolution {floor, lo, target, hi, ceiling} GiB/h + placed from the GLOBAL size percentiles, same for every movie; plus a relative outlier drop. + B. relative percentile - per movie, the band is placed from THAT movie's own candidate size + distribution, so it scales with encode difficulty. + +For each model it reports, per profile: which release each profile would pick, whether the five +profiles diverge, whether the pick is the top-score release inside the band (score is driving), +whether any pick is a lone-small outlier, where picks land in the size distribution, and a +varying-candidate-set oscillation count (re-grabs when the visible candidate set is resampled). + + uv run python tools/size_band_lab.py --in reports/training_data_radarr.jsonl +""" + +from __future__ import annotations + +import argparse +import json +import math +import random +import statistics +from collections import Counter +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from optimizarr.features.optimizer.config import default_topsis +from optimizarr.features.optimizer.topsis import HARD_REJECT_KEYWORDS, Topsis + +# profile -> (score_weight, size_weight, anchor percentile of the size distribution). +# Weights are the shipped 3-axis weights with resolution dropped and re-summed to 1.0. +PROFILES: dict[str, tuple[float, float, float]] = { + "Remux": (0.94, 0.06, 92), + "Quality": (0.86, 0.14, 77), + "Balanced": (0.56, 0.44, 50), + "Efficient": (0.44, 0.56, 30), + "Compact": (0.22, 0.78, 10), +} +HALF_WIDTH_PCT = 15.0 # band half-width in percentile points around the anchor +OUTLIER_FRAC = 0.5 # drop a release whose gbh < OUTLIER_FRAC * median(cluster gbh) +SHOULDER = 0.75 # n_size at lo/hi + + +@dataclass +class Cand: + score: int + gbh: float + res: int + size_gb: float + title: str + + +def _eligible(rels: list[dict]) -> list[Cand]: + """Hard-rejection + non-negative-score filter, mapped to simple Cand records.""" + out: list[Cand] = [] + for r in rels: + if r.get("temporarily_rejected"): + continue + if any( + any(k in reason for k in HARD_REJECT_KEYWORDS) for reason in (r.get("rejections") or []) + ): + continue + s = r.get("score") + if s is None or s < 0: + continue + out.append( + Cand( + s, + float(r.get("gbh") or 0.0), + int(r.get("resolution") or 0), + float(r.get("size_gb") or 0.0), + r.get("title") or "?", + ) + ) + return out + + +def _gap_cut(cands: list[Cand], gap: float) -> list[Cand]: + """Keep the top score cluster down to the first relative drop > gap.""" + srt = sorted(cands, key=lambda c: -c.score) + if not srt: + return [] + kept = [srt[0]] + for prev, cur in zip(srt, srt[1:], strict=False): + if prev.score > 0 and (prev.score - cur.score) / prev.score > gap: + break + kept.append(cur) + return kept + + +def _pct(sorted_vals: list[float], p: float) -> float: + if not sorted_vals: + return 0.0 + k = (len(sorted_vals) - 1) * (p / 100.0) + f = int(k) + c = min(f + 1, len(sorted_vals) - 1) + return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) + + +def _rank_pct(sorted_vals: list[float], x: float) -> float: + """Percentile position of x within sorted_vals (0..100).""" + if not sorted_vals: + return 0.0 + below = sum(1 for v in sorted_vals if v < x) + return 100.0 * below / len(sorted_vals) + + +def trapezoid( + gbh: float, floor: float, lo: float, target: float, hi: float, ceiling: float +) -> float: + """5-point trapezoid: 0 outside [floor, ceiling], SHOULDER at lo/hi, 1.0 at target.""" + if gbh <= floor or gbh >= ceiling: + return 0.0 + if gbh < lo: + return SHOULDER * (gbh - floor) / (lo - floor) if lo > floor else SHOULDER + if gbh <= target: + return SHOULDER + (1 - SHOULDER) * (gbh - lo) / (target - lo) if target > lo else 1.0 + if gbh <= hi: + return 1.0 - (1 - SHOULDER) * (gbh - target) / (hi - target) if hi > target else 1.0 + return SHOULDER * (ceiling - gbh) / (ceiling - hi) if ceiling > hi else SHOULDER + + +def closeness(n_score: float, n_size: float, w_score: float, w_size: float) -> float: + d_ideal = math.sqrt(w_score * (1 - n_score) ** 2 + w_size * (1 - n_size) ** 2) + d_anti = math.sqrt(w_score * n_score**2 + w_size * n_size**2) + tot = d_ideal + d_anti + return 0.0 if tot == 0 else d_anti / tot + + +def band_from_pcts( + vals_sorted: list[float], anchor: float +) -> tuple[float, float, float, float, float]: + """5 band points (gbh) for an anchor percentile over a sorted gbh population.""" + floor = _pct(vals_sorted, 2) + ceiling = _pct(vals_sorted, 98) + lo = _pct(vals_sorted, max(2, anchor - HALF_WIDTH_PCT)) + target = _pct(vals_sorted, anchor) + hi = _pct(vals_sorted, min(98, anchor + HALF_WIDTH_PCT)) + # keep strictly increasing for the trapezoid + lo = max(lo, floor + 1e-6) + hi = max(hi, target + 1e-6) + ceiling = max(ceiling, hi + 1e-6) + return floor, lo, target, hi, ceiling + + +def drop_outliers(cands: list[Cand]) -> list[Cand]: + if len(cands) < 3: + return cands + med = statistics.median(c.gbh for c in cands) + return [c for c in cands if c.gbh >= OUTLIER_FRAC * med] + + +class Model: + """A size model: produces band points for a candidate set + profile anchor.""" + + def __init__(self, name: str, global_pcts: dict[int, list[float]]): + self.name = name + self.global_pcts = global_pcts # res -> sorted gbh list (for absolute model) + + def _res_pop(self, res: int) -> list[float]: + if res in self.global_pcts: + return self.global_pcts[res] + # nearest defined resolution at/below, else any + below = [r for r in sorted(self.global_pcts) if r <= res] + key = below[-1] if below else sorted(self.global_pcts)[0] + return self.global_pcts[key] + + def band(self, cands: list[Cand], res: int, anchor: float) -> tuple[float, ...]: + if self.name == "absolute": + return band_from_pcts(self._res_pop(res), anchor) + pop = sorted(c.gbh for c in cands) or [0.0] + return band_from_pcts(pop, anchor) + + +def pick( + topsis: Topsis, + model: Model, + cands: list[Cand], + res: int, + anchor: float, + w_score: float, + w_size: float, +) -> Cand | None: + if not cands: + return None + floor, lo, target, hi, ceiling = model.band(cands, res, anchor) + best, best_clo = None, -1.0 + for c in cands: + ns = topsis.normalize_score(c.score) + nz = trapezoid(c.gbh, floor, lo, target, hi, ceiling) + clo = closeness(ns, nz, w_score, w_size) + if clo > best_clo: + best, best_clo = c, clo + return best + + +def in_band_top_score(cands: list[Cand], band: tuple[float, ...]) -> Cand | None: + _floor, lo, _target, hi, _ceiling = band + inb = [c for c in cands if lo <= c.gbh <= hi] + return max(inb, key=lambda c: c.score) if inb else None + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) + ap.add_argument("--out", type=Path) + ap.add_argument("--seed", type=int, default=0) + args = ap.parse_args() + + rng = random.Random(args.seed) + rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] + cfg = default_topsis() + topsis = Topsis(cfg) + gap = cfg.score_gap + + # Per-movie candidate set = eligible + gap-cut, restricted to the profile target resolution. + movies: list[tuple[int, list[Cand]]] = [] + res_pop: dict[int, list[float]] = {} + for r in rows: + tr = r["profile"]["target_resolution"] or 0 + cands = _gap_cut(_eligible(r["releases"]), gap) + cands = [c for c in cands if c.res == tr] or cands # fall back to all if none at tr + cands = drop_outliers(cands) + if len(cands) < 2: + continue + movies.append((tr, cands)) + for c in cands: + res_pop.setdefault(c.res, []).append(c.gbh) + for k in res_pop: + res_pop[k].sort() + + models = {"absolute": Model("absolute", res_pop), "relative": Model("relative", res_pop)} + + lines: list[str] = [] + w = lines.append + w("# Size-model experiment: absolute bands vs relative percentile\n") + stamp = datetime.now().strftime("%Y-%m-%d %H:%M") + w(f"Source `{args.inp}` ({len(rows)} movies, {len(movies)} usable) | {stamp}\n") + w(f"shoulder={SHOULDER}, half-width={HALF_WIDTH_PCT}pct, outlier_frac={OUTLIER_FRAC}\n") + + w("\n## Per-resolution candidate GiB/h percentiles (absolute bands placed from these)\n") + w("| res | n | P5 | P10 | P25 | P50 | P75 | P90 | P95 |") + w("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") + for res in sorted(res_pop, reverse=True): + v = res_pop[res] + w( + f"| {res} | {len(v)} | " + + " | ".join(f"{_pct(v, p):.1f}" for p in (5, 10, 25, 50, 75, 90, 95)) + + " |" + ) + + for mname, model in models.items(): + # picks[profile] = list of (movie_idx, pick, cands, band) + diverge = Counter() # distinct-pick-count per movie + score_driving = Counter() + outlier_hits = 0 + land_pct: dict[str, list[float]] = {p: [] for p in PROFILES} + for tr, cands in movies: + picks_this = {} + for prof, (ws, wz, anchor) in PROFILES.items(): + p = pick(topsis, model, cands, tr, anchor, ws, wz) + if p is None: + continue + picks_this[prof] = p + band = model.band(cands, tr, anchor) + top = in_band_top_score(cands, band) + if top is not None and p.title == top.title: + score_driving[prof] += 1 + pop = sorted(c.gbh for c in cands) + land_pct[prof].append(_rank_pct(pop, p.gbh)) + med = statistics.median(c.gbh for c in cands) + if p.gbh < OUTLIER_FRAC * med: + outlier_hits += 1 + diverge[len({p.title for p in picks_this.values()})] += 1 + + w(f"\n## Model: {mname}\n") + w("**Divergence** (distinct releases picked by the 5 profiles, per movie):") + w("") + w("| distinct picks | movies |") + w("| ---: | ---: |") + for k in sorted(diverge): + w(f"| {k} | {diverge[k]} |") + w(f"\n**Outlier picks** (pick gbh < {OUTLIER_FRAC}x median): {outlier_hits} (target: 0)\n") + w("\n**Score-driving** (pick == top-score release in band) + **landing pct**:\n") + w("| profile | anchor pct | score-driving | mean landing pct |") + w("| --- | ---: | ---: | ---: |") + for prof, (_ws, _wz, anchor) in PROFILES.items(): + n = len(land_pct[prof]) + sd = f"{100 * score_driving[prof] / n:.0f}%" if n else "-" + ml = f"{statistics.mean(land_pct[prof]):.0f}" if land_pct[prof] else "-" + w(f"| {prof} | {anchor:.0f} | {sd} | {ml} |") + + # Oscillation: resample candidate set, count re-grabs. + regrabs = grabs = 0 + sample = movies if len(movies) <= 80 else rng.sample(movies, 80) + for tr, cands in sample: + if len(cands) < 4: + continue + for _prof, (ws, wz, anchor) in PROFILES.items(): + cur = rng.choice(cands) + left: set[str] = set() + for _ in range(40): + subset = [c for c in cands if rng.random() < 0.75] or cands + if cur.title not in {c.title for c in subset}: + subset = subset + [cur] + p = pick(topsis, model, subset, tr, anchor, ws, wz) + if p is None or p.title == cur.title: + continue + # swap only on a real closeness gain under the CURRENT subset + band = model.band(subset, tr, anchor) + nz_cur = trapezoid(cur.gbh, *band) + nz_p = trapezoid(p.gbh, *band) + clo_cur = closeness(topsis.normalize_score(cur.score), nz_cur, ws, wz) + clo_p = closeness(topsis.normalize_score(p.score), nz_p, ws, wz) + if clo_p < clo_cur + cfg.default_min_closeness_gain: + continue + grabs += 1 + if p.title in left: + regrabs += 1 + left.add(cur.title) + cur = p + w( + f"\n**Oscillation** (resampled-set walk, {len(sample)} movies x 5 profiles): " + f"{grabs} grabs, **{regrabs} re-grabs** (target: 0)\n" + ) + + out = args.out or Path("reports") / f"size_band_lab_{datetime.now():%Y%m%d_%H%M%S}.md" + out.parent.mkdir(exist_ok=True) + out.write_text("\n".join(lines) + "\n") + print(f"wrote {out}") + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/tools/weight_lab.py b/tools/weight_lab.py index 95fc9eb..350f71a 100644 --- a/tools/weight_lab.py +++ b/tools/weight_lab.py @@ -1,7 +1,7 @@ """Weight lab: visualize how the presets score and pick releases under the new model. -Drives the real engine: each preset's weights + absolute size table {floor, target, bloat} + pick, -and the closeness-gain swap rule (via the real decide()). Two modes: +Drives the real engine: each preset's weights + 5-point size table {floor, lo, target, hi, +ceiling} + pick, and the closeness-gain swap rule (via the real decide()). Two modes: - default (synthetic): curated scenarios + a retention stress test, showing every candidate's closeness under every preset (★ = the preset's pick; "drop" = excluded by that preset's size @@ -363,25 +363,26 @@ def main() -> None: "", f"Generated {datetime.now().isoformat(timespec='seconds')}", "", - "Each preset carries its own absolute size table {floor, target, bloat} GiB/h, weights,", - "and a pick method. A candidate is grabbed only if it raises closeness by at least", - "min_closeness_gain (and does not drop resolution below target). ★ = the preset's pick;", - "'drop' = excluded by that preset's size band (floor..bloat) or the score gap-cut.", + "Each preset carries its own 5-point size table {floor, lo, target, hi, ceiling} GiB/h,", + "weights (score + size), and a pick method. A candidate is grabbed only if it raises", + "closeness by at least min_closeness_gain (and does not drop resolution below target, and", + "is not bigger at a lower-or-equal score). ★ = the preset's pick; 'drop' = excluded by", + "that preset's size band (floor..ceiling) or the score gap-cut.", "", "## Presets", "", - "| preset | score | res | size | pick | gain | 2160 (floor/target/bloat) | 1080 | 720 |", - "|---|---|---|---|---|---|---|---|---|", + "| preset | score | size | pick | gain | 2160 (floor/lo/target/hi/ceiling) | 1080 | 720 |", + "|---|---|---|---|---|---|---|---|", ] def _band(p, res: int) -> str: - f, tgt, b = p.reference.get(res, ("-", "-", "-")) - return f"{f:g}/{tgt:g}/{b:g}" if f != "-" else "-" + pts = p.reference.get(res) + return "/".join(f"{v:g}" for v in pts) if pts else "-" for name, p in t.cfg.presets.items(): w = p.weights md.append( - f"| {name} | {w['score']:.2f} | {w['resolution']:.2f} | {w['size']:.2f} | " + f"| {name} | {w['score']:.2f} | {w['size']:.2f} | " f"{p.pick} | {p.min_closeness_gain:g} | {_band(p, 2160)} | {_band(p, 1080)} | " f"{_band(p, 720)} |" ) From 12555d7ae3212f8e66d56e7dc9c16680c2fdf8fc Mon Sep 17 00:00:00 2001 From: Patrick Veilleux Date: Fri, 5 Jun 2026 23:30:12 -0400 Subject: [PATCH 2/3] Simplify the algorithm. Clean up the tooling. --- ALGORITHM.md | 366 +++----- README.md | 12 +- optimizarr/config.py | 3 +- optimizarr/defaults.toml | 159 ++-- optimizarr/features/optimizer/config.py | 219 ++--- optimizarr/features/optimizer/decision.py | 157 ++-- optimizarr/features/optimizer/state.py | 49 +- optimizarr/features/optimizer/topsis.py | 398 ++++----- optimizarr/features/optimizer/worker.py | 54 +- optimizarr/features/unmonitor/config.py | 24 +- test-results/20260601-pool-pop-order-fix.md | 31 - .../20260602-logistic-score-normalization.md | 66 -- .../20260602-quality-preset-retune.md | 41 - .../20260602-score-curve-refit-250movies.md | 65 -- .../20260603-2axis-trapezoid-size-model.md | 52 -- tests/test_config.py | 61 +- tests/test_decision.py | 183 ++-- tests/test_state.py | 27 +- tests/test_swap.py | 160 ++-- tests/test_topsis.py | 223 +++-- tests/test_worker.py | 37 +- tools/cutoff_viz.py | 360 -------- tools/diagnose.py | 825 +++++++----------- tools/score_curve_fit.py | 203 ----- tools/size_band_lab.py | 339 ------- tools/size_cohorts.py | 181 ---- tools/training_stats.py | 244 ------ tools/weight_lab.py | 414 --------- 28 files changed, 1163 insertions(+), 3790 deletions(-) delete mode 100644 test-results/20260601-pool-pop-order-fix.md delete mode 100644 test-results/20260602-logistic-score-normalization.md delete mode 100644 test-results/20260602-quality-preset-retune.md delete mode 100644 test-results/20260602-score-curve-refit-250movies.md delete mode 100644 test-results/20260603-2axis-trapezoid-size-model.md delete mode 100644 tools/cutoff_viz.py delete mode 100644 tools/score_curve_fit.py delete mode 100644 tools/size_band_lab.py delete mode 100644 tools/size_cohorts.py delete mode 100644 tools/training_stats.py delete mode 100644 tools/weight_lab.py diff --git a/ALGORITHM.md b/ALGORITHM.md index f85faea..1983bea 100644 --- a/ALGORITHM.md +++ b/ALGORITHM.md @@ -1,298 +1,196 @@ # How Optimizarr decides -This is the in-depth companion to the [README](README.md): the selection algorithm, the guard -rails, the TOPSIS math, the configuration model, and the worker loop. - -The optimizer evaluates the releases available for one library item, decides whether a better -one exists, and grabs it through Radarr/Sonarr. It is built around the reality that **grabbed -releases frequently fail to download**, so *"optimized"* means *the algorithm can no longer find -anything better than the current file*, never merely *"we triggered a grab."* - -There are two big ideas: - -- **Per-preset 5-point size bands.** Each preset carries its own - `{floor, lo, target, hi, ceiling}` trapezoid per resolution (GiB/h). `floor` and `ceiling` are - hard gates (out-of-band releases are dropped before scoring); `lo … hi` is the "good size" band - where score drives the pick; `target` is the peak, placed at a per-profile size percentile, so - each profile aims at a different size and the five profiles pick different releases. -- **A single closeness-gain swap rule.** Releases are scored by TOPSIS *closeness* over two axes - (score, size; resolution is a hard guard, not an axis), and a candidate is grabbed only if it - raises closeness past a small margin, doesn't drop resolution below target, and isn't bigger at a - lower-or-equal score (a larger file is grabbed only on a real score upgrade). Because closeness is - file-independent and strictly increases on every swap, the optimizer **cannot oscillate**. +This is the in-depth companion to the [README](README.md): the selection algorithm, the +configuration model, and the worker loop. + +The optimizer evaluates the releases available for one library item, decides whether a better one +exists, and grabs it through Radarr/Sonarr. It is built around the reality that **grabbed releases +frequently fail to download**, so *"optimized"* means *the algorithm can no longer find anything +better than the current file*, never merely *"we triggered a grab."* + +Three ideas carry the whole design: + +- **Two relative axes.** Each candidate is scored on just two axes — Profilarr **score** (higher + better) and **GiB/h** (smaller better) — each min-max normalized *over the candidates the + indexers actually returned for this movie*. There are no absolute size bands, target sizes, or + shaped curves; "good size" is defined relative to what is on offer, which is what lets one + profile shrink a movie that another leaves alone. +- **The forbidden quadrant.** Versus the current file a pick may be better+bigger (a real score + upgrade), better+smaller (ideal), or a little worse+smaller (a deliberate size trade). It may + **never** be worse *and* bigger. That falls out for free: a candidate worse on both axes has a + lower TOPSIS closeness than the current file, and the optimizer only ACTs when a candidate beats + the current file. +- **One-and-done.** A movie is optimized once: when its current (imported) file is the best pick + for its profile it is marked *satisfied* and never re-evaluated. This is what makes relative + scoring safe — there is no re-evaluation loop to oscillate — and it removes the need for any + closeness-margin oscillation proof. --- -## 1. The size model: per-preset tables, 5-point trapezoid - -Each preset defines its own `[optimizer.topsis.presets..reference]` table: one -`{floor, lo, target, hi, ceiling}` entry per resolution, in GiB/h (`GB = 1024³`, so GiB). The -shipped 2160p and 1080p bands (floor / lo / target / hi / ceiling); 720p and 480p follow the same -shape, scaled down: - -| preset | 2160p | 1080p | -| --- | --- | --- | -| Remux | 15 / 28 / 38 / 60 / 90 | 8 / 15 / 20 / 32 / 48 | -| Quality | 6.5 / 10.7 / 11.5 / 13.5 / 30 | 3 / 5 / 6.3 / 8.7 / 16 | -| Balanced | 5 / 8.1 / 9.3 / 11 / 20 | 2 / 3.8 / 4.5 / 5.3 / 10 | -| Efficient | 4 / 7.1 / 7.8 / 8.8 / 15 | 1.5 / 2.8 / 3.5 / 4.3 / 7.5 | -| Compact | 3.5 / 5.9 / 6.8 / 7.6 / 12 | 1.2 / 1.9 / 2.6 / 3.2 / 6 | - -- **floor / ceiling**: legitimacy bounds. Outside `[floor, ceiling]` a release is dropped before - scoring (too soft / fake below floor, bloated above ceiling), and `n_size = 0` there. -- **lo … hi**: the "good size" band. `n_size = size_shoulder` (default 0.85) at lo and hi, so - inside the band size barely moves the decision and **score drives** the pick. -- **target**: the peak (`n_size = 1.0`), placed at a per-profile percentile of real release sizes - (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, Remux at remux bitrates). Slide - `target` within the band to nudge a profile lower or higher. - -The size-desirability curve is a **trapezoid** (a flat-topped tent): - -``` -n_size(gbh) = 0 if gbh ≤ floor or gbh ≥ ceiling - = shoulder·(gbh−floor)/(lo−floor) if floor < gbh < lo (rise to the band) - = shoulder + (1−shoulder)·(gbh−lo)/(target−lo) if lo ≤ gbh ≤ target - = 1 − (1−shoulder)·(gbh−target)/(hi−target) if target < gbh ≤ hi - = shoulder·(ceiling−gbh)/(ceiling−hi) if hi < gbh < ceiling (fall past the band) -``` - -Each profile aims at a **different** band, so the five profiles pick different releases for the -same movie. Unlike the old one-sided curve, a too-small file is penalized (it sits on the rising -shoulder, not a plateau) — but it is never actually *grown* unless that is a genuine score upgrade -(§3). A lone tiny release among bigger same-score peers (usually a bad encode) is dropped by an -outlier prefilter: anything below `outlier_frac` (default 0.5) × the median GiB/h of the -gap-cut cluster. "A good encode has corroborating peers." +## 1. Inclusion filters + +Before scoring, candidates pass through filters that drop the obviously-bad. They are identical for +every profile (`topsis.py::apply_prefilters`): + +1. **Hard rejections** — blocklisted, unparseable, wrong item, dead torrents, temporarily-rejected. +2. **Score window** — three-tier, anchored at the top of what the indexers offer (favors quality, + only widens when the indexer is sparse). Negatives are always dropped (all floors are ≥ 0). + - *Tier 1* `score >= max_available − score_window`. If ≥ `min_candidates` survive: accepted. + - *Tier 2* `score >= current_file_score`. Expands to include releases at or above what you + already have. Tried when Tier 1 yields too few. + - *Tier 3* `score >= max(0, current_file_score − score_window)`. The full downgrade budget: + allows trading some score for a meaningfully smaller file. Reached only as a last resort. +3. **Resolution** — keep only the profile's **target resolution**, so the GiB/h axis is comparable + (a 1080p and a 2160p release are not). This also enforces "never drop below target". +4. **Legitimacy band** — drop anything outside the shared per-resolution `[floor, ceiling]` GiB/h + bounds (fake/upscale below floor, bloat above ceiling). These are wide (≈ P1 / P99 of real + sizes); the relative scoring, not a hard band, expresses taste. +5. **Outlier drop** *(`outlier_frac` = 0.5)* — drop a release whose GiB/h is below + `outlier_frac × median(survivors)`. This is the key guard against over-compressed / upscale + "2160p" junk that Profilarr still scores high on format tags (a real 2160p HEVC is ~4-8 GiB/h; + junk is < 2). Without it, ~half of picks were such tiny files. The absolute floor is too low to + catch them; this relative check is what does. + +If fewer than `min_candidates` (default 6) survive all filters, the relative min-max is not +trustworthy, so the decision is **HOLD without satisfying** — the movie is retried later when more +releases appear. `min_candidates` also controls when the score-window tier is accepted vs expanded. --- -## 2. Scoring: TOPSIS over two axes +## 2. Relative scoring -Resolution is **not** a scored axis: Profilarr already folds it into the score, and a hard guard -forbids dropping below the profile target (§3). So each surviving release has two normalized -attributes: +Over the survivors, each axis is min-max normalized (the current file is then placed on the same +ranges, clamped, so it is comparable): ``` -n_score = 1 / (1 + exp(-(score − score_center) / score_width)) # logistic (default) -n_size = trapezoid above (the preset's floor/lo/target/hi/ceiling for this resolution) +n_score = (score − min_score) / (max_score − min_score) # higher score -> 1 +n_size = (max_gbh − gbh) / (max_gbh − min_gbh) # smaller file -> 1 ``` -Score normalizes through a **fixed** transform (a function of the release alone, so closeness -stays comparable across items and the no-oscillation guarantee in §3 holds). The default is a -**logistic** S-curve: Profilarr scores bunch tightly near the top (real releases top out around -~950k and cluster there) then fall off fast, so a logistic concentrates the `[0,1]` range where -releases actually compete. A linear ramp over `[0, 1,000,000]` wastes ~80% of the axis on scores -no release reaches, which lets a small real score difference at the top get drowned out by the -size axis; the logistic fixes that and, having no hard floor, still separates a library whose -scores all sit near one value. `score_center` (n_score = 0.5) and `score_width` (slope) are set -in `[optimizer.topsis]`; `score_norm = "linear"` restores the old fixed ramp. - -A profile's **weights** (score + size, summing to 1.0) combine the axes into a TOPSIS *closeness*, -the distance to the ideal point `(1,1)` vs the anti-ideal `(0,0)`: +A degenerate range (all equal) yields 1.0 for that axis (it does not discriminate). A profile's +**weights** (score + size, summing to 1.0) combine the two into a TOPSIS *closeness* — distance to +the ideal point `(1, 1)` vs the anti-ideal `(0, 0)`: ``` -d_ideal = √( Σ wₖ·(1 − aₖ)² ) -d_anti = √( Σ wₖ·aₖ² ) +d_ideal = √( w_score·(1−n_score)² + w_size·(1−n_size)² ) +d_anti = √( w_score·n_score² + w_size·n_size² ) closeness = d_anti / (d_ideal + d_anti) # 1 = ideal, 0 = anti-ideal ``` -Shipped weights and pick method: +The weights are the **only** per-profile knob. The shipped profiles: -| Profile | score | size | pick method | +| Profile | score | size | leans | | --- | --- | --- | --- | -| Remux | 0.94 | 0.06 | `topsis` | -| Quality | 0.86 | 0.14 | `topsis` | -| Balanced | 0.56 | 0.44 | `topsis` | -| Efficient | 0.44 | 0.56 | `topsis` | -| Compact | 0.22 | 0.78 | `topsis` | - -All presets pick by `topsis` (highest closeness); the profiles diverge because their **bands** sit -at different sizes, not because of the pick method. `max_score` and `min_size` remain available for -profile overrides. - ---- - -## 3. The decision pipeline - -```mermaid -flowchart TD - A["Item selected"] --> RP["Resolve profile → preset
    (name-keyword match or override)"] - RP --> B["GET /api/v3/release"] - B --> C["Pre-filter 1: drop hard rejections
    blocklisted · unparseable · wrong item · dead"] - C --> D["Pre-filter 2: drop outside the preset's size band
    gbh < floor (fake/upscale) or gbh > ceiling (bloat)"] - D --> E["Pre-filter 3: gap-cut on score
    keep the top cluster; cut at first drop > score_gap"] - E --> O["Pre-filter 4: drop lone-small size outliers
    gbh < outlier_frac × cluster median"] - O --> SC["Score survivors (TOPSIS closeness over score + size)"] - SC --> GATE["SWAP RULE
    raise closeness ≥ min_closeness_gain,
    don't drop resolution below target,
    not bigger at a lower-or-equal score"] - GATE --> LEFT{"any legal candidate?"} - LEFT -->|no| HOLD["HOLD: mark satisfied"] - LEFT -->|yes| PICK["PICK best survivor (topsis: max closeness)"] - PICK --> ACT["ACT: POST grab {guid, indexerId}"] -``` - -Two app-policy pre-filters can run before scoring: `allow_size_increase = false` drops anything -bigger than the current file, and `allow_quality_downgrade = false` drops anything lower-scoring -(turning the latter off neutralizes the size-leaning profiles, which is the point of the flag). +| Remux | 0.94 | 0.06 | best score, size barely matters | +| Quality | 0.86 | 0.14 | best score, mild lean to smaller | +| Balanced | 0.56 | 0.44 | middle | +| Efficient | 0.44 | 0.56 | smaller, decent score | +| Compact | 0.22 | 0.78 | smallest legitimate file | -ACT if at least one candidate passes the swap rule, else HOLD. Closeness decides both *whether* -to act (the gain margin) and, via the pick method, *which* survivor to grab. +Because the axes are relative, the smallest survivor always gets `n_size = 1` regardless of its +absolute bitrate — so Efficient/Compact pick the lean release even when every candidate sits in +what a fixed band would call "too big". Resolution is **not** a scored axis (Profilarr folds it +into the score, and filter 3 already pins it). --- -## 4. The swap rule (the guard rails) - -A scored candidate may replace the current file only if **both** hold: - -1. **Closeness gain.** `closeness(candidate) ≥ closeness(current) + min_closeness_gain`. Closeness - is computed from the release's own attributes and the preset (not from the current file), so it - is a single, stable per-release number. `min_closeness_gain` (default `0.02`, per-preset - overridable) is a hysteresis margin that avoids churning for negligible gains. An unknown - current score is treated as the worst case, so any scored candidate is an improvement. -2. **Resolution guard.** `min(cand_res, target) ≥ min(cur_res, target)`, where `target` is the - profile's target resolution. Resolution may rise toward the target or fall only as far as the - target, never below it. This preserves "drop resolution to match a leaner profile" (a 1080p - profile may move a 2160p file down to 1080p) while stopping a size-leaning preset from shaving - resolution below what the profile asks for. +## 3. The decision -### Why there is no explicit "no senseless swap" rule +`decision.py::decide`: resolve the profile → run the filters → relatively score the survivors → +compare to the current file. -"Never swap to a bigger file for a lower (or equal) score" is **not** a separate rule: at equal -resolution such a candidate is worse on score and no better on size, so its closeness cannot -exceed the current file's, and the gain test already rejects it. The resolution guard is the only -hard constraint closeness does not already imply (a size-leaning preset could otherwise raise -closeness by dropping resolution for size). +- ACT on the best candidate (by the profile's `pick` method, default `topsis` = highest closeness) + **iff** its closeness beats the current file's by `min_closeness_gain` (default `0.02`, a small + hysteresis). Two optional per-app pre-filters run first: `allow_size_increase = false` drops + anything bigger than the current file, `allow_quality_downgrade = false` drops anything + lower-scoring. +- Otherwise **HOLD**. Two kinds: + - **satisfiable** — there were enough candidates and none beat the current file: it is already + optimal for its profile, so mark it satisfied (permanent). + - **insufficient** — fewer than `min_candidates`: do *not* satisfy; retry later. -### No oscillation, for any cycle length - -Closeness is a pure function of a release's attributes and the preset, independent of which file -you currently hold. Every accepted swap raises the held file's closeness by at least -`min_closeness_gain`, so that closeness is **strictly increasing** and bounded by 1: the walk -visits each file at most once and terminates in at most `1 / min_closeness_gain` swaps. This is a -stronger guarantee than a pairwise rule can give. Two directional size/score thresholds, for -example, can stop a 2-file ping-pong yet still admit a 3-file cycle; a single monotonic quantity -cannot cycle at all. The convergence property is checked in tests by simulating the -grab/re-evaluate loop over thousands of random pools across every preset. +The forbidden quadrant is impossible: a candidate that is both lower-score and bigger than the +current file is worse on both axes, so its closeness ≤ the current file's, so it never clears the +ACT margin. --- -## 5. Configuration model +## 4. Configuration model Everything lives under `[optimizer.topsis]`, layered on `defaults.toml`: ```toml [optimizer.topsis] -score_ideal = 1000000 # n_score scale … -score_anti_ideal = 0 -resolution_ideal = 2160 # fallback target when a profile exposes no allowed resolution -resolution_anti_ideal = 480 -score_gap = 0.20 # gap-cut: keep the top score cluster within this relative drop +score_window = 100000 # downgrade budget: keep score >= current - this +min_candidates = 2 # below this, HOLD without satisfying +outlier_frac = 0.5 # drop releases below 0.5x the movie's median GiB/h (junk guard) default_preset = "Balanced" # used when a profile name matches no preset keyword -min_closeness_gain = 0.02 # global swap margin (per-preset overridable) +min_closeness_gain = 0.02 # ACT margin over the current file (per-preset overridable) + +[optimizer.topsis.size_bounds] # shared per-resolution {floor, ceiling} GiB/h legitimacy band +"2160" = { floor = 1.5, ceiling = 36.1 } +"1080" = { floor = 0.45, ceiling = 13.3 } +# … 720 / 480 … [optimizer.topsis.presets.Efficient] -score = 0.44 # weights (score + size, sum 1.0) +score = 0.44 # weights (score + size, sum 1.0) — the only per-profile knob size = 0.56 pick = "topsis" # topsis | max_score | min_size -min_closeness_gain = 0.02 # optional per-preset override -[optimizer.topsis.presets.Efficient.reference] -"2160" = { floor = 4.0, lo = 7.1, target = 7.8, hi = 8.8, ceiling = 15 } # GiB/h per resolution -"1080" = { floor = 1.5, lo = 2.8, target = 3.5, hi = 4.3, ceiling = 7.5 } -# … 720 / 480 … ``` -A Radarr/Sonarr profile attaches to the preset whose name is a case-insensitive **substring** of -the profile name (`2160p Quality` → Quality). Pin or customize an exact profile with -`[optimizer.topsis.profiles.""]`, which may set `preset`, `weights`, `pick`, `reference`, or -`min_closeness_gain` (anything omitted is inherited from the matched preset). Validation at load -time enforces weights-sum-to-1, `0 ≤ min_closeness_gain < 1`, `floor < lo ≤ target ≤ hi < ceiling`, and a -known `pick` method. +A profile attaches to the preset whose name is a case-insensitive substring of the profile name +(`2160p Efficient` → Efficient); `[optimizer.topsis.profiles."Exact Name"]` overrides a preset, +weights, pick, or margin for one profile. --- -## 6. The worker loop +## 5. The worker loop and state -The optimizer is a continuous, interval-driven worker (the unmonitor job keeps its own cron). +The optimizer is a continuous, interval-driven worker. Each tick: refresh the item list if due, +build the active pool, gate on the download queue, then evaluate one item. -```mermaid -flowchart TD - Start(["Worker start"]) --> Refresh["Fetch item list
    (every list_refresh_minutes)"] - Refresh --> Pool["Build active pool:
    has-file − satisfied(in window) − evaluated this pass"] - Pool --> Empty{"pool empty?"} - Empty -->|yes| Idle["Idle sleep, then re-check / refresh"] - Idle --> Refresh - Empty -->|no| Q["GET /api/v3/queue
    (pace gate + 'already downloading' set)"] - Q --> Gate{"queue ≤ queue_max?"} - Gate -->|no| WaitQ["Sleep process_interval_seconds"] - WaitQ --> Q - Gate -->|yes| Pick["Pick item: random or ordered (pick_order)"] - Pick --> Dl{"already in queue?"} - Dl -->|yes| Skip["Skip: already downloading"] - Dl -->|no| Eval["Evaluate (pipeline §3)"] - Eval --> Decide{"ACT or HOLD?"} - Decide -->|HOLD| Sat["Mark satisfied"] - Decide -->|ACT| Grab["POST grab: record nothing"] - Skip --> Settle["Sleep process_interval_seconds (settle)"] - Sat --> Settle - Grab --> Settle - Settle --> Empty -``` +- **Safe refresh.** The library fetch is guarded: a failed or interrupted fetch keeps the previous + item set and retries next tick. State is **never** pruned from the list, so a connection blip can + never wipe it. +- **One queue fetch per tick** serves both the pace gate (`queue_max`) and the "already + downloading?" skip, so there is no in-flight state to track and a restart needs no reconciliation. +- `process_interval_seconds` (default 15, min 10) doubles as a settle delay after a grab. +- `pick_order` only changes which items are improved first, never the per-item decision. -- **One queue fetch per iteration** serves both the pace gate (`queue_max`) and the "already - downloading?" skip, so there's **no in-flight state** to track and a restart needs no - reconciliation. -- `process_interval_seconds` (default 15, min 10) doubles as a **settle delay**: after a grab, - Radarr needs a moment to register the release in the queue before the next `queue_max` check. -- `pick_order` only changes which items are improved first (random, or ordered by title / file - size / date added / release date), never the per-item decision. -- A grab **records nothing**. Each item is remembered for the current **pass** so it isn't - re-picked; a list refresh updates the candidate set but doesn't restart the pass. When the pass - is fully covered it resets. +### Per-item state lifecycle (one-and-done) -### Per-item state lifecycle - -State (`/data/state.json`, keyed by item id) records exactly one thing, whether an item is -**satisfied**, which is what makes failure handling self-correcting. +`state.json` (keyed by item id) records, per satisfied item, **the profile it was satisfied for**. ```mermaid stateDiagram-v2 [*] --> Unprocessed: not in state - Unprocessed --> Unprocessed: ACT: grab posted (state unchanged) - Unprocessed --> Satisfied: HOLD: nothing better than current - Satisfied --> Unprocessed: reevaluate_after_days elapsed + Unprocessed --> Unprocessed: ACT grab posted (state unchanged) + Unprocessed --> Satisfied: HOLD, current file optimal for its profile + Satisfied --> Unprocessed: profile changed OR file removed ``` -- A grab is **never recorded**. A grab that **succeeds** replaces the file; next evaluation finds - nothing better → **satisfied** → leaves the pool. A grab that **fails** was never satisfied, so - the item is retried, and by then the dead release is blocklisted, so pre-filter 1 drops it and - the next-best candidate is picked. Repeated failures walk down the ranking until one sticks. -- A download **in progress** is skipped via live queue membership, never re-grabbed. - -> **Dependency:** this relies on Radarr/Sonarr **Failed Download Handling** (default on) to -> blocklist dead releases. Without it, a failed grab wouldn't be de-prioritized next pass. +- A grab is **never recorded**. A grab that succeeds replaces the file; the next evaluation finds + it optimal → satisfied. A grab that fails was never satisfied, so the item is retried, and the + dead release is now blocklisted (so filter 1 drops it and the next-best is tried). This relies on + Radarr/Sonarr **Failed Download Handling** (default on). +- Satisfied is **permanent**: there is no time-based re-evaluation. A satisfied movie becomes + eligible again only if its **profile changes** (the optimal pick depends on the profile) or its + **file is removed**. To force a full re-run, delete (or edit) `state.json`. --- -## 7. The Unmonitor job +## 6. The Unmonitor job A separate, cron-scheduled pass (`[unmonitor]`, default `0 4 * * *`) that **unmonitors** items so -the \*arr apps stop chasing upgrades off their RSS feeds just because newer releases appeared. - -An item is unmonitored when **all** of these hold (`features/unmonitor/candidates.py`): - -1. it is currently **monitored** (otherwise nothing to do); -2. it **has a file**: never unmonitor a wanted-but-undownloaded item (that would mean "give - up"); only stop chasing *upgrades*; -3. if `require_cutoff_met = true`, its quality **cutoff is met** (don't stop early if it hasn't - reached the target quality yet); -4. it is at least `days` old, measured against the configured `release_type` date - (`digitalRelease` for Radarr, `airDateUtc` for Sonarr, by default). - -This pairs naturally with the optimizer: the optimizer keeps improving files by `hasFile` -regardless of monitored state, while Unmonitor strips the monitoring that would otherwise have -Radarr/Sonarr fighting it with fresh RSS grabs. +the \*arr apps stop chasing upgrades off their RSS feeds. An item is unmonitored when it is +monitored, has a file, (optionally) has its quality cutoff met, and is at least `days` old against +the configured `release_type` date. This pairs with the optimizer: the optimizer keeps improving +files by `hasFile` regardless of monitored state, while Unmonitor strips the monitoring that would +otherwise have Radarr/Sonarr fighting it with fresh RSS grabs. --- *`dry_run = true` makes both features log every would-be action without changing anything. -`tools/weight_lab.py` renders how each preset scores and picks across sample releases, and -`tools/weight_lab.py --dataset ` runs the presets against a real gathered library sample -(see `tools/gather_training_data.py`).* +`tools/diagnose.py` runs the real engine over a gathered library sample +(`tools/gather_training_data.py`) and reports the pick quadrants, the total size shift, and the +full original → new-pick list.* diff --git a/README.md b/README.md index 1c7bea1..aeb617b 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ It does two jobs, both optional and independent: number of days after release, so Radarr/Sonarr stop grabbing "new" releases off RSS just because they appeared. -It is safe by design: it **never grows a file except on a real score upgrade** (a bigger, -lower-or-equal-score release is never grabbed), it **cannot oscillate** (every swap strictly raises -a closeness score computed from the release alone, so a file is never revisited), and "optimized" -means *the algorithm can no longer find anything better*, never merely "we triggered a grab" (grabs -fail to download all the time, and it handles that). +It is safe by design: it **never grows a file except on a real score upgrade** (a pick is never +both lower-score *and* bigger than the current file), it **cannot oscillate** (each movie is +optimized once and then left alone — never re-evaluated unless its profile changes or its file is +removed), and "optimized" means *the algorithm can no longer find anything better*, never merely +"we triggered a grab" (grabs fail to download all the time, and it handles that). > Want the details: the per-preset size tables, the swap rule, the TOPSIS formulas, the config > model, and the worker loop? See **[ALGORITHM.md](ALGORITHM.md)**. @@ -125,7 +125,7 @@ inline in [`defaults.toml`](optimizarr/defaults.toml)): | `[optimizer.] allow_size_increase` | `false` blocks any bigger file (also blocks resolution upgrades). | | `[optimizer.] allow_quality_downgrade` | `false` blocks lower-score releases. **Turn this off if you only ever want upgrades.** Leaving it on is what lets Efficient/Compact realign downward. | | `[unmonitor.] days` / `release_type` / `require_cutoff_met` | When to unmonitor after release, and whether to wait for the quality cutoff first. | -| `[optimizer.topsis]` | The selection engine: per-preset 5-point size **bands** (floor/lo/target/hi/ceiling), **presets**, and tuning. You rarely need this. See [ALGORITHM.md](ALGORITHM.md). | +| `[optimizer.topsis]` | The selection engine: per-profile **weights** (score vs size), the shared size legitimacy bounds, and the score window. You rarely need this. See [ALGORITHM.md](ALGORITHM.md). | The optimizer selects items by **`hasFile`**, regardless of monitored state. It improves the existing library, and the unmonitor job deliberately strips monitoring once a file exists. diff --git a/optimizarr/config.py b/optimizarr/config.py index 350b203..14e99ae 100644 --- a/optimizarr/config.py +++ b/optimizarr/config.py @@ -151,10 +151,9 @@ def log_summary(config: Config) -> None: ) if opt.enabled: logger.info( - " optimizer: process_interval=%ds list_refresh=%dm reevaluate_after=%dd", + " optimizer: process_interval=%ds list_refresh=%dm (one-and-done, no auto re-eval)", opt.process_interval_seconds, opt.list_refresh_minutes, - opt.reevaluate_after_days, ) for name, app in (("radarr", opt.radarr), ("sonarr", opt.sonarr)): logger.info( diff --git a/optimizarr/defaults.toml b/optimizarr/defaults.toml index 2eea258..01861e6 100644 --- a/optimizarr/defaults.toml +++ b/optimizarr/defaults.toml @@ -32,11 +32,11 @@ require_cutoff_met = true [optimizer] enabled = true # Only grab when the download queue has <= queue_max actively-downloading items (0 = empty only). -queue_max = 5 +queue_max = 0 # Pause grabbing while more than import_max completed downloads are waiting to import # (importPending) or importing, so the import backlog drains first. importBlocked (manual-only) is # not counted, so a stuck manual item can't freeze grabs forever. -import_max = 2 +import_max = 1 # Order the worker walks the library each pass. This only changes which items are improved FIRST, # never the per-item decision (so it never changes WHAT gets grabbed, only the order it happens): # random shuffle each pass (default) @@ -47,11 +47,12 @@ import_max = 2 pick_order = "random" # Short loop: seconds between processing items. Also the wait when the queue is full or the list # is exhausted, and the settle delay so a grab surfaces in the queue before the next read. Min 10. -process_interval_seconds = 15 +process_interval_seconds = 300 # Long loop: how often to re-fetch the full library list from Radarr/Sonarr. list_refresh_minutes = 15 -# A satisfied item (HOLD: nothing better found) is reconsidered this many days later. -reevaluate_after_days = 30 +# One-and-done: a movie satisfied for its profile is never re-evaluated automatically. It becomes +# eligible again only if its profile changes or its file is removed; to force a full re-run, delete +# (or edit) the state.json. There is no time-based re-evaluation. # Per-app: # enabled toggle this app from the optimizer. @@ -92,121 +93,77 @@ ignore_completed_in_queue = true auto_import_downgrades = true # ===== TOPSIS ===== +# Relative two-axis model. There are no per-preset size tables or shapes. For each movie: +# 1. FILTER the obviously bad: drop negatives, apply three-tier score window (see below), keep +# only the profile's target resolution, drop outside the shared [floor, ceiling] band, +# drop lone-small outliers. +# 2. SCORE: min-max normalize the survivors (plus the current file) on two axes -- score (higher +# better) and GiB/h (smaller better) -- then combine with the profile's weights into a TOPSIS +# closeness. ACT if the best candidate's closeness beats the current file's; else HOLD. +# The only per-profile knob is the weights. score down + size up vs the current file is impossible +# (a worse-on-both pick has lower closeness than the current file, so it is never grabbed). [optimizer.topsis] -# Score-axis normalization: how a raw customFormatScore becomes the [0,1] "score" attribute. -# logistic (default): S-curve, n_score = 1 / (1 + exp(-(score - score_center) / score_width)). -# Profilarr scores bunch tightly near the top (a "perfect" release is ~1,000,000 but real -# releases top out lower and cluster there) and fall off fast below. A logistic puts the -# axis's resolution where releases actually compete, so a small real score difference at the -# top is not drowned out by the size axis; and there is no hard cutoff, so a library whose -# scores all sit near one value is still spread out. Defaults are fit to observed release scores. -# linear: the old fixed ramp over [score_anti_ideal, score_ideal]. Kept for simple setups. -score_norm = "logistic" -score_center = 825000 # score that maps to n_score = 0.5 (fit to real post-prefilter candidates) -score_width = 87500 # slope; roughly the spread of the competitive score cluster (bigger = gentler) -# Used only when score_norm = "linear": -score_ideal = 1000000 -score_anti_ideal = 0 -# gap-cut inclusion: keep the top score cluster down to the first relative drop greater than this. -score_gap = 0.20 -# n_size value at the band shoulders (lo/hi). Higher = flatter "good size" band, so score drives -# harder inside it; lower = more pull toward the exact target. 1.0 = a hard top-hat band. -size_shoulder = 0.85 +# Score window uses three tiers (tried in order, stopping at the first with enough candidates): +# Tier 1 -- anchor at top: floor = max_available_score - score_window (favors quality) +# Tier 2 -- expand to current: floor = current_file_score (pulls in releases at/above the bar) +# Tier 3 -- full budget: floor = max(0, current_file_score - score_window) (last resort) +score_window = 100000 +# Minimum pool size used at two gates: +# 1. Score-window tier: expand to the next tier if fewer than this survive the current one. +# 2. TOPSIS gate: HOLD without satisfying if fewer than this remain after ALL filters (relative +# min-max over a tiny pool is unreliable). The movie is retried when more releases appear. +min_candidates = 5 # Outlier drop: discard a candidate whose GiB/h is below outlier_frac x the median GiB/h of the -# comparable-score (gap-cut) cluster -- a lone tiny release among bigger ones is usually a bad -# encode. "A good encode has corroborating peers." 0 disables it. +# surviving cluster. This is the key guard against over-compressed / upscale "2160p" junk that +# Profilarr still scores high on format tags (a real 2160p HEVC is ~4-8 GiB/h; junk is < 2). Without +# it, ~half of picks were such tiny files. 0 disables it; lower it (e.g. 0.4) to keep more lean +# encodes, raise it for stricter junk rejection. outlier_frac = 0.5 # Preset used when a profile name matches no preset by keyword. default_preset = "Balanced" -# Swap margin: a candidate is grabbed only if it raises TOPSIS closeness by at least this much -# (per-preset overridable). Any positive value guarantees the optimizer cannot oscillate, since -# closeness is computed from the release alone and therefore strictly increases on every swap. +# ACT margin: grab the best candidate only if its closeness beats the current file's by at least +# this. Hysteresis to avoid churning on a negligible improvement. min_closeness_gain = 0.02 -# ----- Presets ----- -# Each preset = TOPSIS weights (score + size, sum 1.0) + a `pick` method (topsis | max_score | -# min_size) + its OWN absolute size table [.reference] + an optional `min_closeness_gain`. Profiles -# attach to a preset whose name is a (case-insensitive) substring of the profile name; e.g. -# "2160p Quality" -> Quality, "2160p Remux" -> Remux. Add presets freely. (Resolution is no longer -# a scored axis -- Profilarr folds it into the score, and a hard guard in decision.py forbids -# dropping below the profile target, so the two axes are score and size.) -# -# The size table is a per-resolution 5-point trapezoid `{floor, lo, target, hi, ceiling}` in GiB/h: -# - floor / ceiling : the legitimacy bounds. Outside [floor, ceiling] a release is dropped before -# scoring (too soft / fake below floor, bloated above ceiling) and n_size = 0. -# - lo .. hi : the "good size" band. n_size = `size_shoulder` at lo/hi, so inside the band -# size barely moves the pick and SCORE drives it. -# - target : the peak (n_size = 1.0). Placed at a percentile of real release sizes per -# profile (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, Remux -# remux-range). Nudge a profile by sliding target within [lo, hi]. -# Each profile aims at a different size band, so they pick DIFFERENT releases. A too-small file now -# scores low on the size axis, BUT a bigger file is only ever grabbed on a genuine score upgrade: -# decision.py forbids "bigger and lower-or-equal score", so the band preference can never degrade -# into "drop the score to inflate the file". The swap rule also requires a closeness gain -# >= min_closeness_gain and never drops resolution below the profile target. +# Shared per-resolution legitimacy band {floor, ceiling} in GiB/h (GB = 1024^3). Below floor = +# fake/upscale, above ceiling = bloat; outside it a release is dropped before scoring. Wide on +# purpose: the relative scoring, not a hard band, expresses each profile's taste. (~P1 / P99 of +# real release sizes per resolution.) +[optimizer.topsis.size_bounds] +"2160" = { floor = 3, ceiling = 30 } +"1080" = { floor = 1, ceiling = 15 } +"720" = { floor = 0.5, ceiling = 7.5 } +"480" = { floor = 0.10, ceiling = 5 } +# ----- Presets ----- +# Each preset = TOPSIS weights (score + size, sum 1.0) + an optional per-preset `min_closeness_gain`. +# The pick is always the highest-closeness candidate. Profiles attach to a preset whose name +# is a (case-insensitive) substring of the profile name; e.g. "2160p Quality" -> Quality. A high +# size weight (Compact) trades score for the smallest file; a high score weight (Remux) takes the +# best score and barely cares about size. Resolution is not a scored axis (Profilarr folds it into +# the score, and the target-resolution filter handles it). [optimizer.topsis.presets.Remux] -# Untouched UHD/BluRay remux: score is nearly everything; the band sits at remux bitrates (no remux -# releases in the training sample, so these are hand-set, not data-derived). -score = 0.94 -size = 0.06 -pick = "topsis" -[optimizer.topsis.presets.Remux.reference] -"2160" = { floor = 15.0, lo = 28.0, target = 38.0, hi = 60.0, ceiling = 90.0 } -"1080" = { floor = 8.0, lo = 15.0, target = 20.0, hi = 32.0, ceiling = 48.0 } -"720" = { floor = 2.0, lo = 4.0, target = 5.5, hi = 9.0, ceiling = 14.0 } -"480" = { floor = 0.5, lo = 1.2, target = 1.8, hi = 3.0, ceiling = 5.0 } +score = 0.90 +size = 0.10 [optimizer.topsis.presets.Quality] -# Score-first, aims high in the encode range (~P77). High floor rejects suspiciously-small encodes; -# high ceiling keeps legitimately-big high-score files (e.g. a grain-heavy 2160p that runs hot). -score = 0.86 -size = 0.14 -pick = "topsis" -[optimizer.topsis.presets.Quality.reference] -"2160" = { floor = 6.5, lo = 10.7, target = 11.5, hi = 13.5, ceiling = 30.0 } -"1080" = { floor = 3.0, lo = 5.0, target = 6.3, hi = 8.7, ceiling = 16.0 } -"720" = { floor = 1.2, lo = 2.0, target = 2.6, hi = 3.5, ceiling = 7.0 } -"480" = { floor = 0.5, lo = 0.9, target = 1.1, hi = 1.5, ceiling = 3.5 } +score = 0.75 +size = 0.25 [optimizer.topsis.presets.Balanced] -# Good score AND a sensible size: target = the median real encode (~P50). -score = 0.56 -size = 0.44 -pick = "topsis" -[optimizer.topsis.presets.Balanced.reference] -"2160" = { floor = 5.0, lo = 8.1, target = 9.3, hi = 11.0, ceiling = 20.0 } -"1080" = { floor = 2.0, lo = 3.8, target = 4.5, hi = 5.3, ceiling = 10.0 } -"720" = { floor = 0.8, lo = 1.5, target = 1.8, hi = 2.2, ceiling = 4.5 } -"480" = { floor = 0.3, lo = 0.6, target = 0.7, hi = 0.9, ceiling = 2.5 } +score = 0.50 +size = 0.50 [optimizer.topsis.presets.Efficient] -# Lean but legitimate: target around the lower-quartile encode (~P30), not the smallest. -score = 0.44 -size = 0.56 -pick = "topsis" -[optimizer.topsis.presets.Efficient.reference] -"2160" = { floor = 4.0, lo = 7.1, target = 7.8, hi = 8.8, ceiling = 15.0 } -"1080" = { floor = 1.5, lo = 2.8, target = 3.5, hi = 4.3, ceiling = 7.5 } -"720" = { floor = 0.6, lo = 1.1, target = 1.4, hi = 1.7, ceiling = 3.5 } -"480" = { floor = 0.25, lo = 0.45, target = 0.55, hi = 0.7, ceiling = 2.0 } +score = 0.40 +size = 0.60 [optimizer.topsis.presets.Compact] -# Smallest viable, but NOT the absolute smallest: target near the bottom decile (~P10), with the -# floor still rejecting outright fakes/bad encodes below it. -score = 0.22 -size = 0.78 -pick = "topsis" -[optimizer.topsis.presets.Compact.reference] -"2160" = { floor = 3.5, lo = 5.9, target = 6.8, hi = 7.6, ceiling = 12.0 } -"1080" = { floor = 1.2, lo = 1.9, target = 2.6, hi = 3.2, ceiling = 6.0 } -"720" = { floor = 0.4, lo = 0.8, target = 1.0, hi = 1.3, ceiling = 3.0 } -"480" = { floor = 0.2, lo = 0.35, target = 0.45, hi = 0.6, ceiling = 2.0 } +score = 0.25 +size = 0.75 -# Per-profile-name overrides. Reference a preset, or override weights / pick / reference / margin: +# Per-profile-name overrides. Reference a preset, or override weights / pick / margin: # [optimizer.topsis.profiles."2160p Remux"] # preset = "Remux" # [optimizer.topsis.profiles."My Custom 1080p"] # weights = { score = 0.6, size = 0.4 } -# [optimizer.topsis.profiles."My Custom 1080p".reference] -# "1080" = { floor = 1.5, lo = 3.5, target = 4.0, hi = 5.0, ceiling = 9.0 } diff --git a/optimizarr/features/optimizer/config.py b/optimizarr/features/optimizer/config.py index 18cb008..7af71ae 100644 --- a/optimizarr/features/optimizer/config.py +++ b/optimizarr/features/optimizer/config.py @@ -1,22 +1,15 @@ """Optimizer feature configuration: schema + parsing of the [optimizer] TOML section. -Tuning values (per-preset size tables, score anchors) come from the merged config -(defaults.toml + the user's config.toml) — there are no magic defaults baked into this module. -The shared loader (optimizarr.config) delegates to parse_optimizer() here. - -Size model: each preset carries its OWN absolute `reference` table, one 5-point trapezoid -`{floor, lo, target, hi, ceiling}` entry per resolution, in GiB/h: - - floor / ceiling : legitimacy bounds. Outside [floor, ceiling] a release is dropped before - scoring (too small / fake below floor, bloated above ceiling), n_size = 0. - - lo .. hi : the "good size" band; n_size = `size_shoulder` at lo/hi, so inside it score - drives the pick and `target` placement only nudges the preference. - - target : the peak (n_size = 1.0), placed at a per-profile percentile of real sizes. - -Scoring is TWO axes (score, size); resolution is a hard guard in decision.py, not a weighted axis. -The swap rule is a single closeness-gain test (see decision.py): a candidate is taken only if it -raises TOPSIS closeness by at least `min_closeness_gain`, plus the resolution guard. Closeness is a -fixed function of the release alone, so the optimizer is provably non-oscillating (every swap -strictly increases it). +Tuning values come from the merged config (defaults.toml + the user's config.toml); there are no +magic defaults baked into this module. The shared loader (optimizarr.config) delegates here. + +Size model (relative): there are no per-preset size tables or shapes. Inclusion filters drop the +obviously-bad (score < 0, score below `current - score_window`, wrong resolution, outside the +shared per-resolution `size_bounds` legitimacy band, lone-small outliers). The survivors are then +scored on TWO axes, each min-max normalized OVER THE SURVIVORS (+ the current file): score (higher +better) and GiB/h (smaller better). A profile's `weights` combine them into a TOPSIS closeness. +The only per-profile knob is the weights. Oscillation is prevented by one-and-done state (a movie +is satisfied once its current file is optimal and never re-evaluated), not by a closeness margin. """ from __future__ import annotations @@ -36,66 +29,55 @@ "release_date_asc", # oldest release first "release_date_desc", # newest release first } -PICK_METHODS = {"topsis", "max_score", "min_size"} - -# resolution -> (floor, lo, target, hi, ceiling) GiB/h. A 5-point trapezoid: n_size is 0 outside -# [floor, ceiling], rises to `shoulder` at lo, peaks at 1.0 at target, falls back to `shoulder` at -# hi. The flat-ish [lo, hi] band is the "good size" region where score drives the pick. -Reference = dict[int, tuple[float, float, float, float, float]] +# resolution -> (floor, ceiling) GiB/h. Shared legitimacy bounds: below floor = fake/upscale, +# above ceiling = bloat. A candidate outside its resolution's band is dropped before scoring. +SizeBounds = dict[int, tuple[float, float]] @dataclass class Preset: - """A named bundle: TOPSIS weights + a pick method + an absolute size table + swap margin.""" + """A named bundle: TOPSIS weights + a swap margin. The pick is always max closeness.""" weights: dict[str, float] # keys: score, size (sum 1.0) - pick: str # "topsis" | "max_score" | "min_size" - reference: Reference # res -> (floor, lo, target, hi, ceiling) GiB/h - min_closeness_gain: float # swap only if closeness improves by at least this + min_closeness_gain: float # ACT only if the pick beats the current file's closeness by this @dataclass class ProfileOverride: - """Exact-name override: reference a preset, or override its weights / pick / table / margin.""" + """Exact-name override: reference a preset, or override its weights / margin.""" preset: str | None = None weights: dict[str, float] | None = None - pick: str | None = None - reference: Reference | None = None min_closeness_gain: float | None = None @dataclass class ResolvedProfile: - """Everything the scorer + swap rule need for one profile, after preset + override.""" + """Everything the scorer + decision need for one profile, after preset + override.""" weights: dict[str, float] - pick: str - reference: Reference min_closeness_gain: float @dataclass class TopsisConfig: - score_ideal: int - score_anti_ideal: int - score_gap: float default_preset: str default_min_closeness_gain: float presets: dict[str, Preset] - # Score-axis normalization. "logistic" (default) maps raw score through an S-curve centered - # at score_center with slope set by score_width, concentrating the [0,1] range where releases - # actually cluster. "linear" uses the fixed score_anti_ideal..score_ideal ramp. - score_norm: str = "logistic" - score_center: float = 825000.0 - score_width: float = 87500.0 - # n_size value at the band shoulders (lo/hi). Higher = flatter band = score drives harder - # inside it. 1.0 collapses the trapezoid to a hard top-hat. - size_shoulder: float = 0.85 + # Three-tier score window (see topsis._score_floor_tier): + # Tier 1: keep score >= max_available - score_window (anchor at top of what's on offer). + # Tier 2: if fewer than min_candidates survive, expand down to current_file_score. + # Tier 3: if still fewer, expand to max(0, current_file_score - score_window) (full budget). + score_window: int = 100000 + # Minimum pool size used at two gates: (1) the score-window tier check (expand to the next + # tier if too few survive the current one); (2) the TOPSIS gate (HOLD without satisfying if + # fewer than this many remain after all filters -- relative min-max is untrustworthy too thin). + min_candidates: int = 6 # Outlier prefilter: drop a candidate whose GiB/h is below outlier_frac x the median GiB/h of - # the comparable-score (gap-cut) cluster. Encodes "a good release has corroborating peers"; - # 0 disables it. + # the surviving cluster. 0 disables it. outlier_frac: float = 0.5 + # Shared per-resolution {floor, ceiling} legitimacy band (GiB/h). + size_bounds: SizeBounds = field(default_factory=dict) profiles: dict[str, ProfileOverride] = field(default_factory=dict) @@ -107,8 +89,7 @@ class OptimizerAppConfig: # If False, releases bigger than the current file are filtered out before scoring — # blocks resolution upgrades too (1080p -> 2160p is always a size increase). allow_size_increase: bool = True - # If False, releases with a lower score than the current file are filtered out before - # scoring. NOTE: turning this off neutralizes size-leaning presets (Compact/Efficient). + # If False, releases with a lower score than the current file are filtered out before scoring. allow_quality_downgrade: bool = True # If True, queue items waiting for manual import don't count toward queue_max. ignore_completed_in_queue: bool = True @@ -124,7 +105,6 @@ class OptimizerConfig: pick_order: str = "random" process_interval_seconds: int = 15 list_refresh_minutes: int = 15 - reevaluate_after_days: int = 30 radarr: OptimizerAppConfig = field(default_factory=OptimizerAppConfig) sonarr: OptimizerAppConfig = field(default_factory=OptimizerAppConfig) topsis: TopsisConfig = field(default_factory=lambda: default_topsis()) @@ -144,41 +124,28 @@ def _weights(raw: dict, where: str) -> dict[str, float]: return w -def _reference(raw: dict, where: str) -> Reference: - out: Reference = {} +def _size_bounds(raw: dict, where: str) -> SizeBounds: + out: SizeBounds = {} for res, entry in raw.items(): try: res_int = int(res) except (TypeError, ValueError) as e: raise ValueError(f"{where}: key {res!r} is not an integer resolution") from e - need = {"floor", "lo", "target", "hi", "ceiling"} - if not isinstance(entry, dict) or not need <= entry.keys(): - raise ValueError( - f"{where}.{res}: expected {{floor, lo, target, hi, ceiling}}, got {entry!r}" - ) + if not isinstance(entry, dict) or not {"floor", "ceiling"} <= entry.keys(): + raise ValueError(f"{where}.{res}: expected {{floor, ceiling}}, got {entry!r}") floor = float(entry["floor"]) - lo = float(entry["lo"]) - target = float(entry["target"]) - hi = float(entry["hi"]) ceiling = float(entry["ceiling"]) - if not (floor < lo <= target <= hi < ceiling): + if not (0.0 <= floor < ceiling): raise ValueError( - f"{where}.{res}: must satisfy floor < lo <= target <= hi < ceiling, " - f"got floor={floor} lo={lo} target={target} hi={hi} ceiling={ceiling}" + f"{where}.{res}: must satisfy 0 <= floor < ceiling, got floor={floor} " + f"ceiling={ceiling}" ) - out[res_int] = (floor, lo, target, hi, ceiling) + out[res_int] = (floor, ceiling) if not out: - raise ValueError(f"{where} is empty (a preset must define its size table)") + raise ValueError(f"{where} is empty (define at least one resolution's {{floor, ceiling}})") return out -def _parse_pick(raw: dict, where: str) -> str: - pick = str(raw.get("pick", "topsis")) - if pick not in PICK_METHODS: - raise ValueError(f"{where}.pick={pick!r} not in {sorted(PICK_METHODS)}") - return pick - - def _parse_min_gain(value: float | int, where: str) -> float: gain = float(value) if not (0.0 <= gain < 1.0): @@ -187,46 +154,31 @@ def _parse_min_gain(value: float | int, where: str) -> float: def _parse_preset(raw: dict, where: str, default_min_gain: float) -> Preset: - if "reference" not in raw: - raise ValueError(f"{where}: missing required size table [{where}.reference]") min_gain = ( _parse_min_gain(raw["min_closeness_gain"], where) if "min_closeness_gain" in raw else default_min_gain ) - return Preset( - weights=_weights(raw, where), - pick=_parse_pick(raw, where), - reference=_reference(raw["reference"], f"{where}.reference"), - min_closeness_gain=min_gain, - ) + return Preset(weights=_weights(raw, where), min_closeness_gain=min_gain) def _parse_profile_override(raw: dict, where: str) -> ProfileOverride: weights = _weights(raw["weights"], f"{where}.weights") if "weights" in raw else None - pick = _parse_pick(raw, where) if "pick" in raw else None - reference = _reference(raw["reference"], f"{where}.reference") if "reference" in raw else None min_gain = ( _parse_min_gain(raw["min_closeness_gain"], where) if "min_closeness_gain" in raw else None ) - return ProfileOverride( - preset=raw.get("preset"), - weights=weights, - pick=pick, - reference=reference, - min_closeness_gain=min_gain, - ) + return ProfileOverride(preset=raw.get("preset"), weights=weights, min_closeness_gain=min_gain) def _parse_topsis(raw: dict) -> TopsisConfig: - default_min_gain = _parse_min_gain(raw.get("min_closeness_gain", 0.02), "optimizer.topsis") + default_min_gain = _parse_min_gain(raw["min_closeness_gain"], "optimizer.topsis") presets = { name: _parse_preset(p, f"presets.{name}", default_min_gain) - for name, p in raw.get("presets", {}).items() + for name, p in raw["presets"].items() } if not presets: raise ValueError("optimizer.topsis.presets is empty (defaults.toml should define them)") - default_preset = str(raw.get("default_preset", "Balanced")) + default_preset = str(raw["default_preset"]) if default_preset not in presets: raise ValueError(f"default_preset {default_preset!r} is not a defined preset") profiles = { @@ -236,32 +188,27 @@ def _parse_topsis(raw: dict) -> TopsisConfig: for name, ov in profiles.items(): if ov.preset is not None and ov.preset not in presets: raise ValueError(f"profiles.{name}.preset {ov.preset!r} is not a defined preset") - score_norm = str(raw.get("score_norm", "logistic")) - if score_norm not in {"logistic", "linear"}: - raise ValueError( - f"optimizer.topsis.score_norm must be 'logistic' or 'linear', got {score_norm!r}" - ) - score_width = float(raw.get("score_width", 85000)) - if score_width <= 0: - raise ValueError(f"optimizer.topsis.score_width must be > 0, got {score_width}") - size_shoulder = float(raw.get("size_shoulder", 0.85)) - if not (0.0 <= size_shoulder <= 1.0): - raise ValueError(f"optimizer.topsis.size_shoulder must be in [0, 1], got {size_shoulder}") - outlier_frac = float(raw.get("outlier_frac", 0.5)) + score_window = int(raw["score_window"]) + if score_window < 0: + raise ValueError(f"optimizer.topsis.score_window must be >= 0, got {score_window}") + min_candidates = int(raw["min_candidates"]) + if min_candidates < 1: + raise ValueError(f"optimizer.topsis.min_candidates must be >= 1, got {min_candidates}") + outlier_frac = float(raw["outlier_frac"]) if not (0.0 <= outlier_frac < 1.0): raise ValueError(f"optimizer.topsis.outlier_frac must be in [0, 1), got {outlier_frac}") + if "size_bounds" not in raw: + raise ValueError( + "optimizer.topsis.size_bounds is required (per-resolution {floor, ceiling})" + ) return TopsisConfig( - score_ideal=int(raw["score_ideal"]), - score_anti_ideal=int(raw["score_anti_ideal"]), - score_gap=float(raw["score_gap"]), default_preset=default_preset, default_min_closeness_gain=default_min_gain, presets=presets, - score_norm=score_norm, - score_center=float(raw.get("score_center", 805000)), - score_width=score_width, - size_shoulder=size_shoulder, + score_window=score_window, + min_candidates=min_candidates, outlier_frac=outlier_frac, + size_bounds=_size_bounds(raw["size_bounds"], "optimizer.topsis.size_bounds"), profiles=profiles, ) @@ -283,55 +230,39 @@ def _parse_release_types(raw: object, allowed: set[str], where: str) -> list[str return out -def _parse_optimizer_app( - raw: dict, default_release_type: list[str], allowed: set[str], where: str -) -> OptimizerAppConfig: - release_type = _parse_release_types( - raw.get("release_type", default_release_type), allowed, where - ) +def _parse_optimizer_app(raw: dict, allowed: set[str], where: str) -> OptimizerAppConfig: return OptimizerAppConfig( - enabled=bool(raw.get("enabled", True)), - min_age_days=int(raw.get("min_age_days", 0)), - release_type=release_type, - allow_size_increase=bool(raw.get("allow_size_increase", True)), - allow_quality_downgrade=bool(raw.get("allow_quality_downgrade", True)), - ignore_completed_in_queue=bool(raw.get("ignore_completed_in_queue", True)), - auto_import_downgrades=bool(raw.get("auto_import_downgrades", True)), + enabled=bool(raw["enabled"]), + min_age_days=int(raw["min_age_days"]), + release_type=_parse_release_types(raw["release_type"], allowed, where), + allow_size_increase=bool(raw["allow_size_increase"]), + allow_quality_downgrade=bool(raw["allow_quality_downgrade"]), + ignore_completed_in_queue=bool(raw["ignore_completed_in_queue"]), + auto_import_downgrades=bool(raw["auto_import_downgrades"]), ) def parse_optimizer(raw: dict) -> OptimizerConfig: - pick_order = str(raw.get("pick_order", "random")).strip() + pick_order = str(raw["pick_order"]).strip() if pick_order not in PICK_ORDERS: raise ValueError(f"optimizer.pick_order={pick_order!r} not in {sorted(PICK_ORDERS)}") - process_interval_seconds = int(raw.get("process_interval_seconds", 15)) + process_interval_seconds = int(raw["process_interval_seconds"]) if process_interval_seconds < 10: raise ValueError( f"optimizer.process_interval_seconds must be >= 10, got {process_interval_seconds}" ) return OptimizerConfig( - enabled=bool(raw.get("enabled", False)), - queue_max=int(raw.get("queue_max", 5)), - import_max=int(raw.get("import_max", 2)), + enabled=bool(raw["enabled"]), + queue_max=int(raw["queue_max"]), + import_max=int(raw["import_max"]), pick_order=pick_order, process_interval_seconds=process_interval_seconds, - list_refresh_minutes=int(raw.get("list_refresh_minutes", 15)), - reevaluate_after_days=int(raw.get("reevaluate_after_days", 30)), - radarr=_parse_optimizer_app( - raw.get("radarr", {}), - ["digitalRelease", "dateAdded"], - RADARR_RELEASE_TYPES, - "optimizer.radarr", - ), - sonarr=_parse_optimizer_app( - raw.get("sonarr", {}), - ["airDateUtc", "dateAdded"], - SONARR_RELEASE_TYPES, - "optimizer.sonarr", - ), - topsis=_parse_topsis(raw.get("topsis", {})), + list_refresh_minutes=int(raw["list_refresh_minutes"]), + radarr=_parse_optimizer_app(raw["radarr"], RADARR_RELEASE_TYPES, "optimizer.radarr"), + sonarr=_parse_optimizer_app(raw["sonarr"], SONARR_RELEASE_TYPES, "optimizer.sonarr"), + topsis=_parse_topsis(raw["topsis"]), ) diff --git a/optimizarr/features/optimizer/decision.py b/optimizarr/features/optimizer/decision.py index 3372909..bba3eb3 100644 --- a/optimizarr/features/optimizer/decision.py +++ b/optimizarr/features/optimizer/decision.py @@ -1,33 +1,32 @@ """Pure per-item decision: given fetched data, return ACT (with the release) or HOLD. -"Optimized" means the algorithm can no longer find anything better than the current file -(HOLD) — never merely "we triggered a grab". The decision is: - - 1. **Prefilter + score** (topsis.py): drop hard rejections, drop outside the preset's size band - (floor..ceiling), gap-cut the score tail, drop lone-small outliers, then score the survivors - by TOPSIS closeness over two axes (score, size). - 2. **Swap rule** (here): a candidate is legal iff it raises closeness by at least the preset's - `min_closeness_gain`, does not drop resolution below the profile target, AND is not "bigger at - a lower-or-equal score" (a larger file is grabbed only on a genuine score upgrade — the peaked - size curve no longer guarantees that for free). Because closeness is computed from the release - alone, every accepted swap strictly increases it, so the optimizer is provably non-oscillating. - 3. **Pick** (topsis.py): choose among the legal candidates by the profile's pick method. - -ACT iff at least one candidate is legal; otherwise HOLD (and the worker marks the item satisfied). +"Optimized" means the algorithm can no longer find anything better than the current file (a +HOLD that *satisfies*), never merely "we triggered a grab". The decision is: + + 1. **Filter + score** (topsis.py): drop hard rejections, drop score below the downgrade budget + (`current - score_window`), keep only the target resolution, drop outside the legitimacy band, + drop lone-small outliers, then min-max normalize the survivors on two relative axes (score, + GiB/h) and combine with the profile's weights into a TOPSIS closeness. + 2. **Decide** (here): if too few candidates survived to compare, HOLD WITHOUT satisfying (retry + later). Otherwise ACT on the best candidate iff its closeness beats the current file's by the + margin; else HOLD and SATISFY (the current file is already optimal for its profile). + +A satisfying HOLD means the current (already-imported) file is the best pick. Because a candidate +must beat the current file's closeness, a pick can never be both lower-score AND bigger than the +current file (that is worse on both axes, so lower closeness). One-and-done state (worker.py) makes +a satisfied movie permanent, which is what prevents oscillation under relative scoring. """ from dataclasses import dataclass -from optimizarr.features.optimizer.topsis import Topsis - -# Sentinel "no cap" for the resolution guard when a profile exposes no target resolution. -_NO_CAP = 10**9 +from optimizarr.features.optimizer.topsis import GB, Topsis @dataclass class Decision: action: str # "ACT" or "HOLD" reason: str + satisfy: bool = False # HOLD only: True => current file is optimal, mark satisfied (permanent) profile_name: str | None = None current: dict | None = None # {score, resolution, gbh, size_gb, closeness} pick: dict | None = None # {score, resolution, gbh, size_gb, closeness, title} @@ -35,30 +34,11 @@ class Decision: diag: dict | None = None -def resolution_ok(cur_res: int, cand_res: int, target_res: int | None) -> bool: - """Resolution may rise toward the target or fall only as far as the target, never below it. - `min(res, target)` makes over-target resolutions equivalent, so a leaner profile (lower - target) can still drop resolution down to its target while a normal profile never drops.""" - cap = target_res if target_res else _NO_CAP - return min(cand_res, cap) >= min(cur_res, cap) - - -def swap_allowed( - cur_closeness: float | None, - cand_closeness: float, - cur_res: int, - cand_res: int, - target_res: int | None, - min_gain: float, -) -> bool: - """The swap rule: a candidate is legal iff resolution doesn't drop below target AND closeness - improves by at least `min_gain`. An unknown current score (cur_closeness is None) is the worst - case, so any scored candidate that clears the resolution guard is an improvement.""" - if not resolution_ok(cur_res, cand_res, target_res): - return False - if cur_closeness is None: - return True - return cand_closeness >= cur_closeness + min_gain +def _current_raw(cf: dict, runtime_h: float) -> dict: + size_gb = (cf.get("size", 0) or 0) / GB + gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 + res = ((cf.get("quality") or {}).get("quality") or {}).get("resolution") or 0 + return {"score": cf.get("customFormatScore"), "resolution": res, "gbh": gbh, "size_gb": size_gb} def decide( @@ -70,17 +50,14 @@ def decide( current_file: dict | None, allow_size_increase: bool = True, allow_quality_downgrade: bool = True, - allow_larger_at_lower_score: bool = False, ) -> Decision: - """Pure decision: score the candidates, then keep those that raise closeness past the margin - without dropping resolution below target, and pick the best survivor. + """Pure decision: filter + relatively score the candidates, then ACT on the best one if it + beats the current file's closeness, else HOLD (satisfying iff there were enough candidates to + trust the comparison). Two optional pre-filters apply before scoring (per-app policy): - allow_size_increase=False drops releases bigger than the current file; - - allow_quality_downgrade=False drops releases with a lower customFormatScore. - allow_larger_at_lower_score=True drops the "never grow at a lower-or-equal score" swap guard, - letting the size bands realign even when the only nearer-the-band release scores lower (at the - cost of score regressions on bigger encodes).""" + - allow_quality_downgrade=False drops releases with a lower customFormatScore.""" cur = current_file or {} cur_size = cur.get("size") if not allow_size_increase and isinstance(cur_size, int) and cur_size > 0: @@ -90,58 +67,52 @@ def decide( releases = [r for r in releases if (r.get("customFormatScore") or 0) >= cur_score] resolved = topsis.resolve_profile(profile_name) - scored, diag = topsis.score_candidates(releases, runtime_h, resolved, target_resolution) + kept, diag = topsis.apply_prefilters(releases, runtime_h, target_resolution, cur_score) + + cur_raw = _current_raw(cur, runtime_h) if current_file else None + + # Not enough to compare: HOLD but do NOT satisfy, so the movie is retried when more appear. + if len(kept) < topsis.cfg.min_candidates: + return Decision( + "HOLD", + f"too few candidates to compare ({len(kept)} < {topsis.cfg.min_candidates})", + satisfy=False, + profile_name=profile_name, + current={"closeness": None, **cur_raw} if cur_raw else None, + diag=diag, + ) - current_closeness, cur_raw = topsis.closeness_for_current_file( - cur, runtime_h, resolved, target_resolution - ) - current = {"closeness": current_closeness, **cur_raw} - cur_res = cur_raw.get("resolution", 0) or 0 - - legal: list[tuple[dict, dict, float]] = [] - for rel, attrs, clo in scored: - # Never grow a file without a score upgrade. Under the old one-sided size curve "no bigger - # file for a lower score" fell out for free; the peaked trapezoid can rank a bigger, - # nearer-the-band release above a too-small current file, so we restore the invariant - # explicitly: a larger file is accepted only on a genuine score upgrade. This stops the - # "too small smells bad" penalty from degrading into "drop the score to inflate the file". - cand_score = attrs["raw"]["score"] - if ( - not allow_larger_at_lower_score - and cur_score is not None - and cand_score is not None - and cand_score <= cur_score - and isinstance(cur_size, int) - and cur_size > 0 - and rel.get("size", 0) > cur_size - ): - continue - if swap_allowed( - current_closeness, - clo, - cur_res, - attrs["raw"]["resolution"], - target_resolution, - resolved.min_closeness_gain, - ): - legal.append((rel, attrs, clo)) - diag["after_swap_gate"] = len(legal) + scored, current = topsis.score_pool(kept, current_file, runtime_h, resolved) + cur_clo = current["closeness"] if current else None + current_info = {"closeness": cur_clo, **(current["raw"] if current else cur_raw or {})} + + margin = resolved.min_closeness_gain + legal = [t for t in scored if cur_clo is None or t[2] > cur_clo + margin] if not legal: - why = f"no viable candidate ({diag['inclusion']})" if not scored else "nothing better" - return Decision("HOLD", why, profile_name=profile_name, current=current, diag=diag) + # Current file already beats every candidate for its profile -> optimized. Satisfy. + best = scored[0] + best_info = {"closeness": best[2], "title": best[0].get("title", "?"), **best[1]["raw"]} + return Decision( + "HOLD", + "nothing better (current is optimal)", + satisfy=True, + profile_name=profile_name, + current=current_info, + pick=best_info, + diag=diag, + ) - selected = topsis.select(legal, resolved) - assert selected is not None # legal is non-empty (guarded above); select is None only on [] - release, attrs, pick_closeness = selected - pick_info = {"closeness": pick_closeness, "title": release.get("title", "?"), **attrs["raw"]} + rel, attrs, clo = legal[0] # scored is sorted best-first, so this is the highest closeness + pick_info = {"closeness": clo, "title": rel.get("title", "?"), **attrs["raw"]} return Decision( "ACT", - f"closeness-gain {resolved.pick} pick of {len(legal)}", + f"best of {len(legal)} (closeness {clo:.3f})", + satisfy=False, profile_name=profile_name, - current=current, + current=current_info, pick=pick_info, - release=release, + release=rel, diag=diag, ) diff --git a/optimizarr/features/optimizer/state.py b/optimizarr/features/optimizer/state.py index ca66fb4..ef2a521 100644 --- a/optimizarr/features/optimizer/state.py +++ b/optimizarr/features/optimizer/state.py @@ -1,18 +1,17 @@ """Per-item optimizer state, persisted to JSON. -Keyed by app ("radarr"/"sonarr") then item id (movie id / episode id). The only thing -persisted is which items are *satisfied* — the algorithm found nothing better than the -current file (HOLD). The lifecycle is deliberately minimal: - - unprocessed -> not in state: eligible to be picked and evaluated - satisfied -> HOLD: nothing better right now; dropped from the pool until - reevaluate_after_days elapses, then eligible again - -A grab is never recorded. If it succeeds, the next evaluation HOLDs and marks the item -satisfied; if it fails, the item was never satisfied so it stays in the pool and is -retried later (the failed release having been blocklisted by Radarr/Sonarr). Downloads in -progress are detected live from the queue, not from state, so a restart recovers with no -reconciliation — nothing load-bearing lives only in memory. +Keyed by app ("radarr"/"sonarr") then item id (movie id / episode id). Each satisfied entry +records the *profile* it was satisfied for. The lifecycle is one-and-done: + + unprocessed -> not in state: eligible to be evaluated + satisfied -> the current (imported) file is optimal for `profile`; permanently dropped from + the pool. It becomes active again ONLY if the profile changes or the file is + removed. There is no time-based re-activation; delete state.json to force a re-run. + +A grab is never recorded. If it succeeds, the next evaluation HOLDs against the imported file and +marks the item satisfied; if it fails, the item was never satisfied so it stays in the pool and is +retried later (the failed release having been blocklisted by Radarr/Sonarr). Downloads in progress +are detected live from the queue, not from state, so a restart recovers with no reconciliation. """ import json @@ -23,8 +22,6 @@ from dataclasses import asdict, dataclass from datetime import UTC, datetime -from optimizarr.dates import parse_iso - logger = logging.getLogger("optimizarr") SATISFIED = "satisfied" @@ -34,6 +31,7 @@ class StateEntry: status: str updated_at: str + profile: str | None = None # the profile the item was satisfied for (invalidates on change) def _now_iso() -> str: @@ -60,7 +58,9 @@ def _load(self) -> None: bucket = self._data.setdefault(app, {}) for item_id, entry in items.items(): bucket[str(item_id)] = StateEntry( - status=entry["status"], updated_at=entry["updated_at"] + status=entry["status"], + updated_at=entry["updated_at"], + profile=entry.get("profile"), ) def _save_locked(self) -> None: @@ -82,20 +82,21 @@ def _save_locked(self) -> None: def get(self, app: str, item_id: int) -> StateEntry | None: return self._data.get(app, {}).get(str(item_id)) - def is_active(self, app: str, item_id: int, now: datetime, reevaluate_after_days: int) -> bool: - """An item is active (worth picking) unless it's satisfied within the reevaluate - window. Expired satisfied entries become active again.""" + def is_active(self, app: str, item_id: int, profile: str | None, has_file: bool) -> bool: + """An item is active (worth evaluating) unless it is satisfied FOR ITS CURRENT PROFILE and + still has a file. One-and-done: there is no time-based re-activation. A satisfied item + becomes active again only if its profile changed (the optimal pick depends on the profile) + or its file was removed (needs a fresh grab). To force a full re-run, delete state.json.""" entry = self.get(app, item_id) if entry is None or entry.status != SATISFIED: return True - ts = parse_iso(entry.updated_at) - if ts is None: + if not has_file: return True - return (now - ts).total_seconds() / 86400 >= reevaluate_after_days + return entry.profile != profile - def mark_satisfied(self, app: str, item_id: int) -> None: + def mark_satisfied(self, app: str, item_id: int, profile: str | None) -> None: with self._lock: self._data.setdefault(app, {})[str(item_id)] = StateEntry( - status=SATISFIED, updated_at=_now_iso() + status=SATISFIED, updated_at=_now_iso(), profile=profile ) self._save_locked() diff --git a/optimizarr/features/optimizer/topsis.py b/optimizarr/features/optimizer/topsis.py index 12b1c5e..7a74319 100644 --- a/optimizarr/features/optimizer/topsis.py +++ b/optimizarr/features/optimizer/topsis.py @@ -1,29 +1,26 @@ -"""TOPSIS-based release scorer + per-profile pickers. - -Multi-objective release scoring on TWO axes, each normalized to [0,1]: - - score: Profilarr customFormatScore through a fixed transform (logistic by default), higher - better. Resolution is NOT a scored axis — Profilarr folds it into the score, and a hard - guard in decision.py forbids dropping below the profile target. - - size: GiB/h on a 5-point TRAPEZOID `{floor, lo, target, hi, ceiling}` — n_size is 0 outside - [floor, ceiling], rises to `size_shoulder` at lo, peaks at 1.0 at target, falls back to - `size_shoulder` at hi. The flat-ish [lo, hi] band is the profile's "good size" region: - inside it score drives the pick; outside it the steep shoulders pull back toward the - band. A too-small file IS now penalized (so it can be replaced by one nearer the band). - -Inclusion (before scoring): drop hard rejections, drop outside the per-resolution size band -(gbh < floor or gbh > ceiling), gap-cut the score tail, then drop lone-small size outliers. - -The size table is PER-PRESET (config-driven): each resolved profile carries its own table + -weights + pick + a `min_closeness_gain` swap margin. The swap rule lives in decision.py (a -closeness-gain test); closeness is a fixed function of the release alone, so the optimizer is -provably non-oscillating (every swap strictly raises it). This module scores and picks survivors. +"""TOPSIS release scorer over two RELATIVE axes. + +For one library item, candidates are scored on two axes, each min-max normalized over the surviving +candidates (the current file is then placed on the same scale): + - score: Profilarr customFormatScore, higher better -> n_score = (s - smin) / (smax - smin). + - size: GiB/h, smaller better -> n_size = (gmax - g) / (gmax - gmin). +Resolution is NOT a scored axis: a pre-filter keeps only the profile's target resolution. + +Score window uses a three-tier strategy (see `_score_floor_tier`): anchor at the top of what the +indexers offer (Tier 1), expand toward the current file's score if too few survive (Tier 2), and +fall back to the full downgrade budget below the current file (Tier 3). This favors high-scoring +candidates and only widens the window when the indexer returns sparse results near the top. + +A profile's `weights` combine the two normalized axes into a TOPSIS closeness; the only +per-profile knob is the weights. There is no absolute size table or shape, and no logistic score +curve -- everything is relative to what the indexers actually offer for this movie. """ from __future__ import annotations import math -from optimizarr.features.optimizer.config import Reference, ResolvedProfile, TopsisConfig +from optimizarr.features.optimizer.config import ResolvedProfile, SizeBounds, TopsisConfig GB = 1024**3 @@ -59,6 +56,47 @@ def eligible(releases: list[dict]) -> list[dict]: return keep +def _score_floor_tier( + scores: list[int], + current_score: int | None, + score_window: int, + min_pool: int, +) -> tuple[int, int]: + """Three-tier score floor over a list of non-negative scores. Returns (floor, tier). + + Tier 1 -- anchor at top: floor = max(scores) - score_window. Keeps the window tight when + the indexer returns many high-scoring releases. + Tier 2 -- expand to current: floor = current_score. Tried when Tier 1 yields too few; pulls + in releases scored at or above the current file to round out the pool. + Tier 3 -- full budget: floor = max(0, current_score - score_window). Last resort; the + original behavior, reached only when both tighter tiers are too sparse. + """ + max_score = max(scores, default=0) + + t1_floor = max(0, max_score - score_window) + if sum(1 for s in scores if s >= t1_floor) >= min_pool: + return t1_floor, 1 + + if current_score is not None: + t2_floor = max(0, current_score) + if sum(1 for s in scores if s >= t2_floor) >= min_pool: + return t2_floor, 2 + + t3_floor = max(0, current_score - score_window) if current_score is not None else 0 + return t3_floor, 3 + + +def _norm(value: float, lo: float, hi: float, invert: bool = False) -> float: + """Min-max to [0,1] (clamped). invert=True makes a smaller value score higher. A degenerate + range (hi <= lo) means the axis does not discriminate -> 1.0 for everyone.""" + if hi <= lo: + return 1.0 + t = (value - lo) / (hi - lo) + if invert: + t = 1.0 - t + return min(1.0, max(0.0, t)) + + class Topsis: """Config-driven scorer + picker. One instance per optimizer run.""" @@ -77,8 +115,8 @@ def _match_preset(self, profile_name: str) -> str: return self.cfg.default_preset def resolve_profile(self, profile_name: str | None) -> ResolvedProfile: - """Resolve a profile name to weights + pick + size table + swap margin, honoring an - exact-name override, then name-keyword preset matching, then default_preset.""" + """Resolve a profile name to weights + pick + margin, honoring an exact-name override, + then name-keyword preset matching, then default_preset.""" cfg = self.cfg override = cfg.profiles.get(profile_name) if profile_name else None if override and override.preset: @@ -88,49 +126,64 @@ def resolve_profile(self, profile_name: str | None) -> ResolvedProfile: else: base = cfg.presets[cfg.default_preset] weights = override.weights if (override and override.weights) else base.weights - pick = override.pick if (override and override.pick) else base.pick - reference = override.reference if (override and override.reference) else base.reference min_gain = ( override.min_closeness_gain if (override and override.min_closeness_gain is not None) else base.min_closeness_gain ) - return ResolvedProfile( - weights=weights, pick=pick, reference=reference, min_closeness_gain=min_gain - ) - - def reference_for( - self, res: int, reference: Reference - ) -> tuple[float, float, float, float, float]: - """(floor, lo, target, hi, ceiling) for a resolution; nearest defined at or below.""" - if res in reference: - return reference[res] - keys = sorted(reference) + return ResolvedProfile(weights=weights, min_closeness_gain=min_gain) + + def bounds_for(self, res: int) -> tuple[float, float]: + """(floor, ceiling) GiB/h for a resolution; nearest defined at or below, else lowest.""" + bounds: SizeBounds = self.cfg.size_bounds + if res in bounds: + return bounds[res] + keys = sorted(bounds) below = [k for k in keys if k <= res] - return reference[below[-1]] if below else reference[keys[0]] + return bounds[below[-1]] if below else bounds[keys[0]] # ----- pre-filters ----- - def filter_by_size_band( - self, releases: list[dict], runtime_h: float, reference: Reference + def _apply_score_window( + self, releases: list[dict], current_score: int | None + ) -> tuple[list[dict], int, int]: + """Three-tier window; returns (filtered, effective_floor, tier_number).""" + nonneg = [(r, r.get("customFormatScore") or 0) for r in releases] + nonneg = [(r, s) for r, s in nonneg if s >= 0] + scores = [s for _, s in nonneg] + floor, tier = _score_floor_tier( + scores, current_score, self.cfg.score_window, self.cfg.min_candidates + ) + return [r for r, s in nonneg if s >= floor], floor, tier + + def filter_by_score_window(self, releases: list[dict], current_score: int | None) -> list[dict]: + """Three-tier score window anchored at the top of what's available, expanding toward the + downgrade budget only when too few candidates survive the tighter tiers.""" + filtered, _, _ = self._apply_score_window(releases, current_score) + return filtered + + def filter_by_resolution( + self, releases: list[dict], target_resolution: int | None ) -> list[dict]: - """Drop releases outside the preset's per-resolution size band: below `floor` (fakes / - upscales / too-soft-for-the-resolution) or above `ceiling` (bloated).""" + """Keep only the profile's target resolution (so the GiB/h axis is comparable). When the + profile exposes no target, keep everything.""" + if not target_resolution: + return releases + return [r for r in releases if _release_resolution(r) == target_resolution] + + def filter_by_size_band(self, releases: list[dict], runtime_h: float) -> list[dict]: + """Drop releases outside the shared per-resolution [floor, ceiling] band (fake/upscale + below floor, bloat above ceiling).""" keep = [] for r in releases: - floor, _lo, _target, _hi, ceiling = self.reference_for( - _release_resolution(r), reference - ) - gbh = _release_gbh(r, runtime_h) - if floor <= gbh <= ceiling: + floor, ceiling = self.bounds_for(_release_resolution(r)) + if floor <= _release_gbh(r, runtime_h) <= ceiling: keep.append(r) return keep def drop_size_outliers(self, releases: list[dict], runtime_h: float) -> list[dict]: - """Drop a release whose GiB/h is a lone outlier below the comparable-score cluster: below - `outlier_frac x median(cluster GiB/h)`. "A good encode has corroborating peers", so a single - suspiciously-small release among bigger ones (likely a bad encode) is never targeted. Run on - the gap-cut survivors so "comparable-score" holds. Disabled when outlier_frac <= 0.""" + """Drop a release whose GiB/h is a lone outlier below the surviving cluster: below + `outlier_frac x median(cluster GiB/h)`. Disabled when outlier_frac <= 0 or < 3 survivors.""" frac = self.cfg.outlier_frac if frac <= 0 or len(releases) < 3: return releases @@ -139,197 +192,100 @@ def drop_size_outliers(self, releases: list[dict], runtime_h: float) -> list[dic median = gbhs[mid] if len(gbhs) % 2 else (gbhs[mid - 1] + gbhs[mid]) / 2 return [r for r in releases if _release_gbh(r, runtime_h) >= frac * median] - def filter_by_score_gap(self, releases: list[dict]) -> list[dict]: - """Keep the top score cluster: sort desc, scan high->low, cut at the first consecutive - relative drop greater than score_gap. Negatives are always dropped.""" - nonneg = [r for r in releases if (r.get("customFormatScore") or 0) >= 0] - if not nonneg: - return [] - srt = sorted(nonneg, key=lambda r: -(r.get("customFormatScore") or 0)) - kept = [srt[0]] - for prev, cur in zip(srt, srt[1:], strict=False): - ps = prev.get("customFormatScore") or 0 - cs = cur.get("customFormatScore") or 0 - if ps > 0 and (ps - cs) / ps > self.cfg.score_gap: - break - kept.append(cur) - return kept - def apply_prefilters( - self, releases: list[dict], runtime_h: float, reference: Reference + self, + releases: list[dict], + runtime_h: float, + target_resolution: int | None, + current_score: int | None, ) -> tuple[list[dict], dict]: - """Run all pre-filters in order; return (kept, diag) with per-stage counts.""" + """Run the inclusion filters in order; return (kept, diag) with per-stage counts.""" diag: dict[str, object] = {"input": len(releases)} after_hard = eligible(releases) diag["after_hard_rejections"] = len(after_hard) - after_band = self.filter_by_size_band(after_hard, runtime_h, reference) + after_window, score_floor, window_tier = self._apply_score_window(after_hard, current_score) + diag["after_score_window"] = len(after_window) + diag["score_floor"] = score_floor + diag["window_tier"] = window_tier + after_res = self.filter_by_resolution(after_window, target_resolution) + diag["after_resolution"] = len(after_res) + after_band = self.filter_by_size_band(after_res, runtime_h) diag["after_size_band"] = len(after_band) - after_gap = self.filter_by_score_gap(after_band) - diag["after_score_gap"] = len(after_gap) - kept = self.drop_size_outliers(after_gap, runtime_h) + kept = self.drop_size_outliers(after_band, runtime_h) diag["after_outlier_drop"] = len(kept) - diag["inclusion"] = f"size band + gap-cut (>{self.cfg.score_gap:.0%} drop) + outlier drop" + diag["inclusion"] = ( + f"score >= {score_floor:,} (tier {window_tier})" + f" + target res + [floor,ceiling] band + lone-small drop" + ) return kept, diag - # ----- normalization ----- - - def normalize_score(self, s: float) -> float: - cfg = self.cfg - if cfg.score_norm == "logistic": - # S-curve centered at score_center. Profilarr scores bunch near the top (~950k) and - # fall off below, so a logistic puts the axis's resolution where releases actually - # compete, instead of wasting most of [0,1] on scores no release reaches. No hard - # cutoff: a library whose scores all sit near one value still gets spread out. - z = (s - cfg.score_center) / cfg.score_width - if z <= -60: - return 0.0 - if z >= 60: - return 1.0 - return 1.0 / (1.0 + math.exp(-z)) - if s >= cfg.score_ideal: - return 1.0 - if s <= cfg.score_anti_ideal: - return 0.0 - return (s - cfg.score_anti_ideal) / (cfg.score_ideal - cfg.score_anti_ideal) - - def normalize_size( - self, gbh: float, floor: float, lo: float, target: float, hi: float, ceiling: float - ) -> float: - """5-point trapezoid: 0 outside [floor, ceiling], rising to `size_shoulder` at lo, peaking - at 1.0 at target, falling back to `size_shoulder` at hi. The flat-ish [lo, hi] band is the - "good size" region for the profile; inside it score drives the pick, and the gentle slope to - target lets `target` placement nudge the preference. Unlike the old one-sided curve, a - too-small file IS penalized (it can be replaced by one nearer the band).""" - sh = self.cfg.size_shoulder - if gbh <= floor or gbh >= ceiling: - return 0.0 - if gbh < lo: - return sh * (gbh - floor) / (lo - floor) if lo > floor else sh - if gbh <= target: - return sh + (1 - sh) * (gbh - lo) / (target - lo) if target > lo else 1.0 - if gbh <= hi: - return 1.0 - (1 - sh) * (gbh - target) / (hi - target) if hi > target else 1.0 - return sh * (ceiling - gbh) / (ceiling - hi) if ceiling > hi else sh - - def _attrs( - self, score: float, res: int, gbh: float, size_gb: float, resolved: ResolvedProfile - ) -> dict: - floor, lo, target, hi, ceiling = self.reference_for(res, resolved.reference) - return { - "n_score": self.normalize_score(score or 0), - "n_size": self.normalize_size(gbh, floor, lo, target, hi, ceiling), - "raw": { - "score": score, - "resolution": res, - "gbh": gbh, - "size_gb": size_gb, - "reference": (floor, lo, target, hi, ceiling), - "target": target, - }, - } + # ----- scoring ----- - def attributes_for( - self, - release: dict, - runtime_h: float, - resolved: ResolvedProfile, - target_resolution: int | None = None, - ) -> dict: - """Normalized [0,1] attributes + raw values for one release. (target_resolution is accepted - for call-site symmetry but unused: resolution is a guard in decision.py, not an axis.)""" - size_bytes = release.get("size", 0) - return self._attrs( - release.get("customFormatScore", 0), - _release_resolution(release), - _release_gbh(release, runtime_h), - size_bytes / GB, - resolved, - ) - - def closeness(self, attrs: dict, weights: dict[str, float]) -> float: - """TOPSIS closeness in [0,1] over the two axes (score, size). 1 = ideal, 0 = anti-ideal.""" + def closeness(self, n_score: float, n_size: float, weights: dict[str, float]) -> float: + """TOPSIS closeness in [0,1] over the two axes. 1 = ideal (best score, smallest file).""" w = {"n_score": weights["score"], "n_size": weights["size"]} - d_ideal = math.sqrt(sum(w[k] * (1.0 - attrs[k]) ** 2 for k in w)) - d_anti = math.sqrt(sum(w[k] * attrs[k] ** 2 for k in w)) + a = {"n_score": n_score, "n_size": n_size} + d_ideal = math.sqrt(sum(w[k] * (1.0 - a[k]) ** 2 for k in w)) + d_anti = math.sqrt(sum(w[k] * a[k] ** 2 for k in w)) total = d_ideal + d_anti return 0.0 if total == 0 else d_anti / total - def _current_resolution(self, movie_file: dict) -> int: - """The library file's resolution bucket, read from its own `quality` block (Bluray-1080p / - WEBDL-2160p etc.) — the same nominal field candidates report, so the two are comparable. - This is reliable even for scope (2.40:1) content, whose raw pixel height is misleadingly - short. Returns 0 if the quality is unknown.""" - return int(((movie_file.get("quality") or {}).get("quality") or {}).get("resolution") or 0) - - def current_attributes( - self, - movie_file: dict, - runtime_h: float, - resolved: ResolvedProfile, - target_resolution: int | None = None, - ) -> dict | None: - """Normalized attributes for the existing library file (None if its score is unknown).""" - score = movie_file.get("customFormatScore") - if score is None: - return None - size = movie_file.get("size", 0) or 0 - size_gb = size / GB - gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 - res = self._current_resolution(movie_file) - return self._attrs(score, res, gbh, size_gb, resolved) - - def closeness_for_current_file( - self, - movie_file: dict, - runtime_h: float, - resolved: ResolvedProfile, - target_resolution: int | None = None, - ) -> tuple[float | None, dict]: - """Closeness for the existing library file (None if its score is unknown).""" - attrs = self.current_attributes(movie_file, runtime_h, resolved, target_resolution) - if attrs is None: - size = movie_file.get("size", 0) or 0 - size_gb = size / GB - gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 - return None, { - "score": None, - "resolution": self._current_resolution(movie_file), - "gbh": gbh, - "size_gb": size_gb, - } - return self.closeness(attrs, resolved.weights), attrs["raw"] - - # ----- scoring & picking ----- + def _attrs(self, score, res, gbh, size_gb, smin, smax, gmin, gmax) -> dict: + return { + "n_score": _norm(score or 0, smin, smax), + "n_size": _norm(gbh, gmin, gmax, invert=True), + "raw": {"score": score, "resolution": res, "gbh": gbh, "size_gb": size_gb}, + } - def score_candidates( + def score_pool( self, - releases: list[dict], + kept: list[dict], + current_file: dict | None, runtime_h: float, resolved: ResolvedProfile, - target_resolution: int | None = None, - ) -> tuple[list[tuple[dict, dict, float]], dict]: - """Pre-filter, then return (scored: [(release, attrs, closeness)], diag), sorted best - first by closeness (with deterministic tie-breaks).""" - kept, diag = self.apply_prefilters(releases, runtime_h, resolved.reference) - scored = [ - (r, a, self.closeness(a, resolved.weights)) - for r, a in ( - (r, self.attributes_for(r, runtime_h, resolved, target_resolution)) for r in kept + ) -> tuple[list[tuple[dict, dict, float]], dict | None]: + """Min-max normalize the kept candidates on both axes, score each by closeness (sorted + best-first), and place the current file on the SAME ranges. Returns (scored, current), + where `current` carries n_score/n_size/raw/closeness (None if there is no current score). + Ranges are built from candidates only, so the candidate ranking is current-independent.""" + rows = [ + ( + r, + r.get("customFormatScore"), + _release_gbh(r, runtime_h), + _release_resolution(r), + (r.get("size", 0) or 0) / GB, ) + for r in kept ] + scores = [s or 0 for _r, s, _g, _res, _sz in rows] + gbhs = [g for _r, _s, g, _res, _sz in rows] + smin, smax = (min(scores), max(scores)) if scores else (0, 0) + gmin, gmax = (min(gbhs), max(gbhs)) if gbhs else (0.0, 0.0) + + scored = [] + for r, s, g, res, sz in rows: + a = self._attrs(s, res, g, sz, smin, smax, gmin, gmax) + scored.append((r, a, self.closeness(a["n_score"], a["n_size"], resolved.weights))) scored.sort(key=lambda x: (-x[2], -(x[1]["raw"]["score"] or 0), x[1]["raw"]["gbh"])) - return scored, diag - - def select( - self, candidates: list[tuple[dict, dict, float]], resolved: ResolvedProfile - ) -> tuple[dict, dict, float] | None: - """Choose one candidate by the profile's pick method. `candidates` are assumed already - gated (every entry is a legal swap); ties break deterministically.""" - if not candidates: + + current = self._current_attrs(current_file, runtime_h, resolved, smin, smax, gmin, gmax) + return scored, current + + def _current_attrs(self, cf, runtime_h, resolved, smin, smax, gmin, gmax) -> dict | None: + if not cf: return None - if resolved.pick == "max_score": - return max(candidates, key=lambda x: (x[1]["raw"]["score"] or 0, -x[1]["raw"]["gbh"])) - if resolved.pick == "min_size": - return min(candidates, key=lambda x: (x[1]["raw"]["gbh"], -(x[1]["raw"]["score"] or 0))) - # topsis: already sorted best-first by score_candidates - return max(candidates, key=lambda x: x[2]) + score = cf.get("customFormatScore") + size_gb = (cf.get("size", 0) or 0) / GB + gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 + res = _release_resolution(cf) + if score is None: + return { + "n_score": None, + "n_size": None, + "closeness": None, + "raw": {"score": None, "resolution": res, "gbh": gbh, "size_gb": size_gb}, + } + a = self._attrs(score, res, gbh, size_gb, smin, smax, gmin, gmax) + a["closeness"] = self.closeness(a["n_score"], a["n_size"], resolved.weights) + return a diff --git a/optimizarr/features/optimizer/worker.py b/optimizarr/features/optimizer/worker.py index e3d2d0d..ba43572 100644 --- a/optimizarr/features/optimizer/worker.py +++ b/optimizarr/features/optimizer/worker.py @@ -226,15 +226,24 @@ def stop(self) -> None: def _refresh(self, ctx: _AppContext, now: datetime) -> None: adapter = ctx.adapter - adapter.refresh_profiles() - # Select on hasFile alone (not monitored): the optimizer improves the existing - # library, and the unmonitor feature deliberately strips monitoring once a file - # exists. The age gate is the optimizer's own min_age_days. - items = [ - it - for it in adapter.list_items() - if adapter.has_file(it) and age_ok(adapter, it, ctx.app_cfg, now) - ] + # Safe reconciliation: a failed or interrupted library fetch must NEVER clear the known + # item set (and we never prune state from the list anyway). On error, keep the previous + # items_by_id and retry on the next tick (last_refresh is left unchanged). + try: + adapter.refresh_profiles() + # Select on hasFile alone (not monitored): the optimizer improves the existing + # library, and the unmonitor feature deliberately strips monitoring once a file + # exists. The age gate is the optimizer's own min_age_days. + items = [ + it + for it in adapter.list_items() + if adapter.has_file(it) and age_ok(adapter, it, ctx.app_cfg, now) + ] + except Exception: + logger.exception( + "[%s] library refresh failed; keeping the previous item set", adapter.app + ) + return ctx.items_by_id = {adapter.item_id(it): it for it in items} # NB: ctx.evaluated is intentionally NOT cleared here. A refresh only updates the # candidate set (new items become pickable, removed ones drop); the current pass @@ -243,17 +252,19 @@ def _refresh(self, ctx: _AppContext, now: datetime) -> None: ctx.last_refresh = now logger.info("[%s] list refreshed: %d items with files", adapter.app, len(items)) - def _build_pool(self, ctx: _AppContext, now: datetime) -> None: - days = self.opt.reevaluate_after_days + def _build_pool(self, ctx: _AppContext) -> None: app = ctx.adapter.app + adapter = ctx.adapter def active(exclude_evaluated: bool) -> list[int]: - return [ - item_id - for item_id in ctx.items_by_id - if self.state.is_active(app, item_id, now, days) - and not (exclude_evaluated and item_id in ctx.evaluated) - ] + out: list[int] = [] + for item_id, item in ctx.items_by_id.items(): + if exclude_evaluated and item_id in ctx.evaluated: + continue + profile_name, _target_res = adapter.profile_for(item) + if self.state.is_active(app, item_id, profile_name, adapter.has_file(item)): + out.append(item_id) + return out ctx.pool = active(exclude_evaluated=True) if not ctx.pool and ctx.evaluated: @@ -285,9 +296,10 @@ def _process_one(self, ctx: _AppContext, item_id: int) -> None: logger.info("%s", format_decision(adapter.app, label, decision, self.dry_run)) if decision.action == "HOLD": - # Nothing better (incl. no viable release): drop it from the pool. - if not self.dry_run: - self.state.mark_satisfied(adapter.app, item_id) + # Satisfy (permanently) ONLY when the current imported file is optimal for its profile. + # An insufficient-candidates HOLD (decision.satisfy=False) is left in the pool to retry. + if decision.satisfy and not self.dry_run: + self.state.mark_satisfied(adapter.app, item_id, profile_name) return # ACT: grab, but do NOT record anything. If the download succeeds, the next @@ -462,7 +474,7 @@ def _process_app_once(self, ctx: _AppContext) -> bool: import_count = sum(1 for r in records if adapter.is_queue_item_pending_import(r)) if not ctx.pool: - self._build_pool(ctx, now) + self._build_pool(ctx) if not ctx.pool: return False diff --git a/optimizarr/features/unmonitor/config.py b/optimizarr/features/unmonitor/config.py index d03fe0f..ff3f4ff 100644 --- a/optimizarr/features/unmonitor/config.py +++ b/optimizarr/features/unmonitor/config.py @@ -25,28 +25,22 @@ class UnmonitorConfig: sonarr: UnmonitorAppConfig = field(default_factory=UnmonitorAppConfig) -def _parse_unmonitor_app( - raw: dict, default_release_type: str, allowed: set[str], where: str -) -> UnmonitorAppConfig: - release_type = str(raw.get("release_type", default_release_type)).strip() +def _parse_unmonitor_app(raw: dict, allowed: set[str], where: str) -> UnmonitorAppConfig: + release_type = str(raw["release_type"]).strip() if release_type not in allowed: raise ValueError(f"{where}.release_type={release_type!r} not in {sorted(allowed)}") return UnmonitorAppConfig( - days=int(raw.get("days", 30)), + days=int(raw["days"]), release_type=release_type, - require_cutoff_met=bool(raw.get("require_cutoff_met", True)), + require_cutoff_met=bool(raw["require_cutoff_met"]), ) def parse_unmonitor(raw: dict) -> UnmonitorConfig: return UnmonitorConfig( - enabled=bool(raw.get("enabled", True)), - cron_schedule=str(raw.get("cron_schedule", "0 4 * * *")).strip(), - run_on_start=bool(raw.get("run_on_start", True)), - radarr=_parse_unmonitor_app( - raw.get("radarr", {}), "digitalRelease", RADARR_RELEASE_TYPES, "unmonitor.radarr" - ), - sonarr=_parse_unmonitor_app( - raw.get("sonarr", {}), "airDateUtc", SONARR_RELEASE_TYPES, "unmonitor.sonarr" - ), + enabled=bool(raw["enabled"]), + cron_schedule=str(raw["cron_schedule"]).strip(), + run_on_start=bool(raw["run_on_start"]), + radarr=_parse_unmonitor_app(raw["radarr"], RADARR_RELEASE_TYPES, "unmonitor.radarr"), + sonarr=_parse_unmonitor_app(raw["sonarr"], SONARR_RELEASE_TYPES, "unmonitor.sonarr"), ) diff --git a/test-results/20260601-pool-pop-order-fix.md b/test-results/20260601-pool-pop-order-fix.md deleted file mode 100644 index 0c26a56..0000000 --- a/test-results/20260601-pool-pop-order-fix.md +++ /dev/null @@ -1,31 +0,0 @@ -# Test results: pool consumed from wrong end (pick_order inverted) - -Date: 2026-06-01 -Branch: main - -## Bug - -`size_desc` processed the smallest file first (every pick_order was reversed). - -`order_pool()` sorts correctly: `size_desc` returns `[biggest, ..., smallest]` with the -intended-first item at index 0. But `_process_app_once` consumed the pool with -`ctx.pool.pop()`, which removes the **last** element, so it took the smallest first. -Affected every non-random order (alphabetical/date/release too). - -## Fix - -`worker.py`: `ctx.pool.pop()` -> `ctx.pool.pop(0)` so the worker consumes the pool in the -processing order `order_pool` returns. (One pop per ~15s tick, so O(n) front-pop is irrelevant.) - -## Test - -Added `test_process_app_once_consumes_head_of_pool_first`: pool `[10, 20, 30]` -> after one -tick the head (10) is consumed, leaving `[20, 30]`. Fails with the old tail `pop()`. - -## Commands - -```sh -uv run ruff format optimizarr/ tests/ # unchanged -uv run ruff check optimizarr/ tests/ # All checks passed! -uv run pytest # 135 passed -``` diff --git a/test-results/20260602-logistic-score-normalization.md b/test-results/20260602-logistic-score-normalization.md deleted file mode 100644 index b773c5d..0000000 --- a/test-results/20260602-logistic-score-normalization.md +++ /dev/null @@ -1,66 +0,0 @@ -# Logistic score normalization (fix flat top-end of the score axis) - -Date: 2026-06-02 - -## Problem - -Linear `n_score` over `[0, 1,000,000]` made the score axis nearly flat where real releases live. -The gap-cut keeps only candidates within `score_gap` (20%) of the top, so survivors sit in -~`[800k, 1M]`, i.e. the top 0.20 of the axis, while size/resolution use the full `[0,1]`. -Effective discriminating power (`weight x range-used`) for the Quality preset was score ~0.13 vs -size ~0.20, so size out-voted score despite a 3x higher weight. This drove the 2160p Quality -downgrades (Dune: 949,600/46.2 GB wanting 920,000/14 GB). - -## Data (reports/training_data_radarr.jsonl, 52 movies, 9,684 releases) - -- No release reaches 1,000,000; real max is **952,600**, so linear wastes the top ~5% of the axis. -- Gap-cut survivors are **bimodal** (~720k tier and ~850-952k top tier), per-item competitive - spread ~245k (median). The axis needs resolution across ~700k..952k, not a hard window. -- Best-fit logistic to the survivor CDF: **center 805,000, width 85,000**. - -Top-end resolution (Dune is 949.6k vs 920k): - -| gap | linear /1M | logistic 805k/85k | -| --- | --- | --- | -| 950k - 920k | +0.030 | **+0.051** | - -## Why a fixed curve, not relative-to-candidate-list scoring - -Relative (within-set) scoring would make a release's closeness depend on what the indexers -returned that minute, breaking the invariant that closeness is a fixed function of the release -alone. That invariant is what guarantees no oscillation. Relative scoring can therefore churn and -even downgrade (a weak set `{700k, 740k}` normalizes 740k to 1.0 and can replace a 920k file). -The logistic keeps the "resolution where releases cluster" benefit while staying a fixed, -release-only transform, so non-oscillation still holds. - -## Change - -- `optimizarr/features/optimizer/topsis.py`: `normalize_score` gains a logistic branch. -- `optimizarr/features/optimizer/config.py`: `TopsisConfig` gains `score_norm` (default - `"logistic"`), `score_center` (805000), `score_width` (85000); `"linear"` still available. -- `optimizarr/defaults.toml`: documents and sets the above. -- `ALGORITHM.md`: scoring section updated. -- Quality preset retune from the prior change (2160p target 16 / bloat 30, weights - 0.73/0.15/0.12) is kept: the curve fixes score discrimination globally; the preset still sets - what 2160p file sizes the profile accepts. - -## Validation (real `decide()` via `default_topsis()`, "2160p Quality", Dune runtime 2.567 h) - -| outcome | scenario | notes | -| --- | --- | --- | -| HOLD | 949,600/46.2 GB vs 920,000/14 GB | the report; was ACT | -| HOLD | 949,600/46.2 GB vs 935,000/20.5 GB (~8 GiB/h) | small score drop refused | -| HOLD | 949,600/46.2 GB vs 949,600/20.5 GB | same-score shrink; Quality is quality-first | -| ACT | 920,000/30 GB vs 949,600/25 GB | genuine score upgrade, also smaller | -| ACT | 700,000/46 GB vs 949,600/25 GB | upgrade a poor file | -| ACT | 949,600/80 GB vs 949,600/40 GB | real bloat trim at equal score | -| HOLD | 924,600/30 GB vs 720,000/12 GB | low-tier downgrade now correctly refused | - -`ruff format` + `ruff check`: clean. `pytest -q`: 137 passed. - -## Tunables - -`score_center` / `score_width` in `[optimizer.topsis]`. Smaller width = steeper = more -resolution at the very top (920 vs 950) but flatter tier separation; larger = gentler. The -same-score-shrink HOLD above is a Quality choice (size weight 0.12); raise the Quality `size` -weight to re-enable free same-quality shrinks (the curve keeps score safe either way). diff --git a/test-results/20260602-quality-preset-retune.md b/test-results/20260602-quality-preset-retune.md deleted file mode 100644 index 9380a01..0000000 --- a/test-results/20260602-quality-preset-retune.md +++ /dev/null @@ -1,41 +0,0 @@ -# Quality preset retune: stop downgrading high-score 2160p files - -Date: 2026-06-02 - -## Problem - -The shipped **Quality** preset was downgrading large, near-perfect 2160p files. Reported case: -Dune (2021), current file score 949,600 / 46.2 GB (~17.9 GiB/h), optimizer wanted a -920,000 / 14 GB release. Generally 2160p Quality files were dropping from ~18 GiB/h to ~8. - -Root cause: at the top of the Profilarr score scale every good release normalizes to ~0.92-0.95 -(linear over `[0, 1,000,000]`), so the score axis is nearly flat and the old size weight (0.20) -plus a low 2160p band (`target 11 / bloat 20`) let the size axis dominate. A 46 GB file read as -near-bloat (`n_size = 0.236`) and almost any smaller in-band release looked like a big win. - -## Change ([defaults.toml](../optimizarr/defaults.toml)) - -| | weights (score/res/size) | 2160p floor/target/bloat | -| --- | --- | --- | -| before | 0.65 / 0.15 / 0.20 | 5 / 11 / 20 | -| after | 0.73 / 0.15 / 0.12 | 5 / 16 / 30 | - -Lower resolutions raised in step to keep the preset coherent: 1080p 1.5/7/14, 720p 0.5/2.5/6, -480p 0.25/1/3.5. Docs updated in [ALGORITHM.md](../ALGORITHM.md). - -## Validation (real `decide()` via `default_topsis()`, Dune runtime 2.567 h) - -| outcome | scenario | current closeness | best/pick | Δ | -| --- | --- | --- | --- | --- | -| HOLD | 949,600 / 46.2 GB vs 920,000 / 14 GB (the report) | 0.935 | (no legal swap) | - | -| HOLD | 949,600 / 46.2 GB vs 935,000 / 20.5 GB (~8 GiB/h) | 0.935 | (no legal swap) | - | -| ACT | 949,600 / 46.2 GB vs 949,600 / 20.5 GB (equal score, leaner) | 0.935 | 0.957 | +0.022 | -| ACT | 700,000 / 46 GB vs 1,000,000 / 30 GB (true upgrade) | 0.748 | 1.000 | +0.252 | -| ACT | 949,600 / 80 GB (~31 GiB/h) vs 949,600 / 40 GB (real bloat trim) | 0.720 | 0.957 | +0.237 | - -Net: score-losing downgrades of high-score 2160p files now HOLD; equal-or-better-score leaner -files and genuine bloat trims still ACT. - -## Test suite - -`pytest -q`: 137 passed. diff --git a/test-results/20260602-score-curve-refit-250movies.md b/test-results/20260602-score-curve-refit-250movies.md deleted file mode 100644 index c047b21..0000000 --- a/test-results/20260602-score-curve-refit-250movies.md +++ /dev/null @@ -1,65 +0,0 @@ -# Score curve re-fit on 250 movies + library-wide downgrade replay - -Date: 2026-06-02 - -## Why - -The logistic score curve and the Quality preset were first tuned on a 52-movie sample whose top -score maxed at 952,600. Re-harvested a larger random sample to confirm the fit, excluding -sub-zero releases from the analysis (they are dropped by the gap-cut before scoring anyway). - -## New dataset - -`tools/gather_training_data.py --app radarr --count 250` (seed 0, superset of the old sample). -250 movies, 46,635 releases (28,056 with score >= 0; 18,579 negative, excluded from the fit). - -- Release scores now reach **993,060** (vs 952,600 before), p99 = 986k. Confirms scores do climb - near the 1,000,000 "perfect" anchor; the small sample undersold the top. -- Gap-cut survivors (the releases `n_score` actually ranks), >= 0: 13,756. Still multi-tier - (~700k / ~860k / ~920-993k), competitive spread wide. - -## Re-fit - -Best-fit logistic to the survivor CDF: **center 792,500, width 87,500** — essentially the same -as the small-sample fit (805k/85k). Quality 2160p target lowered 16 -> 14 per operator preference -(still HOLDs the Dune case; now also takes equal-score leaner files). - -**Final adopted value: center 825,000, width 87,500.** A follow-up tool, `tools/score_curve_fit.py`, -re-fit the curve on three candidate populations: all releases >= 0 (degenerate, grid-railed), -post gap-cut (~790k, ~ the survivor fit), and the **real** candidates that survive the full -prefilters (eligible + per-item size band + score gap), which fit center **825,000**. The real -population is the right basis (it is exactly what `n_score` ranks); the size band strips the -low-score junk that survives the gap-cut, so the center sits higher. Practical impact is small -(31 score-downgrades either way in replay), but it gives marginally sharper top-tier -discrimination. Set in `[optimizer.topsis]` and the `TopsisConfig` fallback. Note: many movies -had their profiles changed recently, so current_file may be far from target; that only affects -the replay sanity-check, not the curve fit (which is over release scores). - -## Library-wide replay (the real test) - -Replayed `decide()` over all 250 movies with shipped defaults (allow_size_increase=true, -allow_quality_downgrade=true), counting ACT decisions where the picked release scores LOWER than -the current file (a score-downgrade). - -| normalization | ACT | score-downgrades | >= 20k drop | -| --- | --- | --- | --- | -| logistic (new default) | 140 | 31 | 1 | -| linear [0, 1M] (old) | 135 | 33 | 1 | - -Downgrades by profile (logistic): **28 of 31 are `2160p Efficient`** (size-leaning, by design), -2 are `1080p Efficient`, 1 is `2160p Balanced`. **Zero on any Quality profile** — the reported -regression is gone. - -The remaining Efficient downgrades are the feature working: ~0.5-1% score traded for roughly -halving the file, e.g. - -| profile | title | score | size | -| --- | --- | --- | --- | -| 2160p Efficient | The Batman (2022) | 989,040 -> 984,000 | 35.8 -> 16.6 GB | -| 2160p Efficient | Kingdom of the Planet of the Apes | 991,000 -> 980,000 | 25.5 -> 13.3 GB | -| 2160p Efficient | Spider-Man: No Way Home | 993,060 -> 987,000 | 27.8 -> 14.8 GB | - -## Checks - -`ruff format` + `ruff check`: clean. `pytest -q`: 137 passed. Dune (949,600/46.2 GB vs -920,000/14 GB) under `2160p Quality`: HOLD. diff --git a/test-results/20260603-2axis-trapezoid-size-model.md b/test-results/20260603-2axis-trapezoid-size-model.md deleted file mode 100644 index e85ba4c..0000000 --- a/test-results/20260603-2axis-trapezoid-size-model.md +++ /dev/null @@ -1,52 +0,0 @@ -# 2-axis scoring + 5-point trapezoid size model (Phase 2) - -Date: 2026-06-03 - -## What changed - -Replaced the 3-axis TOPSIS (score, resolution, size) + one-sided size curve with: - -- **Two axes** (score, size). Resolution is no longer scored: Profilarr folds it into the score and - a hard guard in decision.py already forbids dropping below the profile target. Weights are now - `{score, size}` summing to 1.0. -- **5-point trapezoid size band** `{floor, lo, target, hi, ceiling}` (renamed `bloat` -> `ceiling`). - `n_size` is 0 outside `[floor, ceiling]`, rises to `size_shoulder` (0.85) at lo, peaks at 1.0 at - target, falls back to the shoulder at hi. Inside `[lo, hi]` score drives the pick; `target` sits - at a per-profile size percentile (Compact ~P10, Efficient ~P30, Balanced ~P50, Quality ~P77, - Remux remux-range), so the five profiles aim at different sizes and pick different releases. -- **Outlier prefilter**: drop a candidate below `outlier_frac` (0.5) x the gap-cut cluster median - GiB/h ("a good encode has corroborating peers"). -- **No-inflate-at-score-loss invariant**: decision.py forbids grabbing a release that is bigger AND - lower-or-equal score. The old one-sided curve gave this for free; the peaked trapezoid does not, - so it is now explicit. A larger file is grabbed only on a genuine score upgrade. -- All presets pick by `topsis` (divergence comes from band placement, not the pick method); - `max_score`/`min_size` remain for overrides. - -## Why these choices (Phase 1 experiment, tools/size_band_lab.py) - -- **Relative percentile bands were rejected**: 1796 re-grabs in a resampled-set oscillation walk - vs **0** for absolute bands. Absolute bands + outlier prefilter is provably stable. -- Shoulder swept 0.6-0.9; 0.85 gives the best in-band score-driving without disturbing where the - size-leaning profiles land. - -## Validation (real engine over 250-movie set) - -- **Divergence** (distinct picks across the 5 presets, 2160p movies): 38 all-5, 22 of 4, 7 of 3, - 1 of 2, 1 of 1. -- **Score-downgrades under each item's own profile**: 19, all on size-leaning profiles (17 Efficient - 2160p, 1 Efficient 1080p, 1 Balanced) and **all shrink the file** (the intended score-for-size - trade). **Zero** on Quality or Remux. -- **Zero grow-at-lower-score swaps** (was 52 before the invariant; the worst was a 220k-point drop - to grow 1.9 -> 3.8 GB, now blocked). -- **Dune** (949,600 / 46.2 GB vs 920,000 / 14 GB under 2160p Quality): HOLD. -- Weights sweep confirmed raising score weight does NOT fix the grows (71 -> 63 only, and hurts - divergence) because the current files sit below floor (n_size 0); the invariant is the right fix. - -## Tests / tooling - -- `pytest -q`: 137 passed (incl. the no-oscillation convergence test and the restored - `test_bigger_at_no_score_gain_is_never_grabbed`). `ruff format` + `ruff check`: clean. -- Updated: topsis.py, config.py, decision.py, defaults.toml, ALGORITHM.md, README.md, - tools/weight_lab.py, tools/cutoff_viz.py. New: tools/size_band_lab.py. -- Reports: reports/size_band_lab_20260602_223602.md (model choice), - reports/score_curve_fit_20260602_162909.md (score curve). diff --git a/tests/test_config.py b/tests/test_config.py index d37868c..77e03f2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -40,11 +40,11 @@ def test_radarr_only_with_defaults(monkeypatch, tmp_path): um = config.unmonitor assert um.enabled is True - assert um.cron_schedule == "0 4 * * *" - assert um.run_on_start is True - assert um.radarr.days == 14 - assert um.radarr.release_type == "digitalRelease" - assert um.radarr.require_cutoff_met is True + assert um.cron_schedule + assert isinstance(um.run_on_start, bool) + assert um.radarr.days > 0 + assert um.radarr.release_type + assert isinstance(um.radarr.require_cutoff_met, bool) # per-app enabled is on by default; sonarr's app config is still parsed even with no conn assert config.optimizer.radarr.enabled is True @@ -178,23 +178,14 @@ def test_rejects_min_closeness_gain_out_of_range(monkeypatch, tmp_path): load_config(path) -def test_rejects_unknown_pick_method(monkeypatch, tmp_path): - monkeypatch.setenv("RADARR_URL", "http://x") - monkeypatch.setenv("RADARR_API_KEY", "k") - path = _write(tmp_path, '[optimizer.topsis.presets.Efficient]\npick = "lottery"\n') - with pytest.raises(ValueError, match="pick"): - load_config(path) - - -def test_rejects_reference_out_of_order(monkeypatch, tmp_path): +def test_rejects_size_bounds_out_of_order(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://x") monkeypatch.setenv("RADARR_API_KEY", "k") path = _write( tmp_path, - "[optimizer.topsis.presets.Balanced.reference]\n" - '"2160" = { floor = 3, lo = 5, target = 20, hi = 10, ceiling = 18 }\n', + '[optimizer.topsis.size_bounds]\n"2160" = { floor = 40, ceiling = 10 }\n', ) - with pytest.raises(ValueError, match="floor < lo <= target <= hi < ceiling"): + with pytest.raises(ValueError, match="floor < ceiling"): load_config(path) @@ -202,11 +193,10 @@ def test_optimizer_app_age_gate_defaults(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://x") monkeypatch.setenv("RADARR_API_KEY", "k") config = load_config(_write(tmp_path, "")) - assert config.optimizer.radarr.min_age_days == 14 - # Dual-gate by default: release date AND dateAdded both must pass. - assert config.optimizer.radarr.release_type == ["digitalRelease", "dateAdded"] - assert config.optimizer.sonarr.release_type == ["airDateUtc", "dateAdded"] - # New per-app flags default on. + assert config.optimizer.radarr.min_age_days >= 0 + assert config.optimizer.radarr.release_type # non-empty + assert config.optimizer.sonarr.release_type # non-empty + # Per-app flags default on. assert config.optimizer.radarr.ignore_completed_in_queue is True assert config.optimizer.radarr.auto_import_downgrades is True assert config.optimizer.sonarr.ignore_completed_in_queue is True @@ -312,7 +302,7 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): queue_max = 2 [optimizer.topsis] - score_gap = 0.30 + score_window = 80000 [optimizer.topsis.profiles."2160p Remux"] preset = "Remux" @@ -326,29 +316,30 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): t = config.optimizer.topsis assert config.optimizer.enabled is True assert config.optimizer.queue_max == 2 - assert t.score_gap == 0.30 + assert t.score_window == 80000 # user override + assert t.min_candidates > 0 # shipped presets survive the deep-merge assert {"Remux", "Quality", "Balanced", "Efficient", "Compact"} <= set(t.presets) - assert t.presets["Compact"].weights["size"] == 0.78 - assert t.presets["Compact"].pick == "topsis" - # per-preset 5-point size tables (floor, lo, target, hi, ceiling) - assert t.presets["Balanced"].reference[2160] == (5.0, 8.1, 9.3, 11.0, 20.0) - assert t.presets["Efficient"].reference[2160] == (4.0, 7.1, 7.8, 8.8, 15.0) - assert t.presets["Remux"].reference[2160] == (15.0, 28.0, 38.0, 60.0, 90.0) - # swap margin default - assert t.default_min_closeness_gain == 0.02 - assert t.presets["Balanced"].min_closeness_gain == 0.02 + assert 0 < t.presets["Compact"].weights["size"] < 1 + # shared per-resolution legitimacy bounds: floor < ceiling, both positive + floor_2160, ceil_2160 = t.size_bounds[2160] + assert 0 < floor_2160 < ceil_2160 + floor_1080, ceil_1080 = t.size_bounds[1080] + assert 0 < floor_1080 < ceil_1080 + # swap margin: valid range + assert 0 <= t.default_min_closeness_gain < 1 + assert 0 <= t.presets["Balanced"].min_closeness_gain < 1 # overrides parse as preset-ref or explicit weights assert t.profiles["2160p Remux"].preset == "Remux" custom_weights = t.profiles["Custom 1080p"].weights assert custom_weights is not None and custom_weights["size"] == 0.4 - assert t.default_preset == "Balanced" + assert t.default_preset in t.presets def test_optimizer_import_max_default_and_override(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://radarr:7878") monkeypatch.setenv("RADARR_API_KEY", "abc") - assert load_config(_write(tmp_path, "")).optimizer.import_max == 2 # default + assert load_config(_write(tmp_path, "")).optimizer.import_max >= 0 over = load_config(_write(tmp_path, "[optimizer]\nimport_max = 4\n")) assert over.optimizer.import_max == 4 diff --git a/tests/test_decision.py b/tests/test_decision.py index c1408a4..1c9a3ea 100644 --- a/tests/test_decision.py +++ b/tests/test_decision.py @@ -4,14 +4,15 @@ def _topsis() -> Topsis: - return Topsis(default_topsis()) + cfg = default_topsis() + cfg.min_candidates = 2 # decision tests use 2-3 candidates; pool-size is tested in test_topsis + return Topsis(cfg) -def _release(guid="g1", score=1_000_000, resolution=2160, size_gb=13.0): +def _release(guid="g1", score=900_000, resolution=2160, size_gb=20.0): return { "guid": guid, - "indexerId": 1, - "title": f"Movie.{resolution}p", + "title": f"Movie.{resolution}p.{guid}", "customFormatScore": score, "quality": {"quality": {"resolution": resolution}}, "size": int(size_gb * GB), @@ -19,7 +20,7 @@ def _release(guid="g1", score=1_000_000, resolution=2160, size_gb=13.0): } -def _file(score=200_000, resolution=1080, size_gb=30.0): +def _file(score=200_000, resolution=2160, size_gb=30.0): return { "id": 555, "customFormatScore": score, @@ -28,158 +29,82 @@ def _file(score=200_000, resolution=1080, size_gb=30.0): } -def test_format_decision_act_shows_current_and_pick(): - releases = [_release(score=1_000_000, resolution=2160, size_gb=13.0)] - d = decide( - _topsis(), - releases, - 2.0, - "2160p Quality", - 2160, - current_file=_file(score=200_000, resolution=1080), - ) +def test_act_on_clear_upgrade(): + # Current is a bloated low-score file; two good candidates exist -> ACT on the best. + rels = [_release("a", 1_000_000, 2160, 13.0), _release("b", 950_000, 2160, 18.0)] + d = decide(_topsis(), rels, 2.0, "2160p Quality", 2160, current_file=_file(200_000, 2160, 30.0)) + assert d.action == "ACT" and d.release is not None and d.release["guid"] == "a" msg = format_decision("radarr", "Movie (2024)", d, dry_run=True) - assert "would GRAB" in msg - assert "current:" in msg and "pick:" in msg - assert "profile=2160p Quality" in msg - assert "Δsize" in msg and "Δcloseness" in msg - - -def test_format_decision_hold_when_nothing_better(): - # Current already at the candidate's exact spec -> no legal transition -> HOLD. - releases = [_release(score=1_000_000, resolution=2160, size_gb=13.0)] - current = _file(score=1_000_000, resolution=2160, size_gb=13.0) - d = decide(_topsis(), releases, 2.0, "2160p Quality", 2160, current_file=current) - assert d.action == "HOLD" - msg = format_decision("radarr", "Movie (2024)", d, dry_run=False) - assert "HOLD" in msg - assert "nothing better" in msg + assert "would GRAB" in msg and "Δsize" in msg -def test_decide_hold_when_no_candidates(): - d = decide(_topsis(), [], 2.0, None, None, current_file=_file()) - assert d.action == "HOLD" - assert "no viable candidate" in d.reason +def test_too_few_candidates_holds_without_satisfying(): + d = decide(_topsis(), [_release()], 2.0, "2160p Quality", 2160, current_file=_file()) + assert d.action == "HOLD" and d.satisfy is False -def test_decide_act_on_clear_upgrade(): - # Current is a bloated 1080p low-score file; candidate is a clean 2160p high-score (res up). - releases = [_release(score=1_000_000, resolution=2160, size_gb=13.0)] - d = decide( - _topsis(), - releases, - 2.0, - "2160p Quality", - 2160, - current_file=_file(score=200_000, resolution=1080), - ) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "g1" +def test_hold_and_satisfy_when_current_optimal(): + cur = _file(1_000_000, 2160, 6.0) # best score, small + rels = [_release("same", 1_000_000, 2160, 6.0), _release("worse", 900_000, 2160, 25.0)] + d = decide(_topsis(), rels, 2.0, "2160p Quality", 2160, current_file=cur) + assert d.action == "HOLD" and d.satisfy is True -def test_decide_act_smaller_at_equal_score(): - # Same res + score, meaningfully smaller -> a free size win for any profile. - current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h - smaller = _release(guid="lean", score=900_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h - d = decide(_topsis(), [smaller], 2.0, "2160p Efficient", 2160, current_file=current) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "lean" +def test_efficient_picks_smaller_quality_picks_higher_score(): + # Same candidate set, different profile -> different pick (the relative model's whole point). + cur = _file(900_000, 2160, 24.0) # 12 GiB/h + small = _release("small", 927_000, 2160, 15.2) # ~7.6 GiB/h, slightly lower score + big = _release("big", 950_600, 2160, 24.9) # ~12.5 GiB/h, top score + eff = decide(_topsis(), [small, big], 2.0, "2160p Efficient", 2160, current_file=cur) + qual = decide(_topsis(), [small, big], 2.0, "2160p Quality", 2160, current_file=cur) + assert eff.action == "ACT" and eff.release["guid"] == "small" + assert qual.action == "ACT" and qual.release["guid"] == "big" -def test_decide_hold_on_bigger_file_without_score_gain(): - # Bigger at same res + same score must never be grabbed. - current = _file(score=900_000, resolution=2160, size_gb=13.0) - bigger = _release(guid="big", score=900_000, resolution=2160, size_gb=24.0) - d = decide(_topsis(), [bigger], 2.0, "2160p Efficient", 2160, current_file=current) +def test_pick_never_lower_score_and_bigger_than_current(): + cur = _file(900_000, 2160, 8.0) # already small and decent + # Only worse-on-both candidates -> must HOLD, never grab a lower-score bigger file. + rels = [_release("x", 850_000, 2160, 20.0), _release("y", 800_000, 2160, 25.0)] + d = decide(_topsis(), rels, 2.0, "2160p Quality", 2160, current_file=cur) assert d.action == "HOLD" - assert d.reason == "nothing better" - - -def test_decide_efficient_pulls_toward_band_remux_does_not(): - # A release nearer Efficient's band (lower bitrate) at a slightly lower score raises closeness - # for Efficient (size-leaning, band ~7.8 GiB/h); Remux refuses it (8 GiB/h is below Remux's - # floor, so it is dropped before scoring). - current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h, above Efficient band - leaner = _release(guid="lean", score=850_000, resolution=2160, size_gb=16.0) # 8 GiB/h, in band - d_eff = decide(_topsis(), [leaner], 2.0, "2160p Efficient", 2160, current_file=current) - assert d_eff.action == "ACT" - d_remux = decide(_topsis(), [leaner], 2.0, "2160p Remux", 2160, current_file=current) - assert d_remux.action == "HOLD" - - -def test_decide_compact_picks_near_its_band_not_the_smallest(): - # Sweet-spot model: Compact aims at its band (~P10, 6.8 GiB/h at 2160), not the absolute - # smallest. A near-peak release beats a tinier one that has dropped below the band. - current = _file(score=900_000, resolution=2160, size_gb=24.0) # 12 GiB/h, above the band - near = _release(guid="near", score=900_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h, in band - tiny = _release(guid="tiny", score=880_000, resolution=2160, size_gb=8.0) # 4 GiB/h, below band - d = decide(_topsis(), [near, tiny], 2.0, "Compact", 2160, current_file=current) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "near" -def test_decide_drops_bigger_releases_when_size_increase_disallowed(): - current = _file(score=400_000, resolution=2160, size_gb=20.0) - bigger = _release(guid="big", score=1_000_000, resolution=2160, size_gb=30.0) - smaller = _release(guid="small", score=900_000, resolution=2160, size_gb=13.0) +def test_allow_size_increase_false_drops_bigger(): + cur = _file(400_000, 2160, 20.0) + bigger = _release("big", 1_000_000, 2160, 30.0) # dropped (> current) + small = _release("small", 900_000, 2160, 13.0) + mid = _release("mid", 850_000, 2160, 18.0) d = decide( _topsis(), - [bigger, smaller], + [bigger, small, mid], 2.0, "2160p Quality", 2160, - current_file=current, + current_file=cur, allow_size_increase=False, ) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "small" + assert d.action == "ACT" and d.release["guid"] == "small" # best of the two survivors -def test_decide_drops_lower_score_releases_when_downgrade_disallowed(): - current = _file(score=800_000, resolution=2160, size_gb=28.0) - higher = _release(guid="hi", score=1_000_000, resolution=2160, size_gb=22.0) - lower = _release(guid="lo", score=700_000, resolution=2160, size_gb=10.0) +def test_allow_quality_downgrade_false_drops_lower_score(): + cur = _file(800_000, 2160, 28.0) + higher = _release("hi", 1_000_000, 2160, 22.0) + mid = _release("mid", 850_000, 2160, 15.0) + lower = _release("lo", 700_000, 2160, 10.0) # dropped (< current score) d = decide( _topsis(), - [higher, lower], + [higher, mid, lower], 2.0, "2160p Quality", 2160, - current_file=current, + current_file=cur, allow_quality_downgrade=False, ) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "hi" - - -def test_decide_hold_when_current_already_good(): - releases = [_release(score=1_000_000, resolution=2160, size_gb=13.0)] - current = _file(score=1_000_000, resolution=2160, size_gb=13.0) - d = decide(_topsis(), releases, 2.0, "2160p Quality", 2160, current_file=current) - assert d.action == "HOLD" - - -def test_decide_scope_4k_not_phantom_upgraded_to_same_class(): - # Current is a 2.39:1 scope 4K file: its quality block says 2160p, even though the mediaInfo - # pixel height is 1608. A same-score 2160p candidate that is slightly BIGGER must NOT be - # grabbed: the current file's own quality (2160) makes resolution equal, so there is no - # closeness gain. (Regression: reading the 1608 pixel height looked like a 1608->2160 upgrade - # and justified the swap.) - current = { - "id": 7, - "customFormatScore": 924_600, - "size": int(12.4 * GB), # 6.2 GiB/h at 2h - "quality": {"quality": {"resolution": 2160}}, - "mediaInfo": {"resolution": "3840x1608"}, - } - bigger_same_score = _release(guid="scope", score=924_600, resolution=2160, size_gb=12.8) - d = decide(_topsis(), [bigger_same_score], 2.0, "2160p Quality", 2160, current_file=current) - assert d.action == "HOLD" + assert d.action == "ACT" and d.release["guid"] == "hi" -def test_decide_unknown_current_score_treated_as_upgrade(): - # current file with no customFormatScore -> any viable candidate is an improvement. - current = {"id": 9, "size": int(30 * GB), "mediaInfo": {"resolution": "3840x2160"}} - releases = [_release(score=900_000, resolution=2160, size_gb=13.0)] - d = decide(_topsis(), releases, 2.0, "2160p Quality", 2160, current_file=current) +def test_unknown_current_score_treated_as_upgrade(): + cur = {"id": 1, "size": int(20.0 * GB), "quality": {"quality": {"resolution": 2160}}} + rels = [_release("a", 900_000, 2160, 10.0), _release("b", 800_000, 2160, 12.0)] + d = decide(_topsis(), rels, 2.0, "2160p Quality", 2160, current_file=cur) assert d.action == "ACT" diff --git a/tests/test_state.py b/tests/test_state.py index 837036e..269871c 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,5 +1,3 @@ -from datetime import UTC, datetime, timedelta - from optimizarr.features.optimizer.state import SATISFIED, StateManager @@ -12,26 +10,31 @@ def test_missing_file_starts_empty(tmp_path): assert m.get("radarr", 1) is None -def test_mark_satisfied_and_persist(tmp_path): +def test_mark_satisfied_persists_profile(tmp_path): path = tmp_path / "state.json" m = StateManager(str(path)) - m.mark_satisfied("radarr", 42) + m.mark_satisfied("radarr", 42, "2160p Quality") assert path.exists() reloaded = StateManager(str(path)) entry = reloaded.get("radarr", 42) assert entry is not None assert entry.status == SATISFIED + assert entry.profile == "2160p Quality" -def test_is_active_lifecycle(tmp_path): +def test_is_active_one_and_done(tmp_path): m = _mgr(tmp_path) - now = datetime.now(UTC) # marks stamp wall-clock time; base offsets on real now - # Unprocessed -> active - assert m.is_active("radarr", 1, now, reevaluate_after_days=30) + # Unprocessed -> active. + assert m.is_active("radarr", 1, "2160p Quality", has_file=True) + + # Satisfied for its profile, with a file -> not active (one-and-done, no time re-eval). + m.mark_satisfied("radarr", 2, "2160p Quality") + assert not m.is_active("radarr", 2, "2160p Quality", has_file=True) + + # Profile changed -> active again (the optimal pick depends on the profile). + assert m.is_active("radarr", 2, "2160p Efficient", has_file=True) - # Satisfied within window -> not active; past window -> active again - m.mark_satisfied("radarr", 2) - assert not m.is_active("radarr", 2, now + timedelta(days=10), reevaluate_after_days=30) - assert m.is_active("radarr", 2, now + timedelta(days=31), reevaluate_after_days=30) + # File removed -> active again (needs a fresh grab). + assert m.is_active("radarr", 2, "2160p Quality", has_file=False) diff --git a/tests/test_swap.py b/tests/test_swap.py index 5e40b6b..8f36e42 100644 --- a/tests/test_swap.py +++ b/tests/test_swap.py @@ -1,20 +1,27 @@ -"""Tests for the swap rule (closeness-gain + resolution guard) and its no-oscillation guarantee.""" +"""Invariants of the relative model: the forbidden quadrant never happens, and the two HOLD kinds. + +The headline guarantee is no longer "no oscillation via a closeness margin" (one-and-done state +handles that) but the dominance invariant: a grabbed pick is never BOTH lower-score AND bigger than +the current file, because a worse-on-both candidate has a lower closeness than the current file and +the decision only ACTs when a candidate beats it. +""" import random from optimizarr.features.optimizer.config import default_topsis -from optimizarr.features.optimizer.decision import decide, resolution_ok, swap_allowed +from optimizarr.features.optimizer.decision import decide from optimizarr.features.optimizer.topsis import GB, Topsis CFG = default_topsis() +CFG.min_candidates = 2 # swap tests use 2-candidate pools; pool-size is tested in test_topsis T = Topsis(CFG) PRESETS = ("Remux", "Quality", "Balanced", "Efficient", "Compact") -def _release(score, res, size_gb, guid="g"): +def _release(score, size_gb, res=2160, guid="g"): return { "guid": guid, - "title": f"{res}p {size_gb}GB", + "title": f"{res}p {size_gb}GB {guid}", "customFormatScore": score, "quality": {"quality": {"resolution": res}}, "size": int(size_gb * GB), @@ -22,7 +29,7 @@ def _release(score, res, size_gb, guid="g"): } -def _file(score, res, size_gb): +def _file(score, size_gb, res=2160): return { "id": 1, "customFormatScore": score, @@ -31,108 +38,49 @@ def _file(score, res, size_gb): } -# ----- resolution guard ----- - - -def test_resolution_ok_blocks_drop_below_target(): - assert not resolution_ok(2160, 1080, 2160) # drop below target - assert resolution_ok(1080, 2160, 2160) # upgrade toward target - assert resolution_ok(2160, 2160, 2160) - - -def test_resolution_ok_allows_drop_to_a_leaner_target(): - assert resolution_ok(2160, 1080, 1080) # leaner profile: dropping to its target is fine - assert not resolution_ok(2160, 720, 1080) # but never below the target - - -def test_resolution_ok_no_target_means_no_downgrade(): - assert resolution_ok(1080, 2160, None) - assert not resolution_ok(2160, 1080, None) - - -# ----- closeness-gain rule ----- - - -def test_swap_requires_closeness_margin(): - assert swap_allowed(0.50, 0.55, 2160, 2160, 2160, 0.02) - assert not swap_allowed(0.50, 0.51, 2160, 2160, 2160, 0.02) # below the margin - assert not swap_allowed(0.50, 0.50, 2160, 2160, 2160, 0.02) # equal - - -def test_swap_unknown_current_is_an_upgrade_but_still_guards_resolution(): - assert swap_allowed(None, 0.1, 2160, 2160, 2160, 0.02) - assert not swap_allowed(None, 0.9, 2160, 1080, 2160, 0.02) # res guard still applies - - -def test_swap_resolution_guard_overrides_a_big_gain(): - assert not swap_allowed(0.1, 0.99, 2160, 1080, 2160, 0.02) - - -# ----- the "no senseless swap" property falls out of closeness ----- - - -def test_bigger_at_no_score_gain_is_never_grabbed(): - """A bigger file with a lower-OR-equal score is never grabbed, for any preset and any current - size. The peaked trapezoid no longer guarantees this for free (a bigger, nearer-the-band - release can out-score a too-small current file), so decide() enforces it explicitly: you only - accept a larger file when it is a genuine score upgrade.""" - rnd = random.Random(7) +def test_pick_is_never_lower_score_and_bigger(): + """The forbidden quadrant: across random pools and presets, a grabbed pick is never both + lower-score AND bigger than the current file.""" + rnd = random.Random(11) for profile in PRESETS: - for _ in range(400): - cur_score = rnd.randint(0, 1_000_000) - cur_gbh = round(rnd.uniform(0.6, 12.0), 2) # anywhere, including below the band - cand_score = rnd.randint(0, cur_score) # <= current - cand_gbh = round(rnd.uniform(cur_gbh + 0.5, 22.0), 2) # strictly bigger - cur = _file(cur_score, 2160, cur_gbh * 2) # runtime 2h: gbh = size_gb / 2 - cand = _release(cand_score, 2160, cand_gbh * 2) - d = decide(T, [cand], 2.0, f"2160p {profile}", 2160, current_file=cur) - assert d.action == "HOLD", (profile, cur_score, cur_gbh, cand_score, cand_gbh) - - -# ----- the headline guarantee: convergence, no oscillation ----- - - -def test_optimizer_converges_no_oscillation_on_random_pools(): - """Iterate decide -> grab -> re-evaluate on random static pools. Closeness must strictly - increase on every grab (so a file is never revisited) and the walk must reach HOLD.""" - rnd = random.Random(20260601) - for profile in PRESETS: - rp = T.resolve_profile(f"2160p {profile}") - gain = rp.min_closeness_gain - for _ in range(300): + for _ in range(600): pool = [ - _release( - rnd.randint(0, 1_000_000), - rnd.choice([720, 1080, 2160]), - round(rnd.uniform(0.5, 60.0), 1), - guid=f"g{i}", - ) + _release(rnd.randint(0, 1_000_000), round(rnd.uniform(3.0, 30.0), 1), guid=f"g{i}") for i in range(rnd.randint(1, 8)) ] - start = rnd.choice(pool) - cur = _file( - start["customFormatScore"], - start["quality"]["quality"]["resolution"], - start["size"] / GB, - ) - prev_clo = None - for _step in range(80): # closeness rises >= gain each grab, so <= 1/gain grabs - d = decide(T, pool, 2.0, f"2160p {profile}", 2160, current_file=cur) - if d.action == "HOLD": - break - assert d.pick is not None and d.current is not None and d.release is not None - pick_clo = d.pick["closeness"] - cur_clo = d.current["closeness"] - if cur_clo is not None: - assert pick_clo >= cur_clo + gain - 1e-9, (profile, cur_clo, pick_clo) - if prev_clo is not None: - assert pick_clo >= prev_clo - 1e-9 # monotonic across the whole walk - prev_clo = pick_clo - rel = d.release - cur = _file( - rel["customFormatScore"], - rel["quality"]["quality"]["resolution"], - rel["size"] / GB, - ) - else: - raise AssertionError(f"{profile}: did not converge (possible oscillation)") + cur = _file(rnd.randint(0, 1_000_000), round(rnd.uniform(3.0, 30.0), 1)) + d = decide(T, pool, 2.0, f"2160p {profile}", 2160, current_file=cur) + if d.action != "ACT": + continue + cur_gb = cur["size"] / GB + lower = (d.pick["score"] or 0) < (cur["customFormatScore"] or 0) + bigger = d.pick["size_gb"] > cur_gb + 1e-6 + assert not (lower and bigger), (profile, cur["customFormatScore"], d.pick) + + +def test_too_few_candidates_holds_without_satisfying(): + # One candidate (< min_candidates) -> HOLD, not satisfied (retry later). + d = decide( + T, [_release(900_000, 8.0)], 2.0, "2160p Efficient", 2160, current_file=_file(800_000, 20.0) + ) + assert d.action == "HOLD" and d.satisfy is False + assert "too few" in d.reason + + +def test_current_optimal_holds_and_satisfies(): + # Current file is the best possible (highest score AND smallest) -> HOLD and satisfy. + cur = _file(1_000_000, 6.0) + pool = [_release(1_000_000, 6.0, guid="same"), _release(900_000, 20.0, guid="worse")] + d = decide(T, pool, 2.0, "2160p Quality", 2160, current_file=cur) + assert d.action == "HOLD" and d.satisfy is True + + +def test_unknown_current_score_acts_on_any_candidate(): + cur = { + "id": 1, + "size": int(20.0 * GB), + "quality": {"quality": {"resolution": 2160}}, + } # no score + pool = [_release(900_000, 8.0), _release(800_000, 10.0)] + d = decide(T, pool, 2.0, "2160p Quality", 2160, current_file=cur) + assert d.action == "ACT" diff --git a/tests/test_topsis.py b/tests/test_topsis.py index 40db3c6..cd8a526 100644 --- a/tests/test_topsis.py +++ b/tests/test_topsis.py @@ -1,16 +1,12 @@ -from dataclasses import replace - -import pytest - from optimizarr.features.optimizer.config import default_topsis -from optimizarr.features.optimizer.topsis import GB, Topsis, eligible +from optimizarr.features.optimizer.topsis import GB, Topsis, _norm, _score_floor_tier, eligible def _topsis() -> Topsis: return Topsis(default_topsis()) -def _release(score=900_000, resolution=2160, size_gb=10.0, rejections=None, temp=False): +def _release(score=900_000, size_gb=20.0, resolution=2160, rejections=None, temp=False): return { "customFormatScore": score, "quality": {"quality": {"resolution": resolution}}, @@ -32,135 +28,138 @@ def test_eligible_drops_blocklisted_and_temp(): assert len(keep) == 1 -def test_size_band_drops_below_floor_and_above_ceiling(): - t = _topsis() - ref = t.resolve_profile("2160p Balanced").reference # 2160 -> (5.0, 8.1, 9.3, 11.0, 20.0) - fake = _release(resolution=2160, size_gb=4.0) # 2.0 GiB/h < floor 5.0 - real = _release(resolution=2160, size_gb=20.0) # 10 GiB/h, in band - bloated = _release(resolution=2160, size_gb=44.0) # 22 GiB/h > ceiling 20 - kept = t.filter_by_size_band([fake, real, bloated], 2.0, ref) - assert kept == [real] - - -def test_normalize_size_trapezoid(): - t = _topsis() # size_shoulder = 0.85 - f, lo, tg, hi, c = 4.0, 8.0, 10.0, 12.0, 20.0 - assert t.normalize_size(4.0, f, lo, tg, hi, c) == 0.0 # at floor - assert t.normalize_size(3.0, f, lo, tg, hi, c) == 0.0 # below floor -> too small, penalized - assert t.normalize_size(8.0, f, lo, tg, hi, c) == pytest.approx(0.85) # at lo = shoulder - assert t.normalize_size(10.0, f, lo, tg, hi, c) == 1.0 # at target = peak - assert t.normalize_size(12.0, f, lo, tg, hi, c) == pytest.approx(0.85) # at hi = shoulder - assert t.normalize_size(20.0, f, lo, tg, hi, c) == 0.0 # at ceiling - assert t.normalize_size(25.0, f, lo, tg, hi, c) == 0.0 # above ceiling - assert t.normalize_size(6.0, f, lo, tg, hi, c) == pytest.approx(0.425) # midway floor->lo - assert t.normalize_size(9.0, f, lo, tg, hi, c) == pytest.approx(0.925) # midway lo->target - - -def test_normalize_size_degenerate_zero_width_peak(): +def test_norm_minmax_and_degenerate(): + assert _norm(5, 0, 10) == 0.5 + assert _norm(5, 0, 10, invert=True) == 0.5 + assert _norm(20, 0, 10) == 1.0 # clamped + assert _norm(-5, 0, 10) == 0.0 # clamped + assert _norm(7, 7, 7) == 1.0 # degenerate range -> non-discriminating + + +def test_score_floor_tier_three_tiers(): + # Tier 1: enough candidates at or above max-score_window. + scores = list(range(950_000, 950_000 + 6 * 10_000, 10_000)) # 6 in [950k, 1M] + floor, tier = _score_floor_tier(scores, current_score=800_000, score_window=100_000, min_pool=6) + assert tier == 1 + assert floor == max(scores) - 100_000 + + # Tier 2: too few at top, but enough when expanded down to current_score. + # max=990k, Tier-1 floor=890k; only top 3 are >= 890k (3 < 6). + # current=860k; Tier-2 floor=860k; all 8 (3+5) are >= 860k (8 >= 6) -> Tier 2 fires. + top = [990_000, 970_000, 950_000] # 3 releases above Tier-1 floor 890k + mid = [885_000, 880_000, 875_000, 870_000, 865_000] # 5 releases below 890k, above 860k + floor2, tier2 = _score_floor_tier( + top + mid, current_score=860_000, score_window=100_000, min_pool=6 + ) + assert tier2 == 2 + assert floor2 == 860_000 + + # Tier 3: sparse everywhere, falls all the way through. + few = [1_000_000, 800_000, 700_000] + floor3, tier3 = _score_floor_tier(few, current_score=900_000, score_window=100_000, min_pool=6) + assert tier3 == 3 + assert floor3 == max(0, 900_000 - 100_000) + + +def test_score_window_tier1_tight_pool(): + # With enough top candidates, Tier 1 fires and the window is anchored at the top. + t = _topsis() # score_window=100000, min_candidates=6 + # 8 releases all within score_window of 1M (floor 900k), current far below at 500k. + rels = [_release(score=1_000_000 - i * 5_000) for i in range(8)] # 1M down to 965k + kept = t.filter_by_score_window(rels, current_score=500_000) + # Tier 1 floor = 1M - 100k = 900k; all 8 are >= 900k. + assert len(kept) == 8 + assert all(r["customFormatScore"] >= 900_000 for r in kept) + + +def test_score_window_tier2_expands_to_current(): t = _topsis() - # lo == target == hi: a sharp peak, shoulders still ramp to floor/ceiling - f, lo, tg, hi, c = 4.0, 9.0, 9.0, 9.0, 16.0 - assert t.normalize_size(9.0, f, lo, tg, hi, c) == 1.0 - assert t.normalize_size(6.5, f, lo, tg, hi, c) == pytest.approx(0.85 * (6.5 - 4) / (9 - 4)) - assert t.normalize_size(12.5, f, lo, tg, hi, c) == pytest.approx(0.85 * (16 - 12.5) / (16 - 9)) + # Only 3 releases above the Tier-1 floor (900k); 6 more clustered just below current (880k). + top = [_release(score=s) for s in [1_000_000, 990_000, 980_000]] + mid = [_release(score=880_000 + i * 1_000) for i in range(6)] # 880k-885k + kept = t.filter_by_score_window(top + mid, current_score=880_000) + # Tier 1: floor=900k, 3 survive < 6; Tier 2: floor=880k, 9 survive >= 6. + assert len(kept) == 9 + assert all(r["customFormatScore"] >= 880_000 for r in kept) -def test_score_gap_keeps_cluster_drops_tail_and_negatives(): - t = _topsis() # default score_gap = 0.20 +def test_score_window_tier3_fallback_and_negatives(): + # With too few candidates at every tier, Tier 3 fires (full budget). + t = _topsis() rels = [ _release(score=1_000_000), - _release(score=950_000), - _release(score=930_000), - _release(score=200_000), # 930k -> 200k is a >20% drop: the tail - _release(score=-50), # negatives always dropped + _release(score=905_000), + _release(score=850_000), # below Tier-1 floor 900k and below Tier-2 floor 1M + _release(score=-5), # negative: always dropped ] - kept = t.filter_by_score_gap(rels) - scores = sorted((r["customFormatScore"] for r in kept), reverse=True) - assert scores == [1_000_000, 950_000, 930_000] + # Only 3 non-negative candidates total -> all tiers fail min_candidates=6 -> Tier 3 + kept = t.filter_by_score_window(rels, current_score=1_000_000) + assert sorted(r["customFormatScore"] for r in kept) == [905_000, 1_000_000] + # Tier 3 floor = max(0, 1M-100k) = 900k -> 850k is dropped, 905k and 1M kept. - -def test_resolve_profile_matches_preset_by_name(): - t = _topsis() - rp = t.resolve_profile("1080p Efficient") - assert rp.weights == t.cfg.presets["Efficient"].weights - assert rp.reference == t.cfg.presets["Efficient"].reference - assert rp.pick == "topsis" + # No current_score: Tier 2 is skipped, Tier 3 floor = 0. + kept_no_cur = t.filter_by_score_window(rels, current_score=None) + assert sorted(r["customFormatScore"] for r in kept_no_cur) == [850_000, 905_000, 1_000_000] -def test_resolve_profile_falls_back_to_default_preset(): +def test_filter_by_resolution_keeps_only_target(): t = _topsis() - rp = t.resolve_profile("Something Unmatched") - assert rp.weights == t.cfg.presets[t.cfg.default_preset].weights - + rels = [_release(resolution=2160), _release(resolution=1080), _release(resolution=720)] + assert [_["quality"]["quality"]["resolution"] for _ in t.filter_by_resolution(rels, 2160)] == [ + 2160 + ] + assert len(t.filter_by_resolution(rels, None)) == 3 # no target -> keep all -def test_resolve_profile_override_reference_pick_and_gain(): - cfg = default_topsis() - from optimizarr.features.optimizer.config import ProfileOverride - cfg.profiles["Special"] = ProfileOverride( - preset="Efficient", - pick="min_size", - reference={2160: (3.0, 5.0, 6.0, 8.0, 11.0)}, - min_closeness_gain=0.05, - ) - rp = Topsis(cfg).resolve_profile("Special") - assert rp.pick == "min_size" - assert rp.reference == {2160: (3.0, 5.0, 6.0, 8.0, 11.0)} - assert rp.min_closeness_gain == 0.05 - assert rp.weights == cfg.presets["Efficient"].weights # inherited +def test_filter_by_size_band_drops_below_floor_and_above_ceiling(): + t = _topsis() # 2160 bounds (3.0, 30.0) + fake = _release(size_gb=2.0) # 1.0 GiB/h < floor 3.0 + real = _release(size_gb=18.0) # 9.0 GiB/h, in band + bloat = _release(size_gb=80.0) # 40 GiB/h > ceiling 30.0 + assert t.filter_by_size_band([fake, real, bloat], 2.0) == [real] -def test_score_candidates_orders_by_closeness(): - t = _topsis() - rp = t.resolve_profile("2160p Quality") - good = _release(score=1_000_000, resolution=2160, size_gb=23.0) # 11.5 GiB/h = Quality target - weak = _release(score=950_000, resolution=1080, size_gb=14.0) # lower res, 7 GiB/h - scored, diag = t.score_candidates([weak, good], 2.0, rp, target_resolution=2160) - assert scored[0][0] is good - assert scored[0][2] >= scored[1][2] - assert diag["input"] == 2 - - -def test_compact_aims_at_its_band_not_the_smallest(): - # Sweet-spot model: Compact targets its band (~P10), not the absolute smallest. A near-peak - # release with a strong score beats a smaller one that has fallen below the band. +def test_bounds_for_nearest_below(): t = _topsis() - rp = t.resolve_profile("Compact") # 2160 band floor 3.5, lo 5.9, target 6.8, hi 7.6, ceiling 12 - below = _release(score=850_000, resolution=2160, size_gb=9.0) # 4.5 GiB/h, below lo - inband = _release(score=1_000_000, resolution=2160, size_gb=13.0) # 6.5 GiB/h, near peak - scored, _ = t.score_candidates([below, inband], 2.0, rp, 2160) - selected = t.select(scored, rp) - assert selected is not None and selected[0] is inband + floor_2160, ceil_2160 = t.bounds_for(2160) + assert 0 < floor_2160 < ceil_2160 + # 1440 has no entry; should fall back to the nearest defined resolution below it (1080). + floor_1440, ceil_1440 = t.bounds_for(1440) + assert 0 < floor_1440 < ceil_1440 + assert (floor_1440, ceil_1440) != (floor_2160, ceil_2160) # not the 2160 entry -def test_select_pick_methods(): - # select() still supports all three pick methods for profile overrides, applied to already-legal - # candidates: max_score = highest score, min_size = smallest GiB/h, topsis = highest closeness. +def test_resolve_profile_matches_by_name(): t = _topsis() - rp = t.resolve_profile("Balanced") - a = _release(score=900_000, resolution=2160, size_gb=18.0) # 9 GiB/h - b = _release(score=950_000, resolution=2160, size_gb=22.0) # 11 GiB/h + rp = t.resolve_profile("1080p Efficient") + assert rp.weights == t.cfg.presets["Efficient"].weights - def attrs(r): - return {"raw": {"score": r["customFormatScore"], "gbh": r["size"] / GB / 2}} - cands = [(a, attrs(a), 0.50), (b, attrs(b), 0.60)] - assert t.select(cands, replace(rp, pick="max_score"))[0] is b # higher score - assert t.select(cands, replace(rp, pick="min_size"))[0] is a # smaller GiB/h - assert t.select(cands, replace(rp, pick="topsis"))[0] is b # higher closeness +def test_score_pool_relative_smallest_and_highest_get_ideal(): + t = _topsis() + rp = t.resolve_profile("Balanced") + rels = [_release(score=900_000, size_gb=10.0), _release(score=800_000, size_gb=30.0)] + scored, _cur = t.score_pool(rels, None, 2.0, rp) + by_score = {s[1]["raw"]["score"]: s[1] for s in scored} + # Highest score -> n_score 1, smallest file -> n_size 1. + assert by_score[900_000]["n_score"] == 1.0 and by_score[900_000]["n_size"] == 1.0 + assert by_score[800_000]["n_score"] == 0.0 and by_score[800_000]["n_size"] == 0.0 -def test_select_empty_returns_none(): +def test_score_pool_efficient_prefers_smaller_quality_prefers_score(): t = _topsis() - assert t.select([], t.resolve_profile(None)) is None + small_low = _release(score=900_000, size_gb=10.0) # smaller, a touch lower score + big_high = _release(score=950_000, size_gb=30.0) # bigger, higher score + eff, _ = t.score_pool([small_low, big_high], None, 2.0, t.resolve_profile("Efficient")) + qual, _ = t.score_pool([small_low, big_high], None, 2.0, t.resolve_profile("Quality")) + assert eff[0][1]["raw"]["score"] == 900_000 # Efficient takes the smaller + assert qual[0][1]["raw"]["score"] == 950_000 # Quality takes the higher score -def test_current_resolution_reads_quality_block(): +def test_score_pool_sorted_best_first(): + # The pick is always the highest-closeness candidate (scored is sorted best-first). t = _topsis() - # the file's own quality bucket is the source, reliable even for scope content whose pixel - # height (mediaInfo) is misleadingly short - f = {"quality": {"quality": {"resolution": 2160}}, "mediaInfo": {"resolution": "3840x1608"}} - assert t._current_resolution(f) == 2160 - assert t._current_resolution({"quality": {"quality": {"resolution": 1080}}}) == 1080 - assert t._current_resolution({}) == 0 # unknown quality - assert t._current_resolution({"quality": {"quality": {}}}) == 0 + rp = t.resolve_profile("Compact") # size-leaning + rels = [_release(score=900_000, size_gb=10.0), _release(score=950_000, size_gb=30.0)] + scored, _ = t.score_pool(rels, None, 2.0, rp) + assert scored[0][1]["raw"]["size_gb"] == 10.0 # Compact ranks the smaller first diff --git a/tests/test_worker.py b/tests/test_worker.py index cc35ab0..8643123 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -229,6 +229,9 @@ def runtime_h(self, item): def profile_for(self, item): return ("2160p Quality", 2160) + def has_file(self, item): + return True + def current_file(self, item): return self._current @@ -249,7 +252,9 @@ def _worker(state, dry_run=False): w = OptimizerWorker.__new__(OptimizerWorker) w.opt = OptimizerConfig(enabled=True) w.state = state - w.topsis = Topsis(default_topsis()) + cfg = default_topsis() + cfg.min_candidates = 2 # worker tests use 2-candidate pools; pool-size tested in test_topsis + w.topsis = Topsis(cfg) w.dry_run = dry_run return w @@ -264,12 +269,15 @@ def test_process_one_hold_marks_satisfied(tmp_path): # Current file is already excellent; the candidate is no better -> HOLD -> satisfied. state = StateManager(str(tmp_path / "s.json")) adapter = _ProcessAdapter( - releases=[_release(score=1_000_000, resolution=2160, size_gb=14.0)], + releases=[ + _release(score=1_000_000, resolution=2160, size_gb=14.0), + _release(score=900_000, resolution=2160, size_gb=28.0), # clearly worse + ], current_file=_file(score=1_000_000, resolution=2160, size_gb=14.0), ) _worker(state)._process_one(_ctx(adapter), 1) entry = state.get("radarr", 1) - assert entry is not None and entry.status == SATISFIED + assert entry is not None and entry.status == SATISFIED and entry.profile == "2160p Quality" assert adapter.grabbed == [] @@ -278,7 +286,10 @@ def test_process_one_act_grabs_without_marking(tmp_path): # pool until a later evaluation HOLDs (success) or it's retried (failure). state = StateManager(str(tmp_path / "s.json")) adapter = _ProcessAdapter( - releases=[_release(score=1_000_000, resolution=2160, size_gb=14.0)], + releases=[ + _release(score=1_000_000, resolution=2160, size_gb=14.0), + _release(score=950_000, resolution=2160, size_gb=18.0), + ], current_file=_file(score=200_000, resolution=1080, size_gb=30.0), ) _worker(state)._process_one(_ctx(adapter), 1) @@ -382,7 +393,10 @@ def test_importblocked_does_not_count_toward_import_gate(tmp_path): ] adapter = _GrabQueueAdapter( records, - releases=[_release(score=1_000_000, resolution=2160, size_gb=14.0)], + releases=[ + _release(score=1_000_000, resolution=2160, size_gb=14.0), + _release(score=950_000, resolution=2160, size_gb=18.0), + ], current_file=_file(score=200_000, resolution=1080, size_gb=30.0), ) ctx = _AppContext(adapter, OptimizerAppConfig(auto_import_downgrades=False)) @@ -422,19 +436,18 @@ def test_build_pool_holds_progress_across_refresh_then_resets(tmp_path): w = _worker(state) ctx = _AppContext(_ProcessAdapter([], None), OptimizerAppConfig()) ctx.items_by_id = {1: {"id": 1}, 2: {"id": 2}, 3: {"id": 3}} - now = datetime.now(UTC) - w._build_pool(ctx, now) + w._build_pool(ctx) assert set(ctx.pool) == {1, 2, 3} # Two items processed this pass; a refresh happened (evaluated preserved). ctx.evaluated = {1, 2} - w._build_pool(ctx, now) + w._build_pool(ctx) assert ctx.pool == [3] # only the unvisited item remains # Last item visited -> pool empties -> pass resets to a fresh full sweep. ctx.evaluated = {1, 2, 3} - w._build_pool(ctx, now) + w._build_pool(ctx) assert set(ctx.pool) == {1, 2, 3} assert ctx.evaluated == set() @@ -788,9 +801,9 @@ def test_queue_active_filter_excludes_completed_when_flag_on(): def test_build_pool_excludes_satisfied(tmp_path): state = StateManager(str(tmp_path / "s.json")) - state.mark_satisfied("radarr", 2) + state.mark_satisfied("radarr", 2, "2160p Quality") # same profile the adapter reports w = _worker(state) ctx = _AppContext(_ProcessAdapter([], None), OptimizerAppConfig()) ctx.items_by_id = {1: {"id": 1}, 2: {"id": 2}} - w._build_pool(ctx, datetime.now(UTC)) - assert ctx.pool == [1] # satisfied item 2 is out of the pool + w._build_pool(ctx) + assert ctx.pool == [1] # satisfied item 2 (same profile, has file) is out of the pool diff --git a/tools/cutoff_viz.py b/tools/cutoff_viz.py deleted file mode 100644 index 0015276..0000000 --- a/tools/cutoff_viz.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Visualize the candidate funnel (incl. the score-gap cutoff) against a LIVE deployment. - -Read-only. For each library item it runs a real interactive indexer search, then reproduces -the optimizer pipeline stage-by-stage and prints a table of *every* candidate release with the -reason it survived or was removed, the score-gap cutoff line, and the final pick. It NEVER -grabs, imports, unmonitors, or writes state — there is no code path here that calls an action. - -It drives the real config (config.toml) and connection (.env), so the numbers match what the -worker would see. - -A live `/api/v3/release` search is a slow interactive indexer call and is rate-limited, so this -processes only a small random sample by default and sleeps between items. Writes a timestamped -Markdown report under ./reports/. - -Run (from the repo root): - - uv run --env-file .env python tools/cutoff_viz.py --config config.toml --limit 5 - -Options: - --config PATH Path to config.toml (default: config.toml). Layered on defaults.toml, same - as the worker. Connection/secrets still come from the env (.env). - --app NAME Which app to evaluate: radarr or sonarr (default: radarr). Must be enabled - in the environment (its URL + API key set). - --limit N Max items to evaluate (default: 5). Caps EVERY path, including --tmdb/--ids - (an over-long id list is truncated). Each item is a live, rate-limited - indexer search, hence the small default. - --tmdb ID [ID..] Evaluate these TMDB ids (Sonarr: TVDB ids) — the id in the *arr movie URL, - so you can paste it straight from the browser. The recommended selector. - --ids ID [ID...] Evaluate these *internal* Radarr/Sonarr ids (the DB primary key). A separate - id space from --tmdb; use this only if you specifically have the internal id. - --tmdb and --ids are matched independently and may be combined. - --sleep SECONDS Delay between items (default: 3.0), to stay gentle on the indexers. - --seed N Seed the random sample for a reproducible run (no effect with --tmdb/--ids). - -Either selector skips the random sample (but still obeys --limit) and warns on any id that is -unknown or has no downloaded file (those are skipped) instead of failing silently. Matching by ---tmdb does not call TMDB: the id is already a field on each item from the one *arr list call. - -Examples: - # 5 random movies - uv run --env-file .env python tools/cutoff_viz.py --config config.toml - - # specific movies by TMDB id (the number in the *arr URL), no sampling - uv run --env-file .env python tools/cutoff_viz.py --config config.toml --tmdb 38356 1858 - - # a reproducible sample of 10 - uv run --env-file .env python tools/cutoff_viz.py --config config.toml --limit 10 --seed 1 -""" - -from __future__ import annotations - -import argparse -import random -import time -from datetime import datetime -from pathlib import Path - -from optimizarr.arr import ArrApi, build_client -from optimizarr.config import load_config -from optimizarr.features.optimizer.config import OptimizerAppConfig -from optimizarr.features.optimizer.decision import resolution_ok, swap_allowed -from optimizarr.features.optimizer.topsis import Topsis, eligible - -# Statuses, best-to-worst for display ordering of the legend only. -KEPT = "candidate" -PICK = "PICK" - - -def _hard_reject_reason(release: dict) -> str: - if release.get("temporarilyRejected"): - return "temporarily rejected" - return "; ".join(release.get("rejections") or []) or "hard reject" - - -def analyze( - topsis: Topsis, - releases: list[dict], - runtime_h: float, - profile_name: str | None, - target_res: int | None, - current_file: dict | None, - app_cfg: OptimizerAppConfig, -) -> dict: - """Reproduce decide()'s pipeline, labelling each release with the stage that removed it. - - Returns a dict with the resolved profile, current-file row, per-release rows (each with raw - attrs, closeness and a status string), and the score-gap cutoff score. - """ - cur = current_file or {} - cur_size = cur.get("size") - cur_score = cur.get("customFormatScore") - resolved = topsis.resolve_profile(profile_name) - - # --- stage membership (decide()'s exact order) --- - after_size = [ - r - for r in releases - if app_cfg.allow_size_increase - or not (isinstance(cur_size, int) and cur_size > 0) - or r.get("size", 0) <= cur_size - ] - after_qual = [ - r - for r in after_size - if app_cfg.allow_quality_downgrade - or cur_score is None - or (r.get("customFormatScore") or 0) >= cur_score - ] - after_hard = eligible(after_qual) - after_band = topsis.filter_by_size_band(after_hard, runtime_h, resolved.reference) - after_gap = topsis.filter_by_score_gap(after_band) # the gap-cut survivors == "scored" - - ids = lambda lst: {id(r) for r in lst} # noqa: E731 - id_size, id_qual = ids(after_size), ids(after_qual) - id_hard, id_band, id_gap = ids(after_hard), ids(after_band), ids(after_gap) - - # The score-gap cutoff line: the lowest score that survived the gap-cut (everything strictly - # below it, among band/hard survivors, was dropped as the score tail). - gap_cutoff_score = ( - min((r.get("customFormatScore") or 0) for r in after_gap) if after_gap else None - ) - - cur_clo, cur_raw = topsis.closeness_for_current_file(cur, runtime_h, resolved, target_res) - cur_res = cur_raw.get("resolution", 0) or 0 - min_gain = resolved.min_closeness_gain - - rows = [] - legal: list[tuple[dict, dict, float]] = [] - for r in releases: - attrs = topsis.attributes_for(r, runtime_h, resolved, target_res) - clo = topsis.closeness(attrs, resolved.weights) - cand_res = attrs["raw"]["resolution"] - rid = id(r) - status = KEPT - if rid not in id_size: - status = "drop: bigger than current (allow_size_increase=false)" - elif rid not in id_qual: - status = "drop: lower score than current (allow_quality_downgrade=false)" - elif rid not in id_hard: - status = f"drop: hard reject ({_hard_reject_reason(r)})" - elif rid not in id_band: - floor, _lo, _t, _hi, ceiling = topsis.reference_for(cand_res, resolved.reference) - g = attrs["raw"]["gbh"] - where = ( - f"{g:.2f} < floor {floor:.2f}" if g < floor else f"{g:.2f} > ceiling {ceiling:.2f}" - ) - status = f"drop: outside size band ({where})" - elif rid not in id_gap: - if (r.get("customFormatScore") or 0) < 0: - status = "drop: negative score" - else: - status = f"drop: score-gap cut (>{topsis.cfg.score_gap:.0%} below top cluster)" - elif swap_allowed(cur_clo, clo, cur_res, cand_res, target_res, min_gain): - legal.append((r, attrs, clo)) - elif not resolution_ok(cur_res, cand_res, target_res): - status = "drop: swap gate (resolution below target)" - else: - delta = clo - (cur_clo if cur_clo is not None else 0.0) - status = f"drop: swap gate (Δcloseness {delta:+.3f} < {min_gain:.3f})" - rows.append({"release": r, "attrs": attrs, "closeness": clo, "status": status}) - - pick = topsis.select(legal, resolved) - pick_id = id(pick[0]) if pick else None - for row in rows: - if pick_id is not None and id(row["release"]) == pick_id: - row["status"] = PICK - - return { - "resolved": resolved, - "profile_name": profile_name, - "target_res": target_res, - "current": {"closeness": cur_clo, **cur_raw}, - "rows": rows, - "gap_cutoff_score": gap_cutoff_score, - "n_legal": len(legal), - } - - -def _fmt_row(title: str, score, res, size_gb, gbh, clo, status) -> str: - score_s = f"{score:,}" if score is not None else "n/a" - clo_s = f"{clo:.3f}" if clo is not None else "n/a" - res_s = f"{res}p" if res else "?" - return f"| {title} | {score_s} | {res_s} | {size_gb:.2f} | {gbh:.2f} | {clo_s} | {status} |" - - -def render_item(api: ArrApi, item: dict, analysis: dict, gap: float) -> list[str]: - label = api.label(item) - prof = analysis["resolved"] - cur = analysis["current"] - target = analysis["target_res"] - target_s = f"{target}p" if target else "?" - md = [ - f"### {label}", - "", - f"- quality profile: `{analysis['profile_name'] or '?'}` (target {target_s})", - f"- resolved preset: pick `{prof.pick}`, weights `{prof.weights}`", - f"- current file: {_fmt_side(cur)}", - f"- legal candidates after gate: **{analysis['n_legal']}**", - "", - "| title | score | res | size GB | GiB/h | closeness | status |", - "| --- | --- | --- | --- | --- | --- | --- |", - ] - - rows = sorted( - analysis["rows"], - key=lambda r: -(r["release"].get("customFormatScore") or 0), - ) - cutoff = analysis["gap_cutoff_score"] - cutoff_drawn = False - for r in rows: - rel = r["release"] - raw = r["attrs"]["raw"] - score = rel.get("customFormatScore") - # Draw the score-gap cutoff line once, just below the last release at/above the cutoff. - if cutoff is not None and not cutoff_drawn and (score or 0) < cutoff: - md.append( - f"| **─── score-gap {gap:.0%} cutoff " - f"(min kept score = {cutoff:,}) ───** | | | | | | |" - ) - cutoff_drawn = True - title = rel.get("title", "?") - # Truncate very long release titles so the table stays readable. - if len(title) > 70: - title = title[:67] + "..." - md.append( - _fmt_row( - title, - score, - raw["resolution"], - raw["size_gb"], - raw["gbh"], - r["closeness"], - r["status"], - ) - ) - md.append("") - return md - - -def _fmt_side(side: dict) -> str: - score = side.get("score") - score_s = f"{score:,}" if score is not None else "n/a" - clo = side.get("closeness") - clo_s = f"{clo:.3f}" if clo is not None else "n/a" - res = side.get("resolution") or 0 - return ( - f"score={score_s} res={res}p size={side.get('size_gb', 0):.2f}GB " - f"({side.get('gbh', 0):.2f} GiB/h) closeness={clo_s}" - ) - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--config", default="config.toml", help="path to config.toml") - ap.add_argument("--app", default="radarr", choices=["radarr", "sonarr"]) - ap.add_argument("--limit", type=int, default=5, help="max items to evaluate (rate limits!)") - ap.add_argument( - "--tmdb", - type=int, - nargs="*", - default=[], - help="evaluate these TMDB ids (the id in the *arr movie URL); Sonarr: TVDB ids", - ) - ap.add_argument( - "--ids", - type=int, - nargs="*", - default=[], - help="evaluate these internal Radarr/Sonarr ids", - ) - ap.add_argument("--sleep", type=float, default=3.0, help="seconds between items") - ap.add_argument("--seed", type=int, help="seed the random sample for a reproducible run") - args = ap.parse_args() - - config = load_config(args.config) - conn = getattr(config, args.app) - if conn is None: - raise SystemExit(f"{args.app} is not configured in the environment") - app_cfg = getattr(config.optimizer, args.app) - topsis = Topsis(config.optimizer.topsis) - api = build_client(args.app, conn) - api.refresh_profiles() - - all_items = api.list_items() - items = [it for it in all_items if api.has_file(it)] - if args.tmdb or args.ids: - # Internal id and external id (tmdb/tvdb) are separate id spaces and can collide, so they - # are matched independently. --tmdb is the one to use: it's the id in the *arr movie URL. - # Warn (per id space) on anything unknown or with no file, instead of silently dropping it. - want_ext, want_internal = set(args.tmdb), set(args.ids) - - def ext_id(it: dict) -> int | None: - return it.get("tmdbId") or it.get("tvdbId") - - items = [it for it in items if api.item_id(it) in want_internal or ext_id(it) in want_ext] - - def warn(label: str, wanted: set[int], present: set[int], with_file: set[int]) -> None: - for missing in sorted(wanted - present): - print(f"[warn] {label} {missing}: no such item") - for nofile in sorted((wanted & present) - with_file): - print(f"[warn] {label} {nofile}: exists but has no downloaded file; skipped") - - warn( - "tmdb/tvdb id", - want_ext, - {e for it in all_items if (e := ext_id(it)) is not None}, - {e for it in items if (e := ext_id(it)) is not None}, - ) - warn( - "internal id", - want_internal, - {api.item_id(it) for it in all_items}, - {api.item_id(it) for it in items}, - ) - # --limit caps every path: each item is a live indexer search, so an over-long id list - # shouldn't fire dozens of them. - if len(items) > args.limit: - print(f"[warn] {len(items)} items selected; capping at --limit {args.limit}") - items = items[: args.limit] - else: - # Random sample so repeated runs don't always show the same first movies. - random.Random(args.seed).shuffle(items) - items = items[: args.limit] - - header = [ - "# Candidate / score-gap cutoff visualization", - "", - f"- generated: {datetime.now().isoformat(timespec='seconds')}", - f"- app: `{args.app}` url: `{conn.url}`", - f"- score_gap (cutoff): `{topsis.cfg.score_gap:.0%}`", - f"- items evaluated: {len(items)} (read-only; no grab/import/state writes)", - "", - "Status legend: `PICK` = chosen release; `candidate` = legal after the transition gate; " - "`drop: ...` = removed, with the stage and reason. Rows are sorted by score so the " - "score-gap cutoff line is visible.", - "", - ] - body: list[str] = [] - for it in items: - label = api.label(it) - print(f"[{args.app}] searching releases for {label} ...") - runtime_h = api.runtime_h(it) - profile_name, target_res = api.profile_for(it) - current_file = api.current_file(it) - releases = api.releases(it) - analysis = analyze( - topsis, releases, runtime_h, profile_name, target_res, current_file, app_cfg - ) - body += render_item(api, it, analysis, topsis.cfg.score_gap) - time.sleep(args.sleep) - - reports = Path("reports") - reports.mkdir(exist_ok=True) - out = reports / f"cutoff_viz_{datetime.now():%Y%m%d_%H%M%S}.md" - out.write_text("\n".join(header + body), encoding="utf-8") - print(f"\nWrote {out} ({len(items)} item(s)).") - - -if __name__ == "__main__": - main() diff --git a/tools/diagnose.py b/tools/diagnose.py index 7f8f734..a15c735 100644 --- a/tools/diagnose.py +++ b/tools/diagnose.py @@ -1,19 +1,16 @@ -"""Unified diagnostic + balance report for the optimizer, run offline against a harvested dataset. - -Drives the REAL engine (decide / Topsis / default_topsis) over the training JSONL and writes three -markdown reports to reports/ (shared timestamp). Read-only; never touches Radarr/Sonarr or config. - - diagnose_picks_.md one row per movie: the current file + what each preset would pick. - Compact overview. Full release titles, never cropped. - diagnose_full_.md per-movie drill-down: the current file, every release (score / res / - GiB-h / size, uncropped title), and each preset's ACT/HOLD + reason + - deltas. This is the "why is X misbehaving" view. - diagnose_stats_.md aggregate balance stats per preset: ACT/HOLD, grow vs shrink, size and - score deltas with dispersion, library size shift (inflate guard on/off), - and the swaps the no-bigger-at-lower-score rule blocked. - diagnose_.html a single self-contained interactive page (Picks / Releases / Stats tabs) - with click-to-sort, per-table text filter, and no pagination. Open it in - a browser; markdown viewers choke on the wide tables. +"""Offline quadrant report for the relative optimizer, run against a harvested dataset. + +Drives the REAL engine (decide / Topsis / default_topsis) over the training JSONL, evaluating each +movie under ITS OWN profile only (Profilarr scoring is profile-specific, so cross-profile picks are +meaningless). For every ACT it classifies the pick vs the current file into one of four quadrants +(score up/down x size up/down), the key one being "score down + size up", which must never happen. + +Writes two reports to reports/ (shared timestamp): + diagnose_.md summary (quadrants, ACT/HOLD split, total size shift) + the full + original -> new-pick list with a quadrant tag per row. + diagnose_.html a single self-contained interactive page (Summary / Picks / Candidates tabs) + with click-to-sort and a per-table filter. Click a movie in Picks to see all + its in-window candidates (and why each was/wasn't chosen). Open in a browser. uv run python tools/diagnose.py --in reports/training_data_radarr.jsonl """ @@ -27,14 +24,13 @@ from pathlib import Path from optimizarr.features.optimizer.config import default_topsis -from optimizarr.features.optimizer.decision import decide, resolution_ok -from optimizarr.features.optimizer.topsis import Topsis +from optimizarr.features.optimizer.decision import decide +from optimizarr.features.optimizer.topsis import HARD_REJECT_KEYWORDS, Topsis, _score_floor_tier def _cand(r: dict) -> dict: return { "guid": r.get("title"), - "indexerId": 1, "title": r.get("title") or "?", "customFormatScore": r.get("score"), "quality": {"quality": {"resolution": r.get("resolution") or 0}}, @@ -55,337 +51,174 @@ def _cur(cf: dict | None) -> dict | None: } -def _pct(vals: list[float], p: float) -> float: - if not vals: - return float("nan") - s = sorted(vals) - k = (len(s) - 1) * (p / 100.0) - f = int(k) - c = min(f + 1, len(s) - 1) - return s[f] + (s[c] - s[f]) * (k - f) - - -def _best_unconstrained(t: Topsis, releases, runtime, preset, target_res, cur): - """Highest-closeness candidate clearing the resolution guard + margin, IGNORING the - no-bigger-at-lower-score rule. Returns (rel, attrs, clo, cur_clo) or None.""" - resolved = t.resolve_profile(preset) - scored, _ = t.score_candidates(releases, runtime, resolved, target_res) - cur_clo, cur_raw = t.closeness_for_current_file(cur, runtime, resolved, target_res) - cur_res = cur_raw.get("resolution", 0) or 0 - for rel, attrs, clo in scored: # sorted best-first - if not resolution_ok(cur_res, attrs["raw"]["resolution"], target_res): - continue - if cur_clo is not None and clo < cur_clo + resolved.min_closeness_gain: +def _quadrant(ds: float, dz: float) -> str: + s = "score+" if ds > 0 else "score-" if ds < 0 else "score=" + z = "size+" if dz > 0.05 else "size-" if dz < -0.05 else "size=" + return f"{s} {z}" + + +def _candidate_status( + x: dict, t: Topsis, floor_score: int, target_res: int, pick_title: str | None +) -> str: + """Why a release was or wasn't the pick, mirroring the engine's filters (for the drill-down).""" + rej = x.get("rejections") or [] + if x.get("temporarily_rejected") or any(any(k in r for k in HARD_REJECT_KEYWORDS) for r in rej): + return "rejected" + s = x.get("score") + if s is None or s < 0: + return "neg-score" + if s < floor_score: + return "below-window" + res = x.get("resolution") or 0 + if target_res and res != target_res: + return "wrong-res" + floor, ceiling = t.bounds_for(res) + g = x.get("gbh") or 0.0 + if g < floor: + return "below-floor" + if g > ceiling: + return "above-ceiling" + return "PICK" if x.get("title") == pick_title else "eligible" + + +def _candidates( + r: dict, t: Topsis, cur_score, target_res: int, pick_title: str | None +) -> list[dict]: + """In-window candidates for one movie, each annotated with status. Uses the same three-tier + score floor as the engine so the drill-down exactly mirrors what got filtered.""" + elig_scores = [ + x.get("score") or 0 + for x in r["releases"] + if (x.get("score") or 0) >= 0 + and not x.get("temporarily_rejected") + and not any( + any(k in rj for k in HARD_REJECT_KEYWORDS) for rj in (x.get("rejections") or []) + ) + ] + floor_score, _ = _score_floor_tier( + elig_scores, cur_score, t.cfg.score_window, t.cfg.min_candidates + ) + out = [] + for x in r["releases"]: + st = _candidate_status(x, t, floor_score, target_res, pick_title) + if st in ("rejected", "neg-score", "below-window"): continue - return rel, attrs, clo, cur_clo - return None - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) - ap.add_argument("--out-dir", default="reports", type=Path) - args = ap.parse_args() + out.append( + { + "title": x.get("title") or "?", + "score": x.get("score") or 0, + "res": x.get("resolution") or 0, + "gbh": x.get("gbh") or 0.0, + "size_gb": x.get("size_gb") or 0.0, + "status": st, + } + ) + out.sort(key=lambda c: (-c["score"], c["gbh"])) + return out - rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] - t = Topsis(default_topsis()) - presets = list(t.cfg.presets) # Remux, Quality, Balanced, Efficient, Compact - # ----- run the engine: per movie, per preset ----- - # records[i] = {title, cur, runtime, target_res, releases_raw, picks{preset: info}} +def _evaluate(rows: list[dict], t: Topsis) -> list[dict]: records = [] for r in rows: cf = r.get("current_file") cur = _cur(cf) - rels = [_cand(x) for x in r["releases"]] - runtime = r["runtime_h"] + profile = r["profile"]["name"] target_res = r["profile"]["target_resolution"] or (cf or {}).get("resolution") or 0 - picks = {} - for p in presets: - d = decide(t, rels, runtime, p, target_res, current_file=cur) - info = {"action": d.action, "reason": d.reason} - if d.action == "ACT" and d.pick: - info["pick"] = d.pick - # would the no-inflate rule have blocked a bigger-lower-score swap? - bu = _best_unconstrained(t, rels, runtime, p, target_res, cur) if cur else None - if bu and cf: - rel, attrs, clo, _cc = bu - bigger = (rel.get("size", 0)) > (cf.get("size_bytes") or 0) - lower = (attrs["raw"]["score"] or 0) <= (cf.get("score") or 0) - if bigger and lower: - info["blocked_inflation"] = { - "title": rel.get("title"), - "score": attrs["raw"]["score"], - "res": attrs["raw"]["resolution"], - "size_gb": attrs["raw"]["size_gb"], - "res_upgrade": attrs["raw"]["resolution"] > (cf.get("resolution") or 0), - } - picks[p] = info - - # Library-shift: decide under the movie's OWN profile, with and without the inflate guard. - own = r["profile"]["name"] - d_on = decide(t, rels, runtime, own, target_res, current_file=cur) - d_off = decide( - t, rels, runtime, own, target_res, current_file=cur, allow_larger_at_lower_score=True - ) - records.append( - { - "title": r["title"], - "profile": own, - "target_res": target_res, - "cur": cf, - "releases": r["releases"], - "picks": picks, - "own_on": d_on.pick if d_on.action == "ACT" else None, - "own_off": d_off.pick if d_off.action == "ACT" else None, - } + d = decide( + t, + [_cand(x) for x in r["releases"]], + r["runtime_h"], + profile, + target_res, + current_file=cur, ) - - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - args.out_dir.mkdir(exist_ok=True) - _write_picks(records, presets, args.out_dir / f"diagnose_picks_{ts}.md") - _write_full(records, presets, args.out_dir / f"diagnose_full_{ts}.md") - _write_stats(records, presets, args.out_dir / f"diagnose_stats_{ts}.md") - _write_html(records, presets, args.out_dir / f"diagnose_{ts}.html") - print(f"wrote diagnose_{{picks,full,stats}}_{ts}.md + diagnose_{ts}.html to {args.out_dir}/") - - -def _fmt_cur(cf: dict | None) -> str: - if not cf or cf.get("score") is None: - return "no current file" - return ( - f"score={cf['score']:,} res={cf.get('resolution') or '?'}p " - f"size={cf.get('size_gb', 0):.1f}GB ({cf.get('gbh', 0):.2f} GiB/h)" - ) - - -def _pick_cell(info: dict) -> str: - if info["action"] != "ACT" or "pick" not in info: - flag = " [inflation blocked]" if "blocked_inflation" in info else "" - return f"HOLD{flag}" - p = info["pick"] - return f"{p.get('score', 0):,} / {p.get('size_gb', 0):.1f}GB / {p.get('gbh', 0):.2f}gbh" + rec = { + "title": r["title"], + "profile": profile, + "action": d.action, + "satisfy": d.satisfy, + "reason": d.reason, + "cur": cf or {}, + "pick": d.pick if d.action == "ACT" else None, + } + pick_title = d.pick.get("title") if d.action == "ACT" and d.pick else None + rec["cands"] = _candidates(r, t, (cf or {}).get("score"), int(target_res or 0), pick_title) + if rec["pick"] and cf: + pk = rec["pick"] + rec["dscore"] = (pk.get("score") or 0) - (cf.get("score") or 0) + rec["dsize"] = (pk.get("size_gb") or 0) - (cf.get("size_gb") or 0) + rec["dgbh"] = (pk.get("gbh") or 0) - (cf.get("gbh") or 0) + rec["quad"] = _quadrant(rec["dscore"], rec["dsize"]) + records.append(rec) + return records + + +def _summary(records: list[dict]) -> dict: + act = [r for r in records if r["action"] == "ACT"] + quad: dict[str, int] = {} + for r in act: + quad[r["quad"]] = quad.get(r["quad"], 0) + 1 + return { + "movies": len(records), + "act": len(act), + "hold_satisfied": sum(1 for r in records if r["action"] == "HOLD" and r["satisfy"]), + "hold_insufficient": sum(1 for r in records if r["action"] == "HOLD" and not r["satisfy"]), + "quad": quad, + "size_shift_gb": sum(r.get("dsize", 0.0) for r in act), + "bug": [r for r in act if r["dscore"] < 0 and r["dsize"] > 0.05], + } -def _write_picks(records, presets, out: Path) -> None: - lines = [ - "# Diagnose: picks overview", - f"\n{len(records)} movies. Each cell = the pick (score / size / GiB-h), or HOLD.\n", - "| movie | profile | current | " + " | ".join(presets) + " |", - "| --- | --- | --- | " + " | ".join("---" for _ in presets) + " |", +_MEANING = { + "score+ size-": "better AND smaller (ideal)", + "score+ size+": "score upgrade, bigger (justified)", + "score+ size=": "score upgrade, same size", + "score- size-": "traded a little score for a smaller file", + "score= size-": "same score, smaller", + "score- size+": "DOWNGRADE + BIGGER (must be 0)", +} + + +def _write_md(records: list[dict], s: dict, out: Path) -> None: + L = [ + "# Diagnose: relative optimizer (own-profile quadrants)", + f"\n{s['movies']} movies. ACT **{s['act']}** · HOLD-satisfied **{s['hold_satisfied']}** · " + f"HOLD-too-few **{s['hold_insufficient']}**. Total size shift: " + f"**{s['size_shift_gb'] / 1024:+.2f} TB**.\n", + "## Pick quadrants (vs the current file, ACT only)\n", + "| quadrant | count | meaning |", + "| --- | ---: | --- |", ] - for rec in records: - cells = " | ".join(_pick_cell(rec["picks"][p]) for p in presets) - lines.append(f"| {rec['title']} | {rec['profile']} | {_fmt_cur(rec['cur'])} | {cells} |") - out.write_text("\n".join(lines) + "\n") - - -def _write_full(records, presets, out: Path) -> None: - lines = ["# Diagnose: full drill-down", ""] - for rec in records: - lines.append( - f"## {rec['title']} · profile: {rec['profile']} (target {rec['target_res']}p)" - ) - lines.append(f"\n**Current file:** {_fmt_cur(rec['cur'])}\n") - # releases (score >= 0), sorted by score desc, uncropped titles - rels = sorted( - (x for x in rec["releases"] if (x.get("score") or -1) >= 0), - key=lambda x: -(x.get("score") or 0), - ) - neg = sum(1 for x in rec["releases"] if (x.get("score") or 0) < 0) - lines.append(f"**Releases** ({len(rels)} with score>=0, {neg} negative hidden):\n") - lines.append("| score | res | GiB/h | size | rej | title |") - lines.append("| ---: | ---: | ---: | ---: | ---: | --- |") - for x in rels: - rj = "Y" if (x.get("rejections") or x.get("temporarily_rejected")) else "" - lines.append( - f"| {x.get('score', 0):,} | {x.get('resolution') or '?'}p | " - f"{x.get('gbh', 0):.2f} | {x.get('size_gb', 0):.1f}GB | {rj} | {x.get('title')} |" - ) - lines.append("\n**Per-preset decision:**\n") - lines.append("| preset | action | pick | Δscore | Δsize | note |") - lines.append("| --- | --- | --- | ---: | ---: | --- |") - cf = rec["cur"] or {} - for p in presets: - info = rec["picks"][p] - note = "" - if "blocked_inflation" in info: - bi = info["blocked_inflation"] - kind = "RES-UPGRADE" if bi["res_upgrade"] else "same-res" - note = ( - f"would inflate ({kind}): {bi['score']:,}/{bi['size_gb']:.1f}GB {bi['title']}" - ) - if info["action"] == "ACT" and "pick" in info: - pk = info["pick"] - ds = (pk.get("score") or 0) - (cf.get("score") or 0) - dz = (pk.get("size_gb") or 0) - (cf.get("size_gb") or 0) - lines.append( - f"| {p} | ACT | {pk.get('title', '?')} | {ds:+,} | {dz:+.1f}GB | {note} |" - ) - else: - lines.append(f"| {p} | HOLD | - | - | - | {note} |") - lines.append("") - out.write_text("\n".join(lines) + "\n") - - -def _gb(x: float) -> str: - return f"{x / 1024:.2f} TB" if abs(x) >= 1024 else f"{x:.0f} GB" - - -# ----- stat computation (shared by the markdown + HTML renderers) ----- - - -def _shift_data(records) -> dict: - """Library size change per movie under its OWN profile, inflate guard ON vs OFF.""" - d = dict(cur=0.0, on=0.0, off=0.0, on_g=0.0, on_s=0.0, off_g=0.0, off_s=0.0, movies=0, pts=0) - examples = [] - for rec in records: - cf = rec["cur"] - if not cf or cf.get("score") is None: - continue - cz = cf.get("size_gb") or 0 - on_gb = (rec["own_on"] or {}).get("size_gb", cz) if rec["own_on"] else cz - off_gb = (rec["own_off"] or {}).get("size_gb", cz) if rec["own_off"] else cz - d["cur"] += cz - d["on"] += on_gb - d["off"] += off_gb - d["on_g"] += max(0.0, on_gb - cz) - d["on_s"] += max(0.0, cz - on_gb) - d["off_g"] += max(0.0, off_gb - cz) - d["off_s"] += max(0.0, cz - off_gb) - if ( - rec["own_off"] - and off_gb > cz - and (rec["own_off"].get("score") or 0) < (cf["score"] or 0) - and off_gb > on_gb + 1e-6 - ): - d["movies"] += 1 - drop = (cf["score"] or 0) - (rec["own_off"].get("score") or 0) - d["pts"] += drop - examples.append((drop, off_gb - cz, rec["title"])) - d["examples"] = sorted(examples, reverse=True) - return d - - -def _preset_data(records, presets) -> list[dict]: - out = [] - for p in presets: - act = hold = grew = shrank = 0 - ds: list[float] = [] - dsc: list[float] = [] - for rec in records: - info = rec["picks"][p] - cf = rec["cur"] - if info["action"] != "ACT" or "pick" not in info: - hold += 1 - continue - act += 1 - if not cf: - continue - pk = info["pick"] - cz, ps = cf.get("size_gb") or 0, cf.get("score") or 0 - if pk.get("size_gb", 0) > cz: - grew += 1 - elif pk.get("size_gb", 0) < cz: - shrank += 1 - if cz > 0: - ds.append(100 * (pk.get("size_gb", 0) - cz) / cz) - dsc.append((pk.get("score") or 0) - ps) - out.append( - dict(preset=p, act=act, hold=hold, grew=grew, shrank=shrank, dsize=ds, dscore=dsc) - ) - return out - - -def _inflation_data(records, presets): - rows = [] - ups = [] - for p in presets: - tot = same = up = 0 - for rec in records: - bi = rec["picks"][p].get("blocked_inflation") - if not bi: - continue - tot += 1 - if bi["res_upgrade"]: - up += 1 - ups.append((p, rec, bi)) - else: - same += 1 - rows.append(dict(preset=p, total=tot, same=same, up=up)) - return rows, ups - - -def _trip(v: list[float], fmt: str) -> str: - return "-" if not v else f"{_pct(v, 10):{fmt}} / {_pct(v, 50):{fmt}} / {_pct(v, 90):{fmt}}" - - -def _write_stats(records, presets, out: Path) -> None: - lines = ["# Diagnose: balance stats", f"\n{len(records)} movies.\n"] - sh = _shift_data(records) - cur = sh["cur"] or 1 - - def pct(x: float) -> str: - return f"{100 * x / cur:+.1f}%" - - lines.append("## Library size shift (every movie realigned to its OWN profile)\n") - lines.append(f"Current library (movies with a file): **{_gb(sh['cur'])}**\n") - lines.append("| inflate guard | new total | net shift | total grown | total shrunk |") - lines.append("| --- | ---: | ---: | ---: | ---: |") - lines.append( - f"| ON (ship default) | {_gb(sh['on'])} | {_gb(sh['on'] - sh['cur'])} " - f"({pct(sh['on'] - sh['cur'])}) | +{_gb(sh['on_g'])} | -{_gb(sh['on_s'])} |" - ) - lines.append( - f"| OFF (drop the rule) | {_gb(sh['off'])} | {_gb(sh['off'] - sh['cur'])} " - f"({pct(sh['off'] - sh['cur'])}) | +{_gb(sh['off_g'])} | -{_gb(sh['off_s'])} |" + for q in sorted(s["quad"], key=lambda k: -s["quad"][k]): + L.append(f"| {q} | {s['quad'][q]} | {_MEANING.get(q, '')} |") + L.append(f"\n**score-down & size-up (must be 0): {len(s['bug'])}**\n") + for r in s["bug"]: + L.append(f"- {r['title']}: Δscore {r['dscore']:+,} Δsize {r['dsize']:+.1f}GB") + + L.append("\n## Original file -> new pick (ACT only)\n") + L.append( + "| movie | profile | quadrant | current (score / GB / gb·h) | " + "new pick (score / GB / gb·h) | Δscore | Δsize | Δgb·h |" ) - lines.append( - f"\n**Cost of dropping the rule:** +{_gb(sh['off'] - sh['on'])} extra growth, across " - f"{sh['movies']} movies that would grow at a lower score " - f"(total score regression {sh['pts']:,} points).\n" - ) - if sh["examples"]: - lines.append("Biggest score regressions the rule prevents (drop / growth / movie):\n") - for drop, grow, title in sh["examples"][:15]: - lines.append(f"- −{drop:,} pts, +{grow:.1f} GB — {title}") - - lines.append("\n## Per-preset picks (vs the current file)\n") - lines.append( - "| preset | ACT | HOLD | grew | shrank | Δsize% p10/p50/p90 | Δscore p10/p50/p90 |" - ) - lines.append("| --- | ---: | ---: | ---: | ---: | --- | --- |") - for d in _preset_data(records, presets): - lines.append( - f"| {d['preset']} | {d['act']} | {d['hold']} | {d['grew']} | {d['shrank']} | " - f"{_trip(d['dsize'], '+.0f')} | {_trip(d['dscore'], '+,.0f')} |" + L.append("| --- | --- | --- | --- | --- | ---: | ---: | ---: |") + for r in records: + if r["action"] != "ACT": + continue + cf, pk = r["cur"], r["pick"] + L.append( + f"| {r['title']} | {r['profile']} | {r['quad']} | " + f"{cf.get('score', 0):,} / {cf.get('size_gb', 0):.1f} / {cf.get('gbh', 0):.2f} | " + f"{pk.get('score', 0):,} / {pk.get('size_gb', 0):.1f} / {pk.get('gbh', 0):.2f} | " + f"{r['dscore']:+,} | {r['dsize']:+.1f}GB | {r['dgbh']:+.2f} |" ) - - rows, ups = _inflation_data(records, presets) - lines.append("\n## Swaps blocked by the no-bigger-at-lower-score rule\n") - lines.append( - "| preset | total blocked | same-res (correctly blocked) | RES-UPGRADE (maybe allow) |" - ) - lines.append("| --- | ---: | ---: | ---: |") - for d in rows: - lines.append(f"| {d['preset']} | {d['total']} | {d['same']} | {d['up']} |") - if ups: - lines.append("\n### Resolution-upgrade cases the rule blocks (the legitimate ones)\n") - lines.append("| preset | movie | current | blocked candidate |") - lines.append("| --- | --- | --- | --- |") - for p, rec, bi in ups[:40]: - lines.append( - f"| {p} | {rec['title']} | {_fmt_cur(rec['cur'])} | " - f"{bi['score']:,} / {bi['res']}p / {bi['size_gb']:.1f}GB {bi['title']} |" - ) - out.write_text("\n".join(lines) + "\n") + out.write_text("\n".join(L) + "\n") # ----- interactive HTML ----- -_HTML_HEAD = """ +_HEAD = """ -Optimizarr diagnostics -

    Optimizarr diagnostics

    -
    {n} movies · generated {ts}
    +.card{{border:1px solid #e5e7eb;border-radius:8px;padding:.7rem 1rem;min-width:140px}} +.card .big{{font-size:1.4rem;font-weight:700}} +

    Optimizarr diagnose

    +
    {n} movies · {ts} · own-profile relative model
    {tabbtns}
    """ -_HTML_TAIL = """""" @@ -447,214 +286,146 @@ def _cell(disp, sort=None, cls="") -> str: return f"{html.escape(str(disp))}" -def _tbl(tid: str, cols: list[tuple[str, bool]], rows: list[list[str]]) -> str: +def _tbl(tid: str, cols: list[tuple[str, bool]], rows: list[list[str]], filt: bool = True) -> str: head = "".join(f'{html.escape(c)}' for c, num in cols) body = "".join("" + "".join(r) + "" for r in rows) - return ( + f = ( f'' - f'{head}{body}
    ' + if filt + else "" + ) + return ( + f'{f}{head}' + f"{body}
    " ) -def _write_html(records, presets, out: Path) -> None: - initials = {p: p[0] for p in presets} +def _write_html(records: list[dict], s: dict, out: Path) -> None: + cards = ( + f"
    " + f"
    ACT
    {s['act']}
    " + f"
    satisfied (optimal)
    {s['hold_satisfied']}
    " + f"
    too few candidates
    {s['hold_insufficient']}
    " + f"
    size shift" + f'
    {s["size_shift_gb"] / 1024:+.2f} TB
    ' + f'
    score-/size+ (bug)
    {len(s["bug"])}
    ' + ) + qrows = [ + [_cell(q), _cell(s["quad"][q], s["quad"][q], "num"), _cell(_MEANING.get(q, ""), cls="txt")] + for q in sorted(s["quad"], key=lambda k: -s["quad"][k]) + ] + summary = ( + "

    Outcome

    " + + cards + + "

    Pick quadrants (ACT vs current)

    " + + _tbl( + "q-tbl", [("quadrant", False), ("count", True), ("meaning", False)], qrows, filt=False + ) + ) - # --- Picks tab --- - pcols = [ + cls = {"score+ size-": "ideal", "score- size+": "bug"} + cols = [ ("Movie", False), ("Profile", False), + ("Quadrant", False), ("Cur score", True), - ("Cur res", True), ("Cur GB", True), - ("Cur GiB/h", True), - ] + [(p, True) for p in presets] + ("Cur gb/h", True), + ("Pick score", True), + ("Pick GB", True), + ("Pick gb/h", True), + ("Δscore", True), + ("Δsize GB", True), + ("Δgb/h", True), + ] prows = [] - for rec in records: - cf = rec["cur"] or {} - cells = [ - _cell(rec["title"], cls="txt"), - _cell(rec["profile"]), - _cell(f"{cf.get('score'):,}" if cf.get("score") is not None else "-", cf.get("score")), - _cell(f"{cf.get('resolution') or '?'}p", cf.get("resolution") or 0, "num"), - _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), - _cell(f"{cf.get('gbh', 0):.2f}", cf.get("gbh") or 0, "num"), - ] - for p in presets: - info = rec["picks"][p] - if info["action"] == "ACT" and "pick" in info: - pk = info["pick"] - cells.append( - _cell( - f"{pk.get('score', 0):,} / {pk.get('size_gb', 0):.1f}GB", - pk.get("score") or 0, - "num", - ) - ) - else: - blk = "blocked_inflation" in info - cells.append( - _cell("HOLD*" if blk else "HOLD", -1, "num holdblk" if blk else "num hold") - ) - prows.append(cells) - picks_html = _tbl("picks-tbl", pcols, prows) - - # --- Releases tab (flat, score >= 0) --- - rcols = [ + for r in records: + if r["action"] != "ACT": + continue + cf, pk = r["cur"], r["pick"] + prows.append( + [ + _cell(r["title"], cls="txt link"), + _cell(r["profile"]), + _cell(r["quad"], cls=cls.get(r["quad"], "ok")), + _cell(f"{cf.get('score', 0):,}", cf.get("score") or 0, "num"), + _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), + _cell(f"{cf.get('gbh', 0):.2f}", cf.get("gbh") or 0, "num"), + _cell(f"{pk.get('score', 0):,}", pk.get("score") or 0, "num"), + _cell(f"{pk.get('size_gb', 0):.1f}", pk.get("size_gb") or 0, "num"), + _cell(f"{pk.get('gbh', 0):.2f}", pk.get("gbh") or 0, "num"), + _cell(f"{r['dscore']:+,}", r["dscore"], "num"), + _cell(f"{r['dsize']:+.1f}", r["dsize"], "num"), + _cell(f"{r['dgbh']:+.2f}", r["dgbh"], "num"), + ] + ) + picks = "
    Click a movie title to see all its in-window candidates.
    " + _tbl( + "p-tbl", cols, prows + ) + + # Candidates drill-down: every in-window candidate for every movie, annotated. + ccols = [ ("Movie", False), ("Profile", False), - ("Cur GB", True), - ("Cur score", True), - ("Rel score", True), + ("Status", False), + ("Score", True), ("Res", True), - ("GiB/h", True), - ("Rel GB", True), - ("Rej", False), - ("Picked by", False), - ("Title", False), + ("gb/h", True), + ("GB", True), ] - rrows = [] - for rec in records: - cf = rec["cur"] or {} - for x in rec["releases"]: - if (x.get("score") or -1) < 0: - continue - pby = "".join( - initials[p] - for p in presets - if rec["picks"][p].get("pick", {}).get("title") == x.get("title") - ) - rrows.append( + crows = [] + for r in records: + for c in r["cands"]: + crows.append( [ - _cell(rec["title"], cls="txt"), - _cell(rec["profile"]), - _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), - _cell(f"{cf.get('score') or 0:,}", cf.get("score") or 0, "num"), - _cell(f"{x.get('score', 0):,}", x.get("score") or 0, "num"), - _cell(f"{x.get('resolution') or '?'}p", x.get("resolution") or 0, "num"), - _cell(f"{x.get('gbh', 0):.2f}", x.get("gbh") or 0, "num"), - _cell(f"{x.get('size_gb', 0):.1f}", x.get("size_gb") or 0, "num"), - _cell("Y" if (x.get("rejections") or x.get("temporarily_rejected")) else ""), - _cell(pby), - _cell(x.get("title"), cls="txt"), + _cell(r["title"], cls="txt"), + _cell(r["profile"]), + _cell(c["status"], cls=c["status"]), + _cell(f"{c['score']:,}", c["score"], "num"), + _cell(f"{c['res']}p", c["res"], "num"), + _cell(f"{c['gbh']:.2f}", c["gbh"], "num"), + _cell(f"{c['size_gb']:.1f}", c["size_gb"], "num"), ] ) - releases_html = _tbl("rel-tbl", rcols, rrows) - - # --- Stats tab --- - sh = _shift_data(records) - cur = sh["cur"] or 1 - net_on, net_off = sh["on"] - sh["cur"], sh["off"] - sh["cur"] - cards = ( - f"
    " - f"
    current library
    {_gb(sh['cur'])}
    " - f'
    guard ON
    {_gb(net_on)}
    ' - f"{100 * net_on / cur:+.1f}%
    " - f'
    guard OFF
    {_gb(net_off)}
    ' - f"{100 * net_off / cur:+.1f}%
    " - f"
    cost of dropping rule" - f'
    +{_gb(sh["off"] - sh["on"])}
    ' - f"{sh['movies']} movies · {sh['pts']:,} pts regression
    " - ) - shift_tbl = _tbl( - "shift-tbl", - [ - ("inflate guard", False), - ("new total", True), - ("net shift", True), - ("net %", True), - ("grown", True), - ("shrunk", True), - ], - [ - [ - _cell("ON (ship default)"), - _cell(_gb(sh["on"]), sh["on"], "num"), - _cell(_gb(net_on), net_on, "num"), - _cell(f"{100 * net_on / cur:+.1f}%", net_on, "num"), - _cell("+" + _gb(sh["on_g"]), sh["on_g"], "num"), - _cell("-" + _gb(sh["on_s"]), sh["on_s"], "num"), - ], - [ - _cell("OFF (drop the rule)"), - _cell(_gb(sh["off"]), sh["off"], "num"), - _cell(_gb(net_off), net_off, "num"), - _cell(f"{100 * net_off / cur:+.1f}%", net_off, "num"), - _cell("+" + _gb(sh["off_g"]), sh["off_g"], "num"), - _cell("-" + _gb(sh["off_s"]), sh["off_s"], "num"), - ], - ], - ) - ex = "".join( - f"
  • −{d:,} pts, +{g:.1f} GB — {html.escape(t)}
  • " for d, g, t in sh["examples"][:25] - ) - pp = _tbl( - "pp-tbl", - [ - ("preset", False), - ("ACT", True), - ("HOLD", True), - ("grew", True), - ("shrank", True), - ("Δsize% p50", True), - ("Δscore p50", True), - ], - [ - [ - _cell(d["preset"]), - _cell(d["act"], d["act"], "num"), - _cell(d["hold"], d["hold"], "num"), - _cell(d["grew"], d["grew"], "num"), - _cell(d["shrank"], d["shrank"], "num"), - _cell(_trip(d["dsize"], "+.0f"), _pct(d["dsize"], 50) if d["dsize"] else 0, "num"), - _cell( - _trip(d["dscore"], "+,.0f"), _pct(d["dscore"], 50) if d["dscore"] else 0, "num" - ), - ] - for d in _preset_data(records, presets) - ], - ) - inf_rows, ups = _inflation_data(records, presets) - inf = _tbl( - "inf-tbl", - [("preset", False), ("total blocked", True), ("same-res", True), ("RES-UPGRADE", True)], - [ - [ - _cell(d["preset"]), - _cell(d["total"], d["total"], "num"), - _cell(d["same"], d["same"], "num"), - _cell(d["up"], d["up"], "num"), - ] - for d in inf_rows - ], - ) - stats_html = ( - "

    Library size shift (every movie realigned to its own profile)

    " - + cards - + shift_tbl - + ( - "

    Biggest score regressions the rule prevents

      " + ex + "
    " - if ex - else "" - ) - + "

    Per-preset picks (vs current file)

    " - + pp - + "

    Swaps blocked by the no-bigger-at-lower-score rule

    " - + inf + cands_tab = ( + "
    In-window candidates (score within the downgrade budget). " + "PICK = chosen; wrong-res / below-floor / above-ceiling = filtered out.
    " + + _tbl("c-tbl", ccols, crows) ) tabs = [ - ("picks", "Picks", picks_html), - ("releases", "Releases", releases_html), - ("stats", "Stats", stats_html), + ("summary", "Summary", summary), + ("picks", "Picks (ACT)", picks), + ("cands", "Candidates", cands_tab), ] - tabbtns = "".join(f'' for tid, label, _ in tabs) - head = _HTML_HEAD.format( - n=len(records), ts=datetime.now().strftime("%Y-%m-%d %H:%M"), tabbtns=tabbtns + tabbtns = "".join(f'' for i, lbl, _ in tabs) + head = _HEAD.format( + n=s["movies"], ts=datetime.now().strftime("%Y-%m-%d %H:%M"), tabbtns=tabbtns ) - body = "".join( - f'' for tid, _, content in tabs + body = "".join(f'' for i, _, c in tabs) + out.write_text(head + body + _TAIL) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) + ap.add_argument("--out-dir", default="reports", type=Path) + args = ap.parse_args() + + rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] + t = Topsis(default_topsis()) + records = _evaluate(rows, t) + s = _summary(records) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + args.out_dir.mkdir(exist_ok=True) + _write_md(records, s, args.out_dir / f"diagnose_{ts}.md") + _write_html(records, s, args.out_dir / f"diagnose_{ts}.html") + print( + f"wrote diagnose_{ts}.{{md,html}} to {args.out_dir}/ — " + f"ACT={s['act']} satisfied={s['hold_satisfied']} too-few={s['hold_insufficient']} " + f"shift={s['size_shift_gb'] / 1024:+.2f}TB bug={len(s['bug'])}" ) - out.write_text(head + body + _HTML_TAIL) if __name__ == "__main__": diff --git a/tools/score_curve_fit.py b/tools/score_curve_fit.py deleted file mode 100644 index 436a0df..0000000 --- a/tools/score_curve_fit.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Explore logistic score-curve fits on different candidate populations (read-only, offline). - -The shipped `score_center` / `score_width` were fit to the pooled distribution of every release -with score >= 0. That includes a lot of releases the optimizer never actually has to choose -between (they fail the size band, or fall outside the per-item score-gap cluster). This tool -re-fits the logistic on tighter populations so we can see whether the curve should be calibrated -to what `n_score` really ranks: - - all_pos : every release with customFormatScore >= 0 (the current shipped basis). - gapcut : after eligible() + filter_by_score_gap() -- the post gap-cut candidates. - real : after the FULL prefilters (eligible + per-item size band + score gap), using each - item's resolved preset -- the real candidates that get scored and can be picked. - -For each population it fits a logistic to the empirical CDF, then replays decide() over the whole -dataset under that fitted curve and counts score-downgrades, so the practical effect is visible. -It NEVER touches Radarr/Sonarr or writes config; it only reads the training JSONL and writes a -markdown report. - - uv run python tools/score_curve_fit.py --in reports/training_data_radarr.jsonl -""" - -from __future__ import annotations - -import argparse -import json -import math -from dataclasses import replace -from datetime import datetime -from pathlib import Path - -from optimizarr.features.optimizer.config import default_topsis -from optimizarr.features.optimizer.decision import decide -from optimizarr.features.optimizer.topsis import Topsis, eligible - -KEY_SCORES = [600_000, 700_000, 800_000, 860_000, 900_000, 920_000, 949_600, 976_000, 993_000] - - -def _release_dict(r: dict) -> dict: - """Reconstruct the Radarr-shaped release dict decide()/Topsis expect from a training row.""" - return { - "guid": r.get("title"), - "indexerId": 1, - "title": r.get("title"), - "customFormatScore": r.get("score"), - "quality": {"quality": {"resolution": r.get("resolution") or 0}}, - "size": r.get("size_bytes") or 0, - "rejections": r.get("rejections") or [], - "temporarilyRejected": bool(r.get("temporarily_rejected")), - } - - -def _current_dict(cf: dict | None) -> dict | None: - if not cf or cf.get("score") is None: - return None - return { - "id": 1, - "customFormatScore": cf.get("score"), - "size": cf.get("size_bytes") or 0, - "quality": {"quality": {"resolution": cf.get("resolution") or 0}}, - } - - -def populations(rows: list[dict], topsis: Topsis) -> dict[str, list[int]]: - """Pooled candidate scores for each population definition.""" - all_pos: list[int] = [] - gapcut: list[int] = [] - real: list[int] = [] - for r in rows: - rels = [_release_dict(x) for x in r["releases"]] - rt = r["runtime_h"] - all_pos += [x["customFormatScore"] for x in rels if (x["customFormatScore"] or -1) >= 0] - gap = topsis.filter_by_score_gap(eligible(rels)) - gapcut += [x["customFormatScore"] for x in gap] - resolved = topsis.resolve_profile(r["profile"]["name"]) - kept, _ = topsis.apply_prefilters(rels, rt, resolved.reference) - real += [x["customFormatScore"] for x in kept] - return {"all_pos": sorted(all_pos), "gapcut": sorted(gapcut), "real": sorted(real)} - - -def _pct(a: list[int], p: float) -> float: - if not a: - return float("nan") - k = (len(a) - 1) * p - f = int(k) - c = min(f + 1, len(a) - 1) - return a[f] + (a[c] - a[f]) * (k - f) - - -def fit_logistic(scores: list[int]) -> tuple[int, int]: - """Grid least-squares fit of n=1/(1+exp(-(s-c)/w)) to the population's empirical CDF.""" - import bisect - - n = len(scores) - lo, hi = scores[0], scores[-1] - xs = list(range(int(lo), int(hi) + 1, 5000)) or [lo] - emp = [bisect.bisect_right(scores, x) / n for x in xs] - best: tuple[float, int, int] | None = None - for c in range(650_000, 960_001, 2500): - for w in range(20_000, 140_001, 2500): - sse = sum( - (1 / (1 + math.exp(-(x - c) / w)) - e) ** 2 for x, e in zip(xs, emp, strict=True) - ) - if best is None or sse < best[0]: - best = (sse, c, w) - assert best is not None - return best[1], best[2] - - -def logistic(s: float, c: float, w: float) -> float: - return 1 / (1 + math.exp(-(s - c) / w)) - - -def replay_downgrades(rows: list[dict], topsis: Topsis) -> tuple[int, int, int]: - """Replay decide() over the dataset; return (ACT, score-downgrades, downgrades >= 20k).""" - act = dn = big = 0 - for r in rows: - cur = _current_dict(r.get("current_file")) - if cur is None: - continue - d = decide( - topsis, - [_release_dict(x) for x in r["releases"]], - r["runtime_h"], - r["profile"]["name"], - r["profile"]["target_resolution"], - cur, - ) - if d.action != "ACT": - continue - act += 1 - ps, cs = (d.pick or {}).get("score"), cur["customFormatScore"] - if ps is not None and cs is not None and ps < cs: - dn += 1 - if cs - ps >= 20_000: - big += 1 - return act, dn, big - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) - ap.add_argument("--out", type=Path) - args = ap.parse_args() - - rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] - cfg = default_topsis() - topsis = Topsis(cfg) - pops = populations(rows, topsis) - - lines: list[str] = [] - w = lines.append - w("# Score-curve fit per candidate population\n") - w(f"Source: `{args.inp}` ({len(rows)} movies) | generated {datetime.now():%Y-%m-%d %H:%M}\n") - w(f"Shipped default: center={int(cfg.score_center):,} width={int(cfg.score_width):,}\n") - - w("\n## Population sizes and percentiles\n") - w("| population | n | p25 | p50 | p75 | p90 | p95 | max |") - w("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") - for name, sc in pops.items(): - w( - f"| {name} | {len(sc):,} | " - + " | ".join(f"{_pct(sc, p):,.0f}" for p in (0.25, 0.5, 0.75, 0.90, 0.95, 1.0)) - + " |" - ) - - fits = {name: fit_logistic(sc) for name, sc in pops.items()} - w("\n## Fitted logistic per population\n") - w("| population | center | width |") - w("| --- | ---: | ---: |") - for name, (c, wd) in fits.items(): - w(f"| {name} | {c:,} | {wd:,} |") - - w("\n## n_score at key scores (each fit + shipped default)\n") - header = "| score | " + " | ".join(fits) + " | shipped |" - w(header) - w("| ---: " + "| ---: " * (len(fits) + 1) + "|") - for s in KEY_SCORES: - row = f"| {s:,} | " - row += " | ".join(f"{logistic(s, c, wd):.3f}" for c, wd in fits.values()) - row += f" | {logistic(s, cfg.score_center, cfg.score_width):.3f} |" - w(row) - - w("\n## Replay impact (decide() over the whole dataset)\n") - w("| curve | center | width | ACT | score-downgrades | >=20k |") - w("| --- | ---: | ---: | ---: | ---: | ---: |") - variants = {**fits, "shipped": (int(cfg.score_center), int(cfg.score_width))} - for name, (c, wd) in variants.items(): - t = Topsis(replace(cfg, score_center=float(c), score_width=float(wd))) - act, dn, big = replay_downgrades(rows, t) - w(f"| {name} | {c:,} | {wd:,} | {act} | {dn} | {big} |") - tl = Topsis(replace(cfg, score_norm="linear")) - act, dn, big = replay_downgrades(rows, tl) - w(f"| linear[0,1M] | - | - | {act} | {dn} | {big} |") - - out = args.out or Path("reports") / f"score_curve_fit_{datetime.now():%Y%m%d_%H%M%S}.md" - out.parent.mkdir(exist_ok=True) - out.write_text("\n".join(lines) + "\n") - print(f"wrote {out}") - print("\n".join(lines)) - - -if __name__ == "__main__": - main() diff --git a/tools/size_band_lab.py b/tools/size_band_lab.py deleted file mode 100644 index cac8f64..0000000 --- a/tools/size_band_lab.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Phase-1 experiment for the size-model redesign (read-only, offline). NO engine change. - -Compares two candidate size models, both 2-axis TOPSIS (logistic n_score from the shipped config + -a 5-point trapezoid n_size, resolution dropped to a guard) over the harvested 250-movie set: - - A. absolute bands + outlier prefilter - per-resolution {floor, lo, target, hi, ceiling} GiB/h - placed from the GLOBAL size percentiles, same for every movie; plus a relative outlier drop. - B. relative percentile - per movie, the band is placed from THAT movie's own candidate size - distribution, so it scales with encode difficulty. - -For each model it reports, per profile: which release each profile would pick, whether the five -profiles diverge, whether the pick is the top-score release inside the band (score is driving), -whether any pick is a lone-small outlier, where picks land in the size distribution, and a -varying-candidate-set oscillation count (re-grabs when the visible candidate set is resampled). - - uv run python tools/size_band_lab.py --in reports/training_data_radarr.jsonl -""" - -from __future__ import annotations - -import argparse -import json -import math -import random -import statistics -from collections import Counter -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -from optimizarr.features.optimizer.config import default_topsis -from optimizarr.features.optimizer.topsis import HARD_REJECT_KEYWORDS, Topsis - -# profile -> (score_weight, size_weight, anchor percentile of the size distribution). -# Weights are the shipped 3-axis weights with resolution dropped and re-summed to 1.0. -PROFILES: dict[str, tuple[float, float, float]] = { - "Remux": (0.94, 0.06, 92), - "Quality": (0.86, 0.14, 77), - "Balanced": (0.56, 0.44, 50), - "Efficient": (0.44, 0.56, 30), - "Compact": (0.22, 0.78, 10), -} -HALF_WIDTH_PCT = 15.0 # band half-width in percentile points around the anchor -OUTLIER_FRAC = 0.5 # drop a release whose gbh < OUTLIER_FRAC * median(cluster gbh) -SHOULDER = 0.75 # n_size at lo/hi - - -@dataclass -class Cand: - score: int - gbh: float - res: int - size_gb: float - title: str - - -def _eligible(rels: list[dict]) -> list[Cand]: - """Hard-rejection + non-negative-score filter, mapped to simple Cand records.""" - out: list[Cand] = [] - for r in rels: - if r.get("temporarily_rejected"): - continue - if any( - any(k in reason for k in HARD_REJECT_KEYWORDS) for reason in (r.get("rejections") or []) - ): - continue - s = r.get("score") - if s is None or s < 0: - continue - out.append( - Cand( - s, - float(r.get("gbh") or 0.0), - int(r.get("resolution") or 0), - float(r.get("size_gb") or 0.0), - r.get("title") or "?", - ) - ) - return out - - -def _gap_cut(cands: list[Cand], gap: float) -> list[Cand]: - """Keep the top score cluster down to the first relative drop > gap.""" - srt = sorted(cands, key=lambda c: -c.score) - if not srt: - return [] - kept = [srt[0]] - for prev, cur in zip(srt, srt[1:], strict=False): - if prev.score > 0 and (prev.score - cur.score) / prev.score > gap: - break - kept.append(cur) - return kept - - -def _pct(sorted_vals: list[float], p: float) -> float: - if not sorted_vals: - return 0.0 - k = (len(sorted_vals) - 1) * (p / 100.0) - f = int(k) - c = min(f + 1, len(sorted_vals) - 1) - return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f) - - -def _rank_pct(sorted_vals: list[float], x: float) -> float: - """Percentile position of x within sorted_vals (0..100).""" - if not sorted_vals: - return 0.0 - below = sum(1 for v in sorted_vals if v < x) - return 100.0 * below / len(sorted_vals) - - -def trapezoid( - gbh: float, floor: float, lo: float, target: float, hi: float, ceiling: float -) -> float: - """5-point trapezoid: 0 outside [floor, ceiling], SHOULDER at lo/hi, 1.0 at target.""" - if gbh <= floor or gbh >= ceiling: - return 0.0 - if gbh < lo: - return SHOULDER * (gbh - floor) / (lo - floor) if lo > floor else SHOULDER - if gbh <= target: - return SHOULDER + (1 - SHOULDER) * (gbh - lo) / (target - lo) if target > lo else 1.0 - if gbh <= hi: - return 1.0 - (1 - SHOULDER) * (gbh - target) / (hi - target) if hi > target else 1.0 - return SHOULDER * (ceiling - gbh) / (ceiling - hi) if ceiling > hi else SHOULDER - - -def closeness(n_score: float, n_size: float, w_score: float, w_size: float) -> float: - d_ideal = math.sqrt(w_score * (1 - n_score) ** 2 + w_size * (1 - n_size) ** 2) - d_anti = math.sqrt(w_score * n_score**2 + w_size * n_size**2) - tot = d_ideal + d_anti - return 0.0 if tot == 0 else d_anti / tot - - -def band_from_pcts( - vals_sorted: list[float], anchor: float -) -> tuple[float, float, float, float, float]: - """5 band points (gbh) for an anchor percentile over a sorted gbh population.""" - floor = _pct(vals_sorted, 2) - ceiling = _pct(vals_sorted, 98) - lo = _pct(vals_sorted, max(2, anchor - HALF_WIDTH_PCT)) - target = _pct(vals_sorted, anchor) - hi = _pct(vals_sorted, min(98, anchor + HALF_WIDTH_PCT)) - # keep strictly increasing for the trapezoid - lo = max(lo, floor + 1e-6) - hi = max(hi, target + 1e-6) - ceiling = max(ceiling, hi + 1e-6) - return floor, lo, target, hi, ceiling - - -def drop_outliers(cands: list[Cand]) -> list[Cand]: - if len(cands) < 3: - return cands - med = statistics.median(c.gbh for c in cands) - return [c for c in cands if c.gbh >= OUTLIER_FRAC * med] - - -class Model: - """A size model: produces band points for a candidate set + profile anchor.""" - - def __init__(self, name: str, global_pcts: dict[int, list[float]]): - self.name = name - self.global_pcts = global_pcts # res -> sorted gbh list (for absolute model) - - def _res_pop(self, res: int) -> list[float]: - if res in self.global_pcts: - return self.global_pcts[res] - # nearest defined resolution at/below, else any - below = [r for r in sorted(self.global_pcts) if r <= res] - key = below[-1] if below else sorted(self.global_pcts)[0] - return self.global_pcts[key] - - def band(self, cands: list[Cand], res: int, anchor: float) -> tuple[float, ...]: - if self.name == "absolute": - return band_from_pcts(self._res_pop(res), anchor) - pop = sorted(c.gbh for c in cands) or [0.0] - return band_from_pcts(pop, anchor) - - -def pick( - topsis: Topsis, - model: Model, - cands: list[Cand], - res: int, - anchor: float, - w_score: float, - w_size: float, -) -> Cand | None: - if not cands: - return None - floor, lo, target, hi, ceiling = model.band(cands, res, anchor) - best, best_clo = None, -1.0 - for c in cands: - ns = topsis.normalize_score(c.score) - nz = trapezoid(c.gbh, floor, lo, target, hi, ceiling) - clo = closeness(ns, nz, w_score, w_size) - if clo > best_clo: - best, best_clo = c, clo - return best - - -def in_band_top_score(cands: list[Cand], band: tuple[float, ...]) -> Cand | None: - _floor, lo, _target, hi, _ceiling = band - inb = [c for c in cands if lo <= c.gbh <= hi] - return max(inb, key=lambda c: c.score) if inb else None - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--in", dest="inp", default="reports/training_data_radarr.jsonl", type=Path) - ap.add_argument("--out", type=Path) - ap.add_argument("--seed", type=int, default=0) - args = ap.parse_args() - - rng = random.Random(args.seed) - rows = [json.loads(line) for line in args.inp.read_text().splitlines() if line.strip()] - cfg = default_topsis() - topsis = Topsis(cfg) - gap = cfg.score_gap - - # Per-movie candidate set = eligible + gap-cut, restricted to the profile target resolution. - movies: list[tuple[int, list[Cand]]] = [] - res_pop: dict[int, list[float]] = {} - for r in rows: - tr = r["profile"]["target_resolution"] or 0 - cands = _gap_cut(_eligible(r["releases"]), gap) - cands = [c for c in cands if c.res == tr] or cands # fall back to all if none at tr - cands = drop_outliers(cands) - if len(cands) < 2: - continue - movies.append((tr, cands)) - for c in cands: - res_pop.setdefault(c.res, []).append(c.gbh) - for k in res_pop: - res_pop[k].sort() - - models = {"absolute": Model("absolute", res_pop), "relative": Model("relative", res_pop)} - - lines: list[str] = [] - w = lines.append - w("# Size-model experiment: absolute bands vs relative percentile\n") - stamp = datetime.now().strftime("%Y-%m-%d %H:%M") - w(f"Source `{args.inp}` ({len(rows)} movies, {len(movies)} usable) | {stamp}\n") - w(f"shoulder={SHOULDER}, half-width={HALF_WIDTH_PCT}pct, outlier_frac={OUTLIER_FRAC}\n") - - w("\n## Per-resolution candidate GiB/h percentiles (absolute bands placed from these)\n") - w("| res | n | P5 | P10 | P25 | P50 | P75 | P90 | P95 |") - w("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") - for res in sorted(res_pop, reverse=True): - v = res_pop[res] - w( - f"| {res} | {len(v)} | " - + " | ".join(f"{_pct(v, p):.1f}" for p in (5, 10, 25, 50, 75, 90, 95)) - + " |" - ) - - for mname, model in models.items(): - # picks[profile] = list of (movie_idx, pick, cands, band) - diverge = Counter() # distinct-pick-count per movie - score_driving = Counter() - outlier_hits = 0 - land_pct: dict[str, list[float]] = {p: [] for p in PROFILES} - for tr, cands in movies: - picks_this = {} - for prof, (ws, wz, anchor) in PROFILES.items(): - p = pick(topsis, model, cands, tr, anchor, ws, wz) - if p is None: - continue - picks_this[prof] = p - band = model.band(cands, tr, anchor) - top = in_band_top_score(cands, band) - if top is not None and p.title == top.title: - score_driving[prof] += 1 - pop = sorted(c.gbh for c in cands) - land_pct[prof].append(_rank_pct(pop, p.gbh)) - med = statistics.median(c.gbh for c in cands) - if p.gbh < OUTLIER_FRAC * med: - outlier_hits += 1 - diverge[len({p.title for p in picks_this.values()})] += 1 - - w(f"\n## Model: {mname}\n") - w("**Divergence** (distinct releases picked by the 5 profiles, per movie):") - w("") - w("| distinct picks | movies |") - w("| ---: | ---: |") - for k in sorted(diverge): - w(f"| {k} | {diverge[k]} |") - w(f"\n**Outlier picks** (pick gbh < {OUTLIER_FRAC}x median): {outlier_hits} (target: 0)\n") - w("\n**Score-driving** (pick == top-score release in band) + **landing pct**:\n") - w("| profile | anchor pct | score-driving | mean landing pct |") - w("| --- | ---: | ---: | ---: |") - for prof, (_ws, _wz, anchor) in PROFILES.items(): - n = len(land_pct[prof]) - sd = f"{100 * score_driving[prof] / n:.0f}%" if n else "-" - ml = f"{statistics.mean(land_pct[prof]):.0f}" if land_pct[prof] else "-" - w(f"| {prof} | {anchor:.0f} | {sd} | {ml} |") - - # Oscillation: resample candidate set, count re-grabs. - regrabs = grabs = 0 - sample = movies if len(movies) <= 80 else rng.sample(movies, 80) - for tr, cands in sample: - if len(cands) < 4: - continue - for _prof, (ws, wz, anchor) in PROFILES.items(): - cur = rng.choice(cands) - left: set[str] = set() - for _ in range(40): - subset = [c for c in cands if rng.random() < 0.75] or cands - if cur.title not in {c.title for c in subset}: - subset = subset + [cur] - p = pick(topsis, model, subset, tr, anchor, ws, wz) - if p is None or p.title == cur.title: - continue - # swap only on a real closeness gain under the CURRENT subset - band = model.band(subset, tr, anchor) - nz_cur = trapezoid(cur.gbh, *band) - nz_p = trapezoid(p.gbh, *band) - clo_cur = closeness(topsis.normalize_score(cur.score), nz_cur, ws, wz) - clo_p = closeness(topsis.normalize_score(p.score), nz_p, ws, wz) - if clo_p < clo_cur + cfg.default_min_closeness_gain: - continue - grabs += 1 - if p.title in left: - regrabs += 1 - left.add(cur.title) - cur = p - w( - f"\n**Oscillation** (resampled-set walk, {len(sample)} movies x 5 profiles): " - f"{grabs} grabs, **{regrabs} re-grabs** (target: 0)\n" - ) - - out = args.out or Path("reports") / f"size_band_lab_{datetime.now():%Y%m%d_%H%M%S}.md" - out.parent.mkdir(exist_ok=True) - out.write_text("\n".join(lines) + "\n") - print(f"wrote {out}") - print("\n".join(lines)) - - -if __name__ == "__main__": - main() diff --git a/tools/size_cohorts.py b/tools/size_cohorts.py deleted file mode 100644 index a8f9287..0000000 --- a/tools/size_cohorts.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Size-per-hour (GiB/h) stats for specific release cohorts, from a gather_training_data.py set. - -Rejected/temporarily-rejected releases are INCLUDED (no quality filtering). Dropped by default: -raw sources (remux + BR-DISK, which inflate size; --include-raw keeps them), non-H.264/H.265 -codecs (AV1/XviD/DivX/VC-1/etc; --any-codec keeps them), negative-score releases -(--include-negative keeps them), and releases with an unknown runtime (gbh = 0). Each drop count -is reported. - -Cohorts: - A. Profile-matched, high score: target profile resolution == result resolution, score > --score - (default 800000). One row for 2160p, one for 1080p. - B. By result resolution, any profile, any score: 2160p, 1080p, 720p, 480p. - -Stats per cohort: n, mean, median, p5, p10, p25, p75, p90, p95, min, max. - -Run: uv run python tools/size_cohorts.py reports/training_data_radarr.jsonl -Writes a timestamped Markdown report under ./reports/ and prints it. -""" - -from __future__ import annotations - -import argparse -import json -import math -import statistics as st -from datetime import datetime -from pathlib import Path - - -def _load(path: Path) -> list[dict]: - lines = path.read_text(encoding="utf-8").splitlines() - return [json.loads(ln) for ln in lines if ln.strip()] - - -def _is_raw(r: dict) -> bool: - """Raw, non-encode sources (remux lossless rips and full BR-DISK) inflate size stats, so - they're dropped by default. Remux: quality name 'Remux-*' or 'remux' in the title. BR-DISK: - Radarr's full-disc quality name.""" - name = (r.get("quality_name") or "").lower() - title = (r.get("title") or "").lower() - return "remux" in name or "remux" in title or "br-disk" in name - - -def _codec(r: dict) -> str: - """Classify the title's video codec: 'hevc' (x265/H.265), 'avc' (x264/H.264), or 'other' - (AV1, XviD/DivX, VC-1, MPEG-2, or unidentifiable).""" - t = (r.get("title") or "").lower().replace(".", "") - if "x265" in t or "h265" in t or "hevc" in t: - return "hevc" - if "x264" in t or "h264" in t or "avc" in t: - return "avc" - return "other" - - -def _pct(sorted_vals: list[float], p: float) -> float: - """Linear-interpolated percentile (p in [0,1]) over an already-sorted list.""" - if len(sorted_vals) == 1: - return sorted_vals[0] - k = (len(sorted_vals) - 1) * p - lo, hi = math.floor(k), math.ceil(k) - if lo == hi: - return sorted_vals[int(k)] - return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (k - lo) - - -def _summary(values: list[float]) -> dict: - n = len(values) - if n == 0: - return {"n": 0} - s = sorted(values) - return { - "n": n, - "mean": st.mean(s), - "median": st.median(s), - "p5": _pct(s, 0.05), - "p10": _pct(s, 0.10), - "p25": _pct(s, 0.25), - "p75": _pct(s, 0.75), - "p90": _pct(s, 0.90), - "p95": _pct(s, 0.95), - "min": s[0], - "max": s[-1], - } - - -def _row(label: str, s: dict) -> str: - if s["n"] == 0: - return f"| {label} | 0 |" + " |" * 11 - return ( - f"| {label} | {s['n']} | {s['mean']:.2f} | {s['median']:.2f} | {s['p5']:.2f} | " - f"{s['p10']:.2f} | {s['p25']:.2f} | {s['p75']:.2f} | {s['p90']:.2f} | {s['p95']:.2f} | " - f"{s['min']:.2f} | {s['max']:.2f} |" - ) - - -HEADER = ( - "| cohort | n | mean | median | p5 | p10 | p25 | p75 | p90 | p95 | min | max |\n" - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" -) - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("dataset", type=Path, help="JSONL from gather_training_data.py") - ap.add_argument("--score", type=float, default=800_000.0, help="group-A score-above threshold") - ap.add_argument("--include-raw", action="store_true", help="keep remux + BR-DISK") - ap.add_argument( - "--any-codec", action="store_true", help="keep all codecs (default: H.264/H.265)" - ) - ap.add_argument("--include-negative", action="store_true", help="keep score < 0 releases") - args = ap.parse_args() - - def label_a(res: int) -> str: - return f"{res}p profile + {res}p result, score > {args.score:,.0f}" - - records = _load(args.dataset) - - # cohort name -> list of gbh - a: dict[str, list[float]] = {} # profile-matched + high score - b: dict[int, list[float]] = {} # by result resolution, any profile/score - skipped_no_runtime = skipped_raw = skipped_codec = skipped_neg = 0 - - for rec in records: - target = (rec.get("profile") or {}).get("target_resolution") - for r in rec.get("releases", []): - if not args.include_raw and _is_raw(r): - skipped_raw += 1 - continue - if not args.any_codec and _codec(r) == "other": - skipped_codec += 1 - continue - score = r.get("score") - if not args.include_negative and score is not None and score < 0: - skipped_neg += 1 - continue - gbh = r.get("gbh") or 0 - if gbh <= 0: # unknown runtime: cannot form a size-per-hour value - skipped_no_runtime += 1 - continue - res = r.get("resolution") or 0 - b.setdefault(res, []).append(gbh) - if target == res and res in (2160, 1080) and score is not None and score > args.score: - a.setdefault(label_a(res), []).append(gbh) - - md: list[str] = [ - "# Size-per-hour (GiB/h) cohorts", - "", - f"- dataset: `{args.dataset}` items: {len(records)}", - f"- raw sources (remux + BR-DISK): " - f"{'INCLUDED' if args.include_raw else f'excluded ({skipped_raw} dropped)'}", - f"- codec: {'ALL' if args.any_codec else f'H.264/H.265 only ({skipped_codec} dropped)'}", - f"- negative scores: {'INCLUDED' if args.include_negative else f'dropped ({skipped_neg})'}", - f"- rejected releases included; {skipped_no_runtime} dropped for unknown runtime", - "", - "## A. Profile-matched, score above threshold", - "", - HEADER, - ] - for res in (2160, 1080): - md.append(_row(label_a(res), _summary(a.get(label_a(res), [])))) - - md += [ - "", - "## B. All results by resolution (any profile, any score)", - "", - HEADER, - ] - for res in (2160, 1080, 720, 480): - md.append(_row(f"all {res}p results", _summary(b.get(res, [])))) - - reports = Path("reports") - reports.mkdir(exist_ok=True) - out = reports / f"size_cohorts_{datetime.now():%Y%m%d_%H%M%S}.md" - text = "\n".join(md) + "\n" - out.write_text(text, encoding="utf-8") - print(text) - print(f"Wrote {out}") - - -if __name__ == "__main__": - main() diff --git a/tools/training_stats.py b/tools/training_stats.py deleted file mode 100644 index 3a57daf..0000000 --- a/tools/training_stats.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Derive size-model stats from a training set gathered by tools/gather_training_data.py. - -Reads the JSONL dataset and computes, with no reference to the current decision logic: - - 1. GiB/h distribution per resolution (count, mean, median, p25, p75, min, max) - the basis for - retuning [optimizer.topsis.reference] floor/target/ceiling. - 2. GiB/h per (resolution x score bracket) - how size correlates with quality, so floors/targets - can be reasoned about per quality tier. Brackets are fractions of --score-ideal. - 3. Size growth/shrink: current library file GiB/h vs the candidate pool, per resolution, so you - can extrapolate whether realigning toward a new target would grow or shrink files. - -Run: uv run python tools/training_stats.py reports/training_data_radarr.jsonl - -Writes a timestamped Markdown report under ./reports/ and prints a short summary. -""" - -from __future__ import annotations - -import argparse -import json -import statistics as st -from datetime import datetime -from pathlib import Path - -# Score brackets as fractions of score_ideal; "neg" and "unknown" are handled separately. -BRACKETS = [(0.0, 0.2), (0.2, 0.4), (0.4, 0.6), (0.6, 0.8), (0.8, 1.0), (1.0, float("inf"))] - - -def _bracket_label(lo: float, hi: float) -> str: - return f">={lo:.0%}" if hi == float("inf") else f"{lo:.0%}-{hi:.0%}" - - -# Sort order for brackets in the output (neg first, then ascending, unknown last). -BRACKET_ORDER = {_bracket_label(lo, hi): i for i, (lo, hi) in enumerate(BRACKETS)} -BRACKET_ORDER["neg"] = -1 -BRACKET_ORDER["unknown"] = 99 - - -def _load(path: Path) -> list[dict]: - lines = path.read_text(encoding="utf-8").splitlines() - return [json.loads(ln) for ln in lines if ln.strip()] - - -def _is_raw(r: dict) -> bool: - """Raw, non-encode sources (remux + full BR-DISK) inflate size stats; dropped by default.""" - name = (r.get("quality_name") or "").lower() - return "remux" in name or "remux" in (r.get("title") or "").lower() or "br-disk" in name - - -def _codec(r: dict) -> str: - """Title video codec: 'hevc' (x265/H.265), 'avc' (x264/H.264), or 'other'.""" - t = (r.get("title") or "").lower().replace(".", "") - if "x265" in t or "h265" in t or "hevc" in t: - return "hevc" - if "x264" in t or "h264" in t or "avc" in t: - return "avc" - return "other" - - -def _bracket(score: float | None, ideal: float) -> str: - if score is None: - return "unknown" - if score < 0: - return "neg" - frac = score / ideal - for lo, hi in BRACKETS: - if lo <= frac < hi: - return _bracket_label(lo, hi) - return _bracket_label(*BRACKETS[-1]) - - -def _summary(values: list[float]) -> dict: - n = len(values) - if n == 0: - return {"n": 0} - s = sorted(values) - return { - "n": n, - "mean": st.mean(s), - "median": st.median(s), - "p25": s[int(0.25 * (n - 1))], - "p75": s[int(0.75 * (n - 1))], - "min": s[0], - "max": s[-1], - } - - -def _cells(s: dict) -> str: - """The 7 numeric cells (n, mean, median, p25, p75, min, max) without leading/trailing pipes.""" - if s["n"] == 0: - return "0 | | | | | | " - return ( - f"{s['n']} | {s['mean']:.2f} | {s['median']:.2f} | {s['p25']:.2f} | " - f"{s['p75']:.2f} | {s['min']:.2f} | {s['max']:.2f}" - ) - - -def _res(res: int) -> str: - return f"{res}p" if res else "?" - - -def _bucket_res(height: int) -> int: - """Snap a raw pixel height to a standard resolution bucket. Current-file resolution comes from - mediaInfo (e.g. 1632/858 for scope framing); candidate resolution is already standard, so this - is a no-op there and only realigns the current-file side.""" - if not height: - return 0 - if height >= 1601: - return 2160 - if height >= 721: - return 1080 - if height >= 620: - return 720 - if height >= 400: - return 480 - return height - - -def _num(value: float | None, fmt: str = ".2f") -> str: - return format(value, fmt) if value is not None else "" - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("dataset", type=Path, help="JSONL from gather_training_data.py") - ap.add_argument("--score-ideal", type=float, default=1_000_000.0, help="score == 1.0 fraction") - ap.add_argument( - "--include-rejected", - action="store_true", - help="include hard/temporarily-rejected releases (default: drop them)", - ) - ap.add_argument("--include-raw", action="store_true", help="keep remux + BR-DISK") - ap.add_argument( - "--any-codec", action="store_true", help="keep all codecs (default: H.264/H.265)" - ) - ap.add_argument("--include-negative", action="store_true", help="keep score < 0 releases") - args = ap.parse_args() - - records = _load(args.dataset) - - rel_by_res: dict[int, list[float]] = {} - rel_by_res_bracket: dict[tuple[int, str], list[float]] = {} - score_by_res_bracket: dict[tuple[int, str], list[float]] = {} - cur_by_res: dict[int, list[float]] = {} - - n_releases = n_raw = n_codec = n_neg = 0 - for rec in records: - cur = rec.get("current_file") - if cur and cur.get("gbh"): - cur_by_res.setdefault(_bucket_res(cur.get("resolution") or 0), []).append(cur["gbh"]) - for r in rec.get("releases", []): - if not args.include_raw and _is_raw(r): - n_raw += 1 - continue - if not args.any_codec and _codec(r) == "other": - n_codec += 1 - continue - score = r.get("score") - if not args.include_negative and score is not None and score < 0: - n_neg += 1 - continue - if not args.include_rejected and (r.get("rejections") or r.get("temporarily_rejected")): - continue - gbh = r.get("gbh") - if not gbh: - continue - res = r.get("resolution") or 0 - n_releases += 1 - rel_by_res.setdefault(res, []).append(gbh) - b = _bracket(r.get("score"), args.score_ideal) - rel_by_res_bracket.setdefault((res, b), []).append(gbh) - if r.get("score") is not None: - score_by_res_bracket.setdefault((res, b), []).append(r["score"]) - - md: list[str] = [ - "# Training-set size stats", - "", - f"- dataset: `{args.dataset}`", - f"- items: {len(records)} releases analyzed: {n_releases}" - f"{' (incl. rejected)' if args.include_rejected else ' (rejected dropped)'}", - f"- raw (remux+BR-DISK): {'INCLUDED' if args.include_raw else f'dropped ({n_raw})'}; " - f"codec: {'ALL' if args.any_codec else f'H.264/H.265 only (dropped {n_codec})'}; " - f"negative scores: {'INCLUDED' if args.include_negative else f'dropped ({n_neg})'}", - f"- score_ideal: {args.score_ideal:,.0f}", - "", - "## 1. GiB/h per resolution (candidate releases)", - "", - "| resolution | n | mean | median | p25 | p75 | min | max |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - ] - for res in sorted(rel_by_res, reverse=True): - md.append(f"| {_res(res)} | {_cells(_summary(rel_by_res[res]))} |") - - md += [ - "", - "## 2. GiB/h per resolution x score bracket", - "", - "Bracket = customFormatScore as a fraction of score_ideal.", - "", - "| resolution | score bracket | n | mean | median | p25 | p75 | min | max | mean score |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", - ] - for res in sorted({k[0] for k in rel_by_res_bracket}, reverse=True): - keys = sorted( - (k for k in rel_by_res_bracket if k[0] == res), - key=lambda k: BRACKET_ORDER.get(k[1], 50), - ) - for k in keys: - scores = score_by_res_bracket.get(k, []) - mean_score = f"{st.mean(scores):,.0f}" if scores else "" - cells = _cells(_summary(rel_by_res_bracket[k])) - md.append(f"| {_res(res)} | {k[1]} | {cells} | {mean_score} |") - - md += [ - "", - "## 3. Size growth / shrink (current files vs candidate pool)", - "", - "Per resolution: mean GiB/h of existing library files vs the candidate releases. A current " - "mean above the pool mean means realigning toward the pool shrinks files; below it means " - "they grow.", - "", - "| resolution | current n | current mean | candidate mean | delta (cand - cur) |", - "| --- | --- | --- | --- | --- |", - ] - for res in sorted(set(cur_by_res) | set(rel_by_res), reverse=True): - cur = cur_by_res.get(res, []) - rel = rel_by_res.get(res, []) - cur_mean = st.mean(cur) if cur else None - rel_mean = st.mean(rel) if rel else None - delta = (rel_mean - cur_mean) if (cur_mean is not None and rel_mean is not None) else None - md.append( - f"| {_res(res)} | {len(cur)} | {_num(cur_mean)} | {_num(rel_mean)} | " - f"{_num(delta, '+.2f')} |" - ) - - reports = Path("reports") - reports.mkdir(exist_ok=True) - out = reports / f"training_stats_{datetime.now():%Y%m%d_%H%M%S}.md" - out.write_text("\n".join(md) + "\n", encoding="utf-8") - print(f"Wrote {out} ({len(records)} items, {n_releases} releases).") - - -if __name__ == "__main__": - main() diff --git a/tools/weight_lab.py b/tools/weight_lab.py deleted file mode 100644 index 350f71a..0000000 --- a/tools/weight_lab.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Weight lab: visualize how the presets score and pick releases under the new model. - -Drives the real engine: each preset's weights + 5-point size table {floor, lo, target, hi, -ceiling} + pick, and the closeness-gain swap rule (via the real decide()). Two modes: - - - default (synthetic): curated scenarios + a retention stress test, showing every candidate's - closeness under every preset (★ = the preset's pick; "drop" = excluded by that preset's size - band or the score gap-cut). - - --dataset PATH: drive a real gathered set (tools/gather_training_data.py JSONL). For a random - sample of movies it shows, per preset, the decision and the GiB/h it would land on — the best - sanity check for tuning the per-preset size tables. - -Run: uv run python tools/weight_lab.py - uv run python tools/weight_lab.py --dataset reports/training_data_radarr.jsonl --limit 25 -Writes a timestamped Markdown report under ./reports/ and prints a short summary. -""" - -from __future__ import annotations - -import argparse -import json -import random -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -from optimizarr.features.optimizer.config import ResolvedProfile, default_topsis -from optimizarr.features.optimizer.decision import decide -from optimizarr.features.optimizer.topsis import GB, Topsis, eligible - - -def _release(title: str, score: int, res: int, size_gb: float) -> dict: - return { - "title": title, - "customFormatScore": score, - "quality": {"quality": {"resolution": res}}, - "size": int(size_gb * GB), - "rejections": [], - } - - -def _as_file(release: dict) -> dict: - """Convert a release-shaped dict into the current-library-file shape decide() expects.""" - res = release["quality"]["quality"]["resolution"] - return { - "id": 1, - "customFormatScore": release["customFormatScore"], - "size": release["size"], - "mediaInfo": {"resolution": f"x{res}"}, - } - - -@dataclass -class Scenario: - name: str - target_res: int - runtime_h: float - current: dict - candidates: list[dict] - note: str = "" - - -SCENARIOS: list[Scenario] = [ - Scenario( - name="4K: remux vs web vs lean x265", - target_res=2160, - runtime_h=2.0, - current=_release("(current) mediocre 2160p", 700_000, 2160, 30.0), - candidates=[ - _release("2160p Remux", 1_000_000, 2160, 60.0), # 30 GiB/h - _release("2160p WEB-DL", 950_000, 2160, 24.0), # 12 GiB/h - _release("2160p x265", 930_000, 2160, 8.0), # 4 GiB/h, lean - ], - note="Remux takes the top-scoring remux (max_score); size-leaning presets swing to the " - "lean encodes. No profile is pushed bigger than a real score gain warrants.", - ), - Scenario( - name="Much-smaller current file must not be inflated", - target_res=2160, - runtime_h=2.0, - current=_release("(current) superb tiny x265", 950_000, 2160, 7.0), # 3.5 GiB/h - candidates=[ - _release("2160p WEB-DL bigger", 950_000, 2160, 22.0), # 11 GiB/h, same score - _release("2160p remux huge", 980_000, 2160, 60.0), # 30 GiB/h, slightly higher - ], - note="The current file is already tiny and good. Same-score-but-bigger is forbidden for " - "all; only Remux may take the higher-scoring big remux (every other preset holds).", - ), -] - -RETENTION_RUNTIME_H = 2.0 -RETENTION_TARGET = 2160 -RETENTION_PRESET = "Compact" # size-leaning: where small gems matter most - - -def _resolved(t: Topsis, name: str) -> ResolvedProfile: - return t.resolve_profile(name) - - -def _included(t: Topsis, releases: list[dict], runtime_h: float, rp: ResolvedProfile) -> set[int]: - """ids of releases that survive this preset's size band (floor..bloat) + the score gap-cut. - Inclusion is now per-preset, since each preset carries its own size table.""" - after_band = t.filter_by_size_band(eligible(releases), runtime_h, rp.reference) - return {id(r) for r in t.filter_by_score_gap(after_band)} - - -def _closeness( - t: Topsis, rp: ResolvedProfile, release: dict, runtime_h: float, target: int -) -> float: - return t.closeness(t.attributes_for(release, runtime_h, rp, target), rp.weights) - - -def render_scenario(t: Topsis, sc: Scenario) -> tuple[list[str], list[str]]: - presets = t.cfg.presets - rp = {name: _resolved(t, name) for name in presets} - included = {name: _included(t, sc.candidates, sc.runtime_h, rp[name]) for name in presets} - cur_clo = { - name: _closeness(t, rp[name], sc.current, sc.runtime_h, sc.target_res) for name in presets - } - cand_clo = { - id(r): {name: _closeness(t, rp[name], r, sc.runtime_h, sc.target_res) for name in presets} - for r in sc.candidates - } - # The real decision (gate + pick) per preset. - cur_file = _as_file(sc.current) - decisions = { - name: decide(t, sc.candidates, sc.runtime_h, name, sc.target_res, cur_file) - for name in presets - } - winner_title = { - name: (d.pick.get("title") if d.action == "ACT" and d.pick else None) - for name, d in decisions.items() - } - - md = [ - f"## {sc.name}", - "", - f"- target {sc.target_res}p · runtime {sc.runtime_h:g}h", - f"- {sc.note}", - "", - ] - header = ["release", "score", "res", "GiB/h", *presets] - md.append("| " + " | ".join(header) + " |") - md.append("|" + "|".join(["---"] * len(header)) + "|") - - def row(r: dict, clo: dict[str, float], dropped: dict[str, bool] | None) -> str: - gbh = (r["size"] / GB) / sc.runtime_h - res = r["quality"]["quality"]["resolution"] - cells = [r["title"], f"{r['customFormatScore']:,}", f"{res}p", f"{gbh:.1f}"] - for name in presets: - if dropped and dropped[name]: - cells.append("drop") - else: - val = f"{clo[name]:.3f}" - if winner_title[name] == r["title"]: - val = f"**{val} ★**" - cells.append(val) - return "| " + " | ".join(cells) + " |" - - md.append(row(sc.current, cur_clo, None) + " ← current") - for r in sc.candidates: - md.append(row(r, cand_clo[id(r)], {name: id(r) not in included[name] for name in presets})) - md.append("") - - md.append("**Decision per preset** (real gate + pick vs current):") - md.append("") - summary = [f" {sc.name}"] - for name in presets: - d = decisions[name] - title = d.pick.get("title") if d.pick else "—" - md.append(f"- `{name}`: {d.action} — {title} ({d.reason})") - summary.append(f" {name:<10} → {d.action:<4} {title}") - md.append("") - return md, summary - - -def _make_popular(rng: random.Random) -> list[dict]: - rel: list[dict] = [] - - def add(kind: str, count: int, lo: int, hi: int, size_lo: float, size_hi: float) -> None: - for _ in range(count): - score = min(1_000_000, rng.randint(lo, hi)) - size = round(rng.uniform(size_lo, size_hi), 1) - rel.append(_release(f"{kind} {size}GB s={score // 1000}k", score, 2160, size)) - - add("remux", 12, 880_000, 1_000_000, 45.0, 70.0) - add("bluray", 22, 860_000, 1_000_000, 26.0, 44.0) - add("web", 26, 850_000, 990_000, 15.0, 26.0) - add("x265", 14, 880_000, 970_000, 6.0, 12.0) # high score, small — the "gems" - add("web-mid", 12, 550_000, 840_000, 14.0, 24.0) - add("low", 4, 60_000, 480_000, 8.0, 30.0) - add("FAKE", 4, 900_000, 1_000_000, 1.0, 2.6) # tiny -> below the 2160 floor (3.0 GiB/h) - rng.shuffle(rel) - return rel - - -def _make_obscure() -> list[dict]: - return [ - _release("remux 55.0GB s=700k", 700_000, 2160, 55.0), - _release("web 18.0GB s=720k", 720_000, 2160, 18.0), - _release("x265 7.0GB s=690k", 690_000, 2160, 7.0), # small gem - _release("low 9.0GB s=380k", 380_000, 2160, 9.0), - _release("banned 10.0GB s=-30k", -30_000, 2160, 10.0), - ] - - -def render_retention(t: Topsis, name: str, releases: list[dict]) -> list[str]: - rp = _resolved(t, RETENTION_PRESET) - rt = RETENTION_RUNTIME_H - after_hard = eligible(releases) - after_band = t.filter_by_size_band(after_hard, rt, rp.reference) - kept = t.filter_by_score_gap(after_band) - - md = [f"### {name} (preset {RETENTION_PRESET})", ""] - md.append("| stage | releases |") - md.append("|---|---|") - md.append(f"| input | {len(releases)} |") - md.append(f"| after size band (floor..bloat) | {len(after_band)} |") - md.append(f"| after score gap-cut | {len(kept)} |") - md.append("") - - ranked = sorted(kept, key=lambda r: -_closeness(t, rp, r, rt, RETENTION_TARGET)) - md.append("Top 8 by closeness:") - md.append("") - md.append("| # | release | score | size GB (GiB/h) | closeness |") - md.append("|---|---|---|---|---|") - for i, r in enumerate(ranked[:8], 1): - gbh = (r["size"] / GB) / rt - clo = _closeness(t, rp, r, rt, RETENTION_TARGET) - md.append( - f"| {i} | {r['title']} | {r['customFormatScore']:,} | " - f"{r['size'] / GB:.1f} ({gbh:.1f}) | {clo:.3f} |" - ) - md.append("") - if kept: - smallest = min(kept, key=lambda r: r["size"]) - by_score = sorted(kept, key=lambda r: -r["customFormatScore"]) - s_rank = by_score.index(smallest) + 1 - c_rank = ranked.index(smallest) + 1 - verb = "wins" if c_rank == 1 else "ranks" - md.append( - f"Smallest survivor **{smallest['title']}**: #{s_rank} of {len(kept)} by score, " - f"#{c_rank} by closeness — survived the filter and {verb} on size." - ) - md.append("") - return md - - -# ----- real dataset mode (gather_training_data.py JSONL) ----- - - -def _rel_from_record(r: dict) -> dict: - return { - "title": r.get("title") or "?", - "customFormatScore": r.get("score") or 0, - "quality": {"quality": {"resolution": r.get("resolution") or 0}}, - "size": r.get("size_bytes") or 0, - "rejections": r.get("rejections") or [], - "temporarilyRejected": bool(r.get("temporarily_rejected")), - } - - -def _file_from_record(c: dict | None) -> dict | None: - if not c: - return None - return { - "id": 1, - "customFormatScore": c.get("score"), - "size": c.get("size_bytes") or 0, - "quality": {"quality": {"resolution": c.get("resolution") or 0}}, - } - - -def render_dataset_item(t: Topsis, rec: dict) -> tuple[list[str], list[str]]: - prof = rec.get("profile") or {} - pname, tres = prof.get("name"), prof.get("target_resolution") - rt = rec.get("runtime_h") or 0 - releases = [_rel_from_record(r) for r in rec.get("releases", [])] - cur_file = _file_from_record(rec.get("current_file")) - cur = rec.get("current_file") or {} - cur_gbh = cur.get("gbh", 0.0) or 0.0 - title = rec.get("title", "?") - - md = [ - f"### {title}", - "", - f"- profile `{pname}` · target {tres}p · runtime {rt:g}h · {len(releases)} releases", - f"- current: score={cur.get('score')} · {cur.get('resolution') or '?'}p · " - f"{cur_gbh:.2f} GiB/h", - "", - "| preset | decision | pick | pick score | pick GiB/h | Δsize | Δcloseness |", - "|---|---|---|---|---|---|---|", - ] - summary = [f" {title} (cur {cur_gbh:.1f} GiB/h)"] - for name in t.cfg.presets: - d = decide(t, releases, rt, name, tres, cur_file) - if d.action == "ACT" and d.pick: - p = d.pick - pgbh = p.get("gbh", 0.0) - c_clo = (d.current or {}).get("closeness") - dclo = ( - f"{p['closeness'] - c_clo:+.3f}" - if c_clo is not None and p.get("closeness") is not None - else "" - ) - ttl = p.get("title", "?") - ttl = ttl[:42] + "..." if len(ttl) > 45 else ttl - md.append( - f"| {name} | GRAB | {ttl} | {p.get('score') or 0:,} | {pgbh:.2f} | " - f"{pgbh - cur_gbh:+.2f} | {dclo} |" - ) - summary.append(f" {name:<10} GRAB {pgbh:5.1f} GiB/h {ttl}") - else: - md.append(f"| {name} | HOLD | — | | | | |") - summary.append(f" {name:<10} HOLD") - md.append("") - return md, summary - - -def run_dataset(t: Topsis, path: Path, limit: int, seed: int) -> tuple[list[str], list[str]]: - records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()] - random.Random(seed).shuffle(records) - sample = records[:limit] - md = [ - f"# Preset lab — real library sample ({len(sample)} of {len(records)} movies)", - "", - f"Generated {datetime.now().isoformat(timespec='seconds')} · dataset `{path}`", - "", - "Per movie: each preset's decision and the GiB/h it would land on, via the real decide() " - "over all gathered candidates (incl. remux). Lets you eyeball the per-preset size tables " - "on real releases.", - "", - ] - summaries: list[str] = [] - for rec in sample: - lines, summary = render_dataset_item(t, rec) - md.extend(lines) - summaries.extend(summary) - return md, summaries - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--dataset", type=Path, help="JSONL from gather_training_data.py") - ap.add_argument("--limit", type=int, default=25, help="movies to sample in --dataset mode") - ap.add_argument("--seed", type=int, default=0) - args = ap.parse_args() - - t = Topsis(default_topsis()) - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - - if args.dataset: - md, summaries = run_dataset(t, args.dataset, args.limit, args.seed) - out = Path("reports") / f"weight-lab-dataset-{ts}.md" - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text("\n".join(md) + "\n") - print(f"wrote {out}") - print("\n".join(summaries)) - return - - md: list[str] = [ - "# Preset lab", - "", - f"Generated {datetime.now().isoformat(timespec='seconds')}", - "", - "Each preset carries its own 5-point size table {floor, lo, target, hi, ceiling} GiB/h,", - "weights (score + size), and a pick method. A candidate is grabbed only if it raises", - "closeness by at least min_closeness_gain (and does not drop resolution below target, and", - "is not bigger at a lower-or-equal score). ★ = the preset's pick; 'drop' = excluded by", - "that preset's size band (floor..ceiling) or the score gap-cut.", - "", - "## Presets", - "", - "| preset | score | size | pick | gain | 2160 (floor/lo/target/hi/ceiling) | 1080 | 720 |", - "|---|---|---|---|---|---|---|---|", - ] - - def _band(p, res: int) -> str: - pts = p.reference.get(res) - return "/".join(f"{v:g}" for v in pts) if pts else "-" - - for name, p in t.cfg.presets.items(): - w = p.weights - md.append( - f"| {name} | {w['score']:.2f} | {w['size']:.2f} | " - f"{p.pick} | {p.min_closeness_gain:g} | {_band(p, 2160)} | {_band(p, 1080)} | " - f"{_band(p, 720)} |" - ) - md.append("") - md.append("# Part 1 — preset comparison on curated scenarios") - md.append("") - - summaries: list[str] = [] - for sc in SCENARIOS: - lines, summary = render_scenario(t, sc) - md.extend(lines) - summaries.extend(summary) - - md.append("# Part 2 — retention at scale") - md.append("") - summaries.append(" --- Part 2: retention ---") - rng = random.Random(7) - md.extend(render_retention(t, "Large popular-title pool", _make_popular(rng))) - md.extend(render_retention(t, "Small obscure-title pool", _make_obscure())) - - out = Path("reports") / f"weight-lab-{ts}.md" - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text("\n".join(md) + "\n") - print(f"wrote {out}") - print("\n".join(summaries)) - - -if __name__ == "__main__": - main() From 5dc3cf6d4d5a94d0f29c3ed1f938ecfb9dde7fd3 Mon Sep 17 00:00:00 2001 From: Patrick Veilleux Date: Sat, 6 Jun 2026 09:30:06 -0400 Subject: [PATCH 3/3] improve algo, add schedule --- ALGORITHM.md | 56 ++++++---- optimizarr/defaults.toml | 30 ++++- optimizarr/features/optimizer/config.py | 115 +++++++++++++------ optimizarr/features/optimizer/decision.py | 45 ++++++-- optimizarr/features/optimizer/topsis.py | 9 +- optimizarr/features/optimizer/worker.py | 52 ++++++++- tests/test_config.py | 65 +++++++++-- tests/test_decision.py | 14 +++ tests/test_topsis.py | 2 + tests/test_worker.py | 129 +++++++++++++++++++++- tools/diagnose.py | 69 ++++++++++-- 11 files changed, 479 insertions(+), 107 deletions(-) diff --git a/ALGORITHM.md b/ALGORITHM.md index 1983bea..b46a016 100644 --- a/ALGORITHM.md +++ b/ALGORITHM.md @@ -17,13 +17,12 @@ Three ideas carry the whole design: profile shrink a movie that another leaves alone. - **The forbidden quadrant.** Versus the current file a pick may be better+bigger (a real score upgrade), better+smaller (ideal), or a little worse+smaller (a deliberate size trade). It may - **never** be worse *and* bigger. That falls out for free: a candidate worse on both axes has a - lower TOPSIS closeness than the current file, and the optimizer only ACTs when a candidate beats - the current file. + **never** be worse *and* bigger. That falls out for free: a candidate worse on both axes must + improve by at least one concrete threshold (`min_score_delta` or `min_size_delta_gb`) to ACT, + which a worse+bigger candidate never can. - **One-and-done.** A movie is optimized once: when its current (imported) file is the best pick - for its profile it is marked *satisfied* and never re-evaluated. This is what makes relative - scoring safe — there is no re-evaluation loop to oscillate — and it removes the need for any - closeness-margin oscillation proof. + for its profile it is marked *satisfied* and never re-evaluated. This removes re-evaluation loops + that could oscillate. --- @@ -99,19 +98,19 @@ into the score, and filter 3 already pins it). `decision.py::decide`: resolve the profile → run the filters → relatively score the survivors → compare to the current file. -- ACT on the best candidate (by the profile's `pick` method, default `topsis` = highest closeness) - **iff** its closeness beats the current file's by `min_closeness_gain` (default `0.02`, a small - hysteresis). Two optional per-app pre-filters run first: `allow_size_increase = false` drops - anything bigger than the current file, `allow_quality_downgrade = false` drops anything +- ACT on the best candidate (highest TOPSIS closeness) **iff** it clears at least one concrete + threshold vs the current file: score improves by `>= min_score_delta` (default `100`) **or** + size shrinks by `>= min_size_delta_gb` (default `0.5 GB`). When there is no current file, any + candidate qualifies. Two optional per-app pre-filters run first: `allow_size_increase = false` + drops anything bigger than the current file, `allow_quality_downgrade = false` drops anything lower-scoring. - Otherwise **HOLD**. Two kinds: - - **satisfiable** — there were enough candidates and none beat the current file: it is already - optimal for its profile, so mark it satisfied (permanent). + - **satisfiable** — there were enough candidates and none cleared either threshold: the current + file is good enough for its profile, so mark it satisfied (permanent). - **insufficient** — fewer than `min_candidates`: do *not* satisfy; retry later. -The forbidden quadrant is impossible: a candidate that is both lower-score and bigger than the -current file is worse on both axes, so its closeness ≤ the current file's, so it never clears the -ACT margin. +The forbidden quadrant is impossible: a candidate worse on both axes (lower score, bigger size) +cannot clear either threshold and therefore never triggers ACT. --- @@ -125,22 +124,22 @@ score_window = 100000 # downgrade budget: keep score >= current - this min_candidates = 2 # below this, HOLD without satisfying outlier_frac = 0.5 # drop releases below 0.5x the movie's median GiB/h (junk guard) default_preset = "Balanced" # used when a profile name matches no preset keyword -min_closeness_gain = 0.02 # ACT margin over the current file (per-preset overridable) +min_score_delta = 100 # ACT only if pick.score - current.score >= this +min_size_delta_gb = 0.5 # ...or if current.size - pick.size >= this (in GB) [optimizer.topsis.size_bounds] # shared per-resolution {floor, ceiling} GiB/h legitimacy band -"2160" = { floor = 1.5, ceiling = 36.1 } -"1080" = { floor = 0.45, ceiling = 13.3 } +"2160" = { floor = 3.0, ceiling = 30.0 } +"1080" = { floor = 1.0, ceiling = 15.0 } # … 720 / 480 … [optimizer.topsis.presets.Efficient] -score = 0.44 # weights (score + size, sum 1.0) — the only per-profile knob -size = 0.56 -pick = "topsis" # topsis | max_score | min_size +score = 0.40 # weights (score + size, sum 1.0) — the only per-profile knob +size = 0.60 ``` A profile attaches to the preset whose name is a case-insensitive substring of the profile name -(`2160p Efficient` → Efficient); `[optimizer.topsis.profiles."Exact Name"]` overrides a preset, -weights, pick, or margin for one profile. +(`2160p Efficient` → Efficient); `[optimizer.topsis.profiles."Exact Name"]` overrides a preset +or its weights for one profile. --- @@ -177,6 +176,17 @@ stateDiagram-v2 eligible again only if its **profile changes** (the optimal pick depends on the profile) or its **file is removed**. To force a full re-run, delete (or edit) `state.json`. +### Active-hours schedule + +`[optimizer.schedule]` defines a per-day active window in local time (24h HH:MM). **Outside the +window the worker skips list refresh and movie evaluation, but queue import processing always +runs** (so completed downloads kept off the queue by auto-import are never blocked by the +schedule). + +A window where `start >= end` crosses midnight. The default ships `23:00` to `08:00` on every day, +meaning the optimizer queries indexers and evaluates movies overnight. Omit a day (or the entire +block) to treat that day as always active. Transitions are logged once at INFO. + --- ## 6. The Unmonitor job diff --git a/optimizarr/defaults.toml b/optimizarr/defaults.toml index 01861e6..ae8e1fd 100644 --- a/optimizarr/defaults.toml +++ b/optimizarr/defaults.toml @@ -92,6 +92,20 @@ allow_quality_downgrade = true ignore_completed_in_queue = true auto_import_downgrades = true +# Active-hours schedule: restrict when the optimizer fetches releases and evaluates movies. +# Outside the window the worker still processes the queue (auto-imports stuck downloads) but +# skips list refresh and per-movie evaluation. Times are in local timezone, 24h HH:MM format. +# A window where start >= end crosses midnight (e.g. 23:00 to 08:00 is active from 23:00 that +# day until 08:00 the next day). Omit a day or the whole block to be always active on that day. +[optimizer.schedule] +sunday = { start = "23:00", end = "08:00" } +monday = { start = "23:00", end = "08:00" } +tuesday = { start = "23:00", end = "08:00" } +wednesday = { start = "23:00", end = "08:00" } +thursday = { start = "23:00", end = "08:00" } +friday = { start = "23:00", end = "08:00" } +saturday = { start = "23:00", end = "08:00" } + # ===== TOPSIS ===== # Relative two-axis model. There are no per-preset size tables or shapes. For each movie: # 1. FILTER the obviously bad: drop negatives, apply three-tier score window (see below), keep @@ -112,7 +126,7 @@ score_window = 100000 # 1. Score-window tier: expand to the next tier if fewer than this survive the current one. # 2. TOPSIS gate: HOLD without satisfying if fewer than this remain after ALL filters (relative # min-max over a tiny pool is unreliable). The movie is retried when more releases appear. -min_candidates = 5 +min_candidates = 4 # Outlier drop: discard a candidate whose GiB/h is below outlier_frac x the median GiB/h of the # surviving cluster. This is the key guard against over-compressed / upscale "2160p" junk that # Profilarr still scores high on format tags (a real 2160p HEVC is ~4-8 GiB/h; junk is < 2). Without @@ -121,8 +135,14 @@ min_candidates = 5 outlier_frac = 0.5 # Preset used when a profile name matches no preset by keyword. default_preset = "Balanced" -# ACT margin: grab the best candidate only if its closeness beats the current file's by at least -# this. Hysteresis to avoid churning on a negligible improvement. +# ACT gate: grab the pick only when it clears at least one of these vs the current file. +# score must improve by at least min_score_delta, OR size must shrink by at least min_size_delta_gb. +# If neither threshold is met, the current file is good enough and the item is marked satisfied. +min_score_delta = 1000 +min_size_delta_gb = 1 +# Closeness gate (AND with the axis gate above): the pick's TOPSIS closeness must also improve +# by at least this amount vs the current file. Prevents grabbing when an axis moved just enough +# but the overall profile balance did not meaningfully shift. min_closeness_gain = 0.02 # Shared per-resolution legitimacy band {floor, ceiling} in GiB/h (GB = 1024^3). Below floor = @@ -136,7 +156,7 @@ min_closeness_gain = 0.02 "480" = { floor = 0.10, ceiling = 5 } # ----- Presets ----- -# Each preset = TOPSIS weights (score + size, sum 1.0) + an optional per-preset `min_closeness_gain`. +# Each preset = TOPSIS weights (score + size, sum 1.0). # The pick is always the highest-closeness candidate. Profiles attach to a preset whose name # is a (case-insensitive) substring of the profile name; e.g. "2160p Quality" -> Quality. A high # size weight (Compact) trades score for the smallest file; a high score weight (Remux) takes the @@ -162,7 +182,7 @@ size = 0.60 score = 0.25 size = 0.75 -# Per-profile-name overrides. Reference a preset, or override weights / pick / margin: +# Per-profile-name overrides. Reference a preset by name, or supply explicit weights: # [optimizer.topsis.profiles."2160p Remux"] # preset = "Remux" # [optimizer.topsis.profiles."My Custom 1080p"] diff --git a/optimizarr/features/optimizer/config.py b/optimizarr/features/optimizer/config.py index 7af71ae..168d702 100644 --- a/optimizarr/features/optimizer/config.py +++ b/optimizarr/features/optimizer/config.py @@ -8,13 +8,14 @@ shared per-resolution `size_bounds` legitimacy band, lone-small outliers). The survivors are then scored on TWO axes, each min-max normalized OVER THE SURVIVORS (+ the current file): score (higher better) and GiB/h (smaller better). A profile's `weights` combine them into a TOPSIS closeness. -The only per-profile knob is the weights. Oscillation is prevented by one-and-done state (a movie -is satisfied once its current file is optimal and never re-evaluated), not by a closeness margin. +The only per-profile knob is the weights. A grab is issued only when the pick clears at least one +of the two concrete improvement thresholds (min_score_delta or min_size_delta_gb). """ from __future__ import annotations from dataclasses import dataclass, field +from datetime import time from optimizarr.config import RADARR_RELEASE_TYPES, SONARR_RELEASE_TYPES @@ -36,19 +37,17 @@ @dataclass class Preset: - """A named bundle: TOPSIS weights + a swap margin. The pick is always max closeness.""" + """A named bundle: TOPSIS weights. The pick is always max closeness.""" weights: dict[str, float] # keys: score, size (sum 1.0) - min_closeness_gain: float # ACT only if the pick beats the current file's closeness by this @dataclass class ProfileOverride: - """Exact-name override: reference a preset, or override its weights / margin.""" + """Exact-name override: reference a preset, or override its weights.""" preset: str | None = None weights: dict[str, float] | None = None - min_closeness_gain: float | None = None @dataclass @@ -56,13 +55,11 @@ class ResolvedProfile: """Everything the scorer + decision need for one profile, after preset + override.""" weights: dict[str, float] - min_closeness_gain: float @dataclass class TopsisConfig: default_preset: str - default_min_closeness_gain: float presets: dict[str, Preset] # Three-tier score window (see topsis._score_floor_tier): # Tier 1: keep score >= max_available - score_window (anchor at top of what's on offer). @@ -72,7 +69,14 @@ class TopsisConfig: # Minimum pool size used at two gates: (1) the score-window tier check (expand to the next # tier if too few survive the current one); (2) the TOPSIS gate (HOLD without satisfying if # fewer than this many remain after all filters -- relative min-max is untrustworthy too thin). - min_candidates: int = 6 + min_candidates: int = 2 + # ACT gate: both conditions must hold. + # 1. Axis gate: at least one axis must move a meaningful amount vs the current file. + min_score_delta: int = 100 + min_size_delta_gb: float = 0.5 + # 2. Closeness gate: the pick's TOPSIS closeness must also improve by at least this. + # Prevents grabbing when an axis moved just enough but the overall balance did not improve. + min_closeness_gain: float = 0.02 # Outlier prefilter: drop a candidate whose GiB/h is below outlier_frac x the median GiB/h of # the surviving cluster. 0 disables it. outlier_frac: float = 0.5 @@ -97,6 +101,14 @@ class OptimizerAppConfig: auto_import_downgrades: bool = True +@dataclass +class ScheduleWindow: + """One day's active window in local time. start >= end means the window crosses midnight.""" + + start: time + end: time + + @dataclass class OptimizerConfig: enabled: bool = False @@ -108,6 +120,10 @@ class OptimizerConfig: radarr: OptimizerAppConfig = field(default_factory=OptimizerAppConfig) sonarr: OptimizerAppConfig = field(default_factory=OptimizerAppConfig) topsis: TopsisConfig = field(default_factory=lambda: default_topsis()) + # Per-day active-hours schedule (weekday int -> window, 0=Monday 6=Sunday). + # Empty dict means always active. Evaluation and list refresh are skipped outside the window; + # queue import processing always continues. + schedule: dict[int, ScheduleWindow] = field(default_factory=dict) # ----- parsing helpers ----- @@ -146,36 +162,17 @@ def _size_bounds(raw: dict, where: str) -> SizeBounds: return out -def _parse_min_gain(value: float | int, where: str) -> float: - gain = float(value) - if not (0.0 <= gain < 1.0): - raise ValueError(f"{where}.min_closeness_gain must satisfy 0 <= gain < 1.0, got {gain}") - return gain - - -def _parse_preset(raw: dict, where: str, default_min_gain: float) -> Preset: - min_gain = ( - _parse_min_gain(raw["min_closeness_gain"], where) - if "min_closeness_gain" in raw - else default_min_gain - ) - return Preset(weights=_weights(raw, where), min_closeness_gain=min_gain) +def _parse_preset(raw: dict, where: str) -> Preset: + return Preset(weights=_weights(raw, where)) def _parse_profile_override(raw: dict, where: str) -> ProfileOverride: weights = _weights(raw["weights"], f"{where}.weights") if "weights" in raw else None - min_gain = ( - _parse_min_gain(raw["min_closeness_gain"], where) if "min_closeness_gain" in raw else None - ) - return ProfileOverride(preset=raw.get("preset"), weights=weights, min_closeness_gain=min_gain) + return ProfileOverride(preset=raw.get("preset"), weights=weights) def _parse_topsis(raw: dict) -> TopsisConfig: - default_min_gain = _parse_min_gain(raw["min_closeness_gain"], "optimizer.topsis") - presets = { - name: _parse_preset(p, f"presets.{name}", default_min_gain) - for name, p in raw["presets"].items() - } + presets = {name: _parse_preset(p, f"presets.{name}") for name, p in raw["presets"].items()} if not presets: raise ValueError("optimizer.topsis.presets is empty (defaults.toml should define them)") default_preset = str(raw["default_preset"]) @@ -194,6 +191,19 @@ def _parse_topsis(raw: dict) -> TopsisConfig: min_candidates = int(raw["min_candidates"]) if min_candidates < 1: raise ValueError(f"optimizer.topsis.min_candidates must be >= 1, got {min_candidates}") + min_score_delta = int(raw["min_score_delta"]) + if min_score_delta < 0: + raise ValueError(f"optimizer.topsis.min_score_delta must be >= 0, got {min_score_delta}") + min_size_delta_gb = float(raw["min_size_delta_gb"]) + if min_size_delta_gb < 0: + raise ValueError( + f"optimizer.topsis.min_size_delta_gb must be >= 0, got {min_size_delta_gb}" + ) + min_closeness_gain = float(raw["min_closeness_gain"]) + if not (0.0 <= min_closeness_gain < 1.0): + raise ValueError( + f"optimizer.topsis.min_closeness_gain must be in [0, 1), got {min_closeness_gain}" + ) outlier_frac = float(raw["outlier_frac"]) if not (0.0 <= outlier_frac < 1.0): raise ValueError(f"optimizer.topsis.outlier_frac must be in [0, 1), got {outlier_frac}") @@ -203,10 +213,12 @@ def _parse_topsis(raw: dict) -> TopsisConfig: ) return TopsisConfig( default_preset=default_preset, - default_min_closeness_gain=default_min_gain, presets=presets, score_window=score_window, min_candidates=min_candidates, + min_score_delta=min_score_delta, + min_size_delta_gb=min_size_delta_gb, + min_closeness_gain=min_closeness_gain, outlier_frac=outlier_frac, size_bounds=_size_bounds(raw["size_bounds"], "optimizer.topsis.size_bounds"), profiles=profiles, @@ -242,6 +254,42 @@ def _parse_optimizer_app(raw: dict, allowed: set[str], where: str) -> OptimizerA ) +_DAY_TO_WEEKDAY: dict[str, int] = { + "sunday": 6, + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, +} + + +def _parse_time(s: str, where: str) -> time: + try: + h, m = str(s).strip().split(":") + return time(int(h), int(m)) + except (ValueError, AttributeError) as exc: + raise ValueError(f"{where}: invalid time {s!r}, expected HH:MM") from exc + + +def _parse_schedule(raw: dict, where: str) -> dict[int, ScheduleWindow]: + out: dict[int, ScheduleWindow] = {} + for day, entry in raw.items(): + wd = _DAY_TO_WEEKDAY.get(str(day).lower()) + if wd is None: + raise ValueError( + f"{where}: unknown day {day!r}; expected one of {sorted(_DAY_TO_WEEKDAY)}" + ) + if not isinstance(entry, dict) or "start" not in entry or "end" not in entry: + raise ValueError(f"{where}.{day}: expected {{start = ..., end = ...}}, got {entry!r}") + out[wd] = ScheduleWindow( + start=_parse_time(entry["start"], f"{where}.{day}.start"), + end=_parse_time(entry["end"], f"{where}.{day}.end"), + ) + return out + + def parse_optimizer(raw: dict) -> OptimizerConfig: pick_order = str(raw["pick_order"]).strip() if pick_order not in PICK_ORDERS: @@ -263,6 +311,7 @@ def parse_optimizer(raw: dict) -> OptimizerConfig: radarr=_parse_optimizer_app(raw["radarr"], RADARR_RELEASE_TYPES, "optimizer.radarr"), sonarr=_parse_optimizer_app(raw["sonarr"], SONARR_RELEASE_TYPES, "optimizer.sonarr"), topsis=_parse_topsis(raw["topsis"]), + schedule=_parse_schedule(raw.get("schedule", {}), "optimizer.schedule"), ) diff --git a/optimizarr/features/optimizer/decision.py b/optimizarr/features/optimizer/decision.py index bba3eb3..208cab3 100644 --- a/optimizarr/features/optimizer/decision.py +++ b/optimizarr/features/optimizer/decision.py @@ -8,13 +8,15 @@ drop lone-small outliers, then min-max normalize the survivors on two relative axes (score, GiB/h) and combine with the profile's weights into a TOPSIS closeness. 2. **Decide** (here): if too few candidates survived to compare, HOLD WITHOUT satisfying (retry - later). Otherwise ACT on the best candidate iff its closeness beats the current file's by the - margin; else HOLD and SATISFY (the current file is already optimal for its profile). - -A satisfying HOLD means the current (already-imported) file is the best pick. Because a candidate -must beat the current file's closeness, a pick can never be both lower-score AND bigger than the -current file (that is worse on both axes, so lower closeness). One-and-done state (worker.py) makes -a satisfied movie permanent, which is what prevents oscillation under relative scoring. + later). Otherwise ACT on the best candidate iff it clears BOTH gate sets vs the current file: + axis gate (score improves >= min_score_delta OR size shrinks >= min_size_delta_gb) AND + closeness gate (TOPSIS closeness improves >= min_closeness_gain). Both must pass. + If no candidate clears both, HOLD and SATISFY (the current file is already good enough). + +ACT requires BOTH gate sets to clear: at least one axis must move by a meaningful amount (axis +gate), AND the TOPSIS closeness must improve by at least min_closeness_gain (closeness gate). A +pick that moves an axis but barely shifts the overall profile balance is not worth grabbing. When +there is no current file, both gates are waived. One-and-done state (worker.py) is permanent. """ from dataclasses import dataclass @@ -34,6 +36,22 @@ class Decision: diag: dict | None = None +def _worth_grabbing( + pick_raw: dict, + cur_info: dict, + min_score_delta: int, + min_size_delta_gb: float, +) -> bool: + """Return True if the pick clears at least one threshold vs the current file. + When the current score is unknown, always return True (treat as an upgrade).""" + cur_score = cur_info.get("score") + if cur_score is None: + return True + score_delta = (pick_raw.get("score") or 0) - cur_score + size_delta_gb = (cur_info.get("size_gb") or 0) - (pick_raw.get("size_gb") or 0) + return score_delta >= min_score_delta or size_delta_gb >= min_size_delta_gb + + def _current_raw(cf: dict, runtime_h: float) -> dict: size_gb = (cf.get("size", 0) or 0) / GB gbh = (size_gb / runtime_h) if (runtime_h and runtime_h > 0) else 0.0 @@ -86,16 +104,21 @@ def decide( cur_clo = current["closeness"] if current else None current_info = {"closeness": cur_clo, **(current["raw"] if current else cur_raw or {})} - margin = resolved.min_closeness_gain - legal = [t for t in scored if cur_clo is None or t[2] > cur_clo + margin] + cfg = topsis.cfg + legal = [ + t + for t in scored + if _worth_grabbing(t[1]["raw"], current_info, cfg.min_score_delta, cfg.min_size_delta_gb) + and (cur_clo is None or t[2] >= cur_clo + cfg.min_closeness_gain) + ] if not legal: - # Current file already beats every candidate for its profile -> optimized. Satisfy. + # No candidate cleared all ACT gates vs the current file -> current is good enough. best = scored[0] best_info = {"closeness": best[2], "title": best[0].get("title", "?"), **best[1]["raw"]} return Decision( "HOLD", - "nothing better (current is optimal)", + "current is good enough (no candidate cleared axis + closeness gates)", satisfy=True, profile_name=profile_name, current=current_info, diff --git a/optimizarr/features/optimizer/topsis.py b/optimizarr/features/optimizer/topsis.py index 7a74319..bc77c0f 100644 --- a/optimizarr/features/optimizer/topsis.py +++ b/optimizarr/features/optimizer/topsis.py @@ -115,7 +115,7 @@ def _match_preset(self, profile_name: str) -> str: return self.cfg.default_preset def resolve_profile(self, profile_name: str | None) -> ResolvedProfile: - """Resolve a profile name to weights + pick + margin, honoring an exact-name override, + """Resolve a profile name to weights, honoring an exact-name override, then name-keyword preset matching, then default_preset.""" cfg = self.cfg override = cfg.profiles.get(profile_name) if profile_name else None @@ -126,12 +126,7 @@ def resolve_profile(self, profile_name: str | None) -> ResolvedProfile: else: base = cfg.presets[cfg.default_preset] weights = override.weights if (override and override.weights) else base.weights - min_gain = ( - override.min_closeness_gain - if (override and override.min_closeness_gain is not None) - else base.min_closeness_gain - ) - return ResolvedProfile(weights=weights, min_closeness_gain=min_gain) + return ResolvedProfile(weights=weights) def bounds_for(self, res: int) -> tuple[float, float]: """(floor, ceiling) GiB/h for a resolution; nearest defined at or below, else lowest.""" diff --git a/optimizarr/features/optimizer/worker.py b/optimizarr/features/optimizer/worker.py index ba43572..2cc2fa6 100644 --- a/optimizarr/features/optimizer/worker.py +++ b/optimizarr/features/optimizer/worker.py @@ -210,6 +210,7 @@ def __init__(self, config: Config, state: StateManager): self.topsis = Topsis(self.opt.topsis) self.dry_run = config.dry_run self._stop = threading.Event() + self._schedule_active: bool | None = None # None = unknown (first tick) conns = {"radarr": config.radarr, "sonarr": config.sonarr} app_cfgs = {"radarr": self.opt.radarr, "sonarr": self.opt.sonarr} @@ -252,6 +253,32 @@ def _refresh(self, ctx: _AppContext, now: datetime) -> None: ctx.last_refresh = now logger.info("[%s] list refreshed: %d items with files", adapter.app, len(items)) + def _in_active_hours(self, now_local: datetime | None = None) -> bool: + """Return True if the current local time is inside the configured active window. + An empty schedule means always active. A window where start >= end crosses midnight: + e.g. start=23:00, end=08:00 is active from 23:00 that day through 08:00 the next.""" + schedule = self.opt.schedule + if not schedule: + return True + t = (now_local or datetime.now()).time() + today = (now_local or datetime.now()).weekday() # 0=Mon, 6=Sun + yesterday = (today - 1) % 7 + + if today in schedule: + s, e = schedule[today].start, schedule[today].end + if s < e: # same-day window: active between s and e + if s <= t < e: + return True + elif t >= s: # cross-midnight: today's portion (s until midnight) + return True + + if yesterday in schedule: + s, e = schedule[yesterday].start, schedule[yesterday].end + if s >= e and t < e: # yesterday's window crosses into today (midnight until e) + return True + + return False + def _build_pool(self, ctx: _AppContext) -> None: app = ctx.adapter.app adapter = ctx.adapter @@ -449,19 +476,34 @@ def run(self) -> None: # Nothing actionable (queue full or pool exhausted): wait one short tick. self._sleep(self.opt.process_interval_seconds) - def _process_app_once(self, ctx: _AppContext) -> bool: - """Do at most one unit of work for an app. Returns True if an item was processed.""" + def _process_app_once(self, ctx: _AppContext, _active: bool | None = None) -> bool: + """Do at most one unit of work for an app. Returns True if an item was processed. + + `_active` overrides the schedule check (for tests); omit to use real local time.""" now = datetime.now(UTC) adapter = ctx.adapter + active = self._in_active_hours() if _active is None else _active + + if active != self._schedule_active: + self._schedule_active = active + if active: + logger.info("[optimizer] entered active hours; resuming evaluation") + else: + logger.info( + "[optimizer] outside active hours; skipping evaluation (queue imports continue)" + ) - if ctx.needs_refresh(now, self.opt.list_refresh_minutes): + # List refresh and pool rebuild only happen inside active hours. + if active and ctx.needs_refresh(now, self.opt.list_refresh_minutes): self._refresh(ctx, now) ctx.pool = [] # force rebuild below - # Auto-import stuck downgrades first so they stop blocking the queue (and so the - # item-id skip set below doesn't keep them locked out forever). + # Auto-import stuck downgrades always runs so the queue drains regardless of schedule. self._handle_queue_imports(ctx) + if not active: + return False + # One queue fetch serves both the global gate and the per-item skip. The gate's count # optionally filters out items already past download (waiting for or doing import) — # those don't consume bandwidth and shouldn't block new picks. diff --git a/tests/test_config.py b/tests/test_config.py index 77e03f2..0acb6e2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -170,14 +170,6 @@ def test_rejects_unknown_preset_in_profile_override(monkeypatch, tmp_path): load_config(path) -def test_rejects_min_closeness_gain_out_of_range(monkeypatch, tmp_path): - monkeypatch.setenv("RADARR_URL", "http://x") - monkeypatch.setenv("RADARR_API_KEY", "k") - path = _write(tmp_path, "[optimizer.topsis.presets.Efficient]\nmin_closeness_gain = 1.5\n") - with pytest.raises(ValueError, match="min_closeness_gain"): - load_config(path) - - def test_rejects_size_bounds_out_of_order(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://x") monkeypatch.setenv("RADARR_API_KEY", "k") @@ -326,9 +318,10 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): assert 0 < floor_2160 < ceil_2160 floor_1080, ceil_1080 = t.size_bounds[1080] assert 0 < floor_1080 < ceil_1080 - # swap margin: valid range - assert 0 <= t.default_min_closeness_gain < 1 - assert 0 <= t.presets["Balanced"].min_closeness_gain < 1 + # ACT gate thresholds: axis and closeness gates + assert t.min_score_delta >= 0 + assert t.min_size_delta_gb >= 0 + assert 0 <= t.min_closeness_gain < 1 # overrides parse as preset-ref or explicit weights assert t.profiles["2160p Remux"].preset == "Remux" custom_weights = t.profiles["Custom 1080p"].weights @@ -336,6 +329,56 @@ def test_parses_topsis_presets_and_overrides(monkeypatch, tmp_path): assert t.default_preset in t.presets +def test_parses_schedule(monkeypatch, tmp_path): + monkeypatch.setenv("RADARR_URL", "http://x") + monkeypatch.setenv("RADARR_API_KEY", "k") + path = _write( + tmp_path, + """ + [optimizer.schedule] + monday = { start = "22:30", end = "07:00" } + saturday = { start = "23:00", end = "09:00" } + """, + ) + cfg = load_config(path) + sch = cfg.optimizer.schedule + # 0=Monday, 5=Saturday + assert 0 in sch and 5 in sch + from datetime import time + + assert sch[0].start == time(22, 30) and sch[0].end == time(7, 0) + assert sch[5].start == time(23, 0) and sch[5].end == time(9, 0) + + +def test_schedule_defaults_all_days(monkeypatch, tmp_path): + # Built-in defaults define a 23:00-08:00 window for all 7 days. + monkeypatch.setenv("RADARR_URL", "http://x") + monkeypatch.setenv("RADARR_API_KEY", "k") + cfg = load_config(_write(tmp_path, "")) + assert len(cfg.optimizer.schedule) == 7 + from datetime import time + + for window in cfg.optimizer.schedule.values(): + assert window.start == time(23, 0) + assert window.end == time(8, 0) + + +def test_rejects_invalid_schedule_time(monkeypatch, tmp_path): + monkeypatch.setenv("RADARR_URL", "http://x") + monkeypatch.setenv("RADARR_API_KEY", "k") + path = _write(tmp_path, '[optimizer.schedule]\nmonday = { start = "25:00", end = "08:00" }\n') + with pytest.raises((ValueError, Exception)): + load_config(path) + + +def test_rejects_unknown_schedule_day(monkeypatch, tmp_path): + monkeypatch.setenv("RADARR_URL", "http://x") + monkeypatch.setenv("RADARR_API_KEY", "k") + path = _write(tmp_path, '[optimizer.schedule]\nfunday = { start = "23:00", end = "08:00" }\n') + with pytest.raises(ValueError, match="unknown day"): + load_config(path) + + def test_optimizer_import_max_default_and_override(monkeypatch, tmp_path): monkeypatch.setenv("RADARR_URL", "http://radarr:7878") monkeypatch.setenv("RADARR_API_KEY", "abc") diff --git a/tests/test_decision.py b/tests/test_decision.py index 1c9a3ea..96ce4c7 100644 --- a/tests/test_decision.py +++ b/tests/test_decision.py @@ -108,3 +108,17 @@ def test_unknown_current_score_treated_as_upgrade(): rels = [_release("a", 900_000, 2160, 10.0), _release("b", 800_000, 2160, 12.0)] d = decide(_topsis(), rels, 2.0, "2160p Quality", 2160, current_file=cur) assert d.action == "ACT" + + +def test_hold_when_axis_passes_but_closeness_gain_insufficient(): + # Axis gate passes (score +1000 >= min_score_delta=500) but both candidates have identical + # size so the TOPSIS closeness barely shifts -> closeness gate blocks the grab. + t = _topsis() + t.cfg.min_score_delta = 500 + t.cfg.min_size_delta_gb = 0.5 + t.cfg.min_closeness_gain = 0.05 + cur = _file(900_000, 2160, 20.0) + pick = _release("a", 901_000, 2160, 20.0) # +1000 score, same size -> closeness gain ~0.017 + other = _release("b", 850_000, 2160, 20.0) # lower score, same size (filler) + d = decide(t, [pick, other], 2.0, "2160p Quality", 2160, current_file=cur) + assert d.action == "HOLD" and d.satisfy is True diff --git a/tests/test_topsis.py b/tests/test_topsis.py index cd8a526..ce57b45 100644 --- a/tests/test_topsis.py +++ b/tests/test_topsis.py @@ -74,6 +74,7 @@ def test_score_window_tier1_tight_pool(): def test_score_window_tier2_expands_to_current(): t = _topsis() + t.cfg.min_candidates = 6 # tier-expansion tests need a pool threshold above 3 # Only 3 releases above the Tier-1 floor (900k); 6 more clustered just below current (880k). top = [_release(score=s) for s in [1_000_000, 990_000, 980_000]] mid = [_release(score=880_000 + i * 1_000) for i in range(6)] # 880k-885k @@ -86,6 +87,7 @@ def test_score_window_tier2_expands_to_current(): def test_score_window_tier3_fallback_and_negatives(): # With too few candidates at every tier, Tier 3 fires (full budget). t = _topsis() + t.cfg.min_candidates = 6 # tier-expansion tests need a pool threshold above 3 rels = [ _release(score=1_000_000), _release(score=905_000), diff --git a/tests/test_worker.py b/tests/test_worker.py index 8643123..a9e7d9d 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,10 +1,15 @@ import logging import threading -from datetime import UTC, datetime +from datetime import UTC, datetime, time from optimizarr.arr import ArrApi, RadarrApi from optimizarr.config import Connection -from optimizarr.features.optimizer.config import OptimizerAppConfig, OptimizerConfig, default_topsis +from optimizarr.features.optimizer.config import ( + OptimizerAppConfig, + OptimizerConfig, + ScheduleWindow, + default_topsis, +) from optimizarr.features.optimizer.state import SATISFIED, StateManager from optimizarr.features.optimizer.topsis import GB, Topsis from optimizarr.features.optimizer.worker import ( @@ -256,6 +261,7 @@ def _worker(state, dry_run=False): cfg.min_candidates = 2 # worker tests use 2-candidate pools; pool-size tested in test_topsis w.topsis = Topsis(cfg) w.dry_run = dry_run + w._schedule_active = None return w @@ -807,3 +813,122 @@ def test_build_pool_excludes_satisfied(tmp_path): ctx.items_by_id = {1: {"id": 1}, 2: {"id": 2}} w._build_pool(ctx) assert ctx.pool == [1] # satisfied item 2 (same profile, has file) is out of the pool + + +# ----- schedule / active hours ----- + +# June 2026 reference days (verified against calendar): +# 2026-06-01 = Monday (weekday 0) +# 2026-06-07 = Sunday (weekday 6) +# 2026-06-08 = Monday (weekday 0) + +_ALL_DAYS_23_08 = {wd: ScheduleWindow(time(23, 0), time(8, 0)) for wd in range(7)} + + +def _sched_worker(): + w = OptimizerWorker.__new__(OptimizerWorker) + w.opt = OptimizerConfig(enabled=True) + w._schedule_active = None + return w + + +def test_in_active_hours_empty_schedule_always_active(): + w = _sched_worker() + w.opt.schedule = {} + assert w._in_active_hours(datetime(2026, 6, 1, 14, 0)) # any time, any day + + +def test_in_active_hours_same_day_window_inside(): + w = _sched_worker() + w.opt.schedule = {0: ScheduleWindow(time(9, 0), time(17, 0))} # Monday 09:00-17:00 + assert w._in_active_hours(datetime(2026, 6, 1, 12, 0)) # inside + assert w._in_active_hours(datetime(2026, 6, 1, 9, 0)) # on the start boundary + assert not w._in_active_hours(datetime(2026, 6, 1, 8, 59)) # just before + assert not w._in_active_hours(datetime(2026, 6, 1, 17, 0)) # on the end boundary (exclusive) + assert not w._in_active_hours(datetime(2026, 6, 1, 18, 0)) # after window + + +def test_in_active_hours_cross_midnight_window(): + # 23:00 Sunday -> 08:00 Monday: default schedule. + w = _sched_worker() + w.opt.schedule = _ALL_DAYS_23_08 + + # Sunday 23:30 -> active (Sunday window, today portion, t >= s) + assert w._in_active_hours(datetime(2026, 6, 7, 23, 30)) + # Sunday 22:59 -> inactive (before Sunday window starts) + assert not w._in_active_hours(datetime(2026, 6, 7, 22, 59)) + # Monday 00:30 -> active (yesterday=Sunday crossed midnight, t < e=08:00) + assert w._in_active_hours(datetime(2026, 6, 8, 0, 30)) + # Monday 07:59 -> active (still in Sunday's cross-midnight tail) + assert w._in_active_hours(datetime(2026, 6, 8, 7, 59)) + # Monday 08:00 -> inactive (end boundary is exclusive; Monday's own window starts at 23:00) + assert not w._in_active_hours(datetime(2026, 6, 8, 8, 0)) + # Monday 14:00 -> inactive (daytime gap) + assert not w._in_active_hours(datetime(2026, 6, 8, 14, 0)) + # Monday 23:00 -> active (Monday's own window begins) + assert w._in_active_hours(datetime(2026, 6, 8, 23, 0)) + + +def test_in_active_hours_no_entry_for_today(): + # Schedule has Monday only; on a different day -> inactive. + w = _sched_worker() + w.opt.schedule = {0: ScheduleWindow(time(23, 0), time(8, 0))} + # Sunday (no entry, yesterday is Saturday which also has no entry) -> inactive + assert not w._in_active_hours(datetime(2026, 6, 7, 23, 30)) + + +def test_process_app_once_skips_evaluation_outside_active_hours(tmp_path): + # When _active=False is passed, the worker must still run queue imports but skip + # pool building, refresh, and item processing (return False). + state = StateManager(str(tmp_path / "s.json")) + + class _TrackingAdapter(_GrabQueueAdapter): + def __init__(self): + super().__init__( + records=[], + releases=[_release()], + current_file=_file(score=200_000, resolution=1080, size_gb=30.0), + ) + self.queue_called = False + + def queue_items(self): + self.queue_called = True + return [] + + adapter = _TrackingAdapter() + ctx = _AppContext(adapter, OptimizerAppConfig(auto_import_downgrades=True)) + ctx.items_by_id = {1: {"id": 1}} + ctx.pool = [1] + ctx.last_refresh = datetime.now(UTC) + + w = _worker(state) + result = w._process_app_once(ctx, _active=False) + + assert result is False + assert ctx.pool == [1] # pool must not have been consumed + assert adapter.grabbed == [] # no grab + # Queue was still fetched (handle_queue_imports always runs, even outside hours). + assert adapter.queue_called + + +def test_process_app_once_active_override_proceeds_normally(tmp_path): + # When _active=True is passed, the default all-day schedule is bypassed and evaluation runs. + state = StateManager(str(tmp_path / "s.json")) + adapter = _GrabQueueAdapter( + records=[], + releases=[ + _release(score=1_000_000, resolution=2160, size_gb=14.0), + _release(guid="g2", score=950_000, resolution=2160, size_gb=18.0), + ], + current_file=_file(score=200_000, resolution=1080, size_gb=30.0), + ) + ctx = _AppContext(adapter, OptimizerAppConfig(auto_import_downgrades=False)) + ctx.items_by_id = {1: {"id": 1}} + ctx.pool = [1] + ctx.last_refresh = datetime.now(UTC) + + w = _worker(state) + result = w._process_app_once(ctx, _active=True) + + assert result is True + assert len(adapter.grabbed) == 1 diff --git a/tools/diagnose.py b/tools/diagnose.py index a15c735..773fcd0 100644 --- a/tools/diagnose.py +++ b/tools/diagnose.py @@ -82,10 +82,16 @@ def _candidate_status( def _candidates( - r: dict, t: Topsis, cur_score, target_res: int, pick_title: str | None + r: dict, + t: Topsis, + cur_score, + target_res: int, + pick_title: str | None, + clo_map: dict[str, float], ) -> list[dict]: - """In-window candidates for one movie, each annotated with status. Uses the same three-tier - score floor as the engine so the drill-down exactly mirrors what got filtered.""" + """In-window candidates for one movie, each annotated with status and closeness. + Uses the same three-tier score floor as the engine so the drill-down exactly mirrors + what got filtered. Closeness is None for candidates filtered out before scoring.""" elig_scores = [ x.get("score") or 0 for x in r["releases"] @@ -103,14 +109,16 @@ def _candidates( st = _candidate_status(x, t, floor_score, target_res, pick_title) if st in ("rejected", "neg-score", "below-window"): continue + title = x.get("title") or "?" out.append( { - "title": x.get("title") or "?", + "title": title, "score": x.get("score") or 0, "res": x.get("resolution") or 0, "gbh": x.get("gbh") or 0.0, "size_gb": x.get("size_gb") or 0.0, "status": st, + "closeness": clo_map.get(title), # None when filtered before scoring } ) out.sort(key=lambda c: (-c["score"], c["gbh"])) @@ -124,14 +132,26 @@ def _evaluate(rows: list[dict], t: Topsis) -> list[dict]: cur = _cur(cf) profile = r["profile"]["name"] target_res = r["profile"]["target_resolution"] or (cf or {}).get("resolution") or 0 + releases = [_cand(x) for x in r["releases"]] d = decide( t, - [_cand(x) for x in r["releases"]], + releases, r["runtime_h"], profile, target_res, current_file=cur, ) + + # Run a scoring pass to get per-candidate closeness for the drill-down table. + cur_score = (cf or {}).get("score") + resolved = t.resolve_profile(profile) + kept, _ = t.apply_prefilters(releases, r["runtime_h"], int(target_res or 0), cur_score) + clo_map: dict[str, float] = {} + if kept: + scored_pool, _ = t.score_pool(kept, cur, r["runtime_h"], resolved) + clo_map = {rel.get("title", "?"): clo for rel, _, clo in scored_pool} + + cur_clo = d.current.get("closeness") if d.current else None rec = { "title": r["title"], "profile": profile, @@ -139,15 +159,20 @@ def _evaluate(rows: list[dict], t: Topsis) -> list[dict]: "satisfy": d.satisfy, "reason": d.reason, "cur": cf or {}, + "cur_clo": cur_clo, "pick": d.pick if d.action == "ACT" else None, } pick_title = d.pick.get("title") if d.action == "ACT" and d.pick else None - rec["cands"] = _candidates(r, t, (cf or {}).get("score"), int(target_res or 0), pick_title) + rec["cands"] = _candidates(r, t, cur_score, int(target_res or 0), pick_title, clo_map) if rec["pick"] and cf: pk = rec["pick"] rec["dscore"] = (pk.get("score") or 0) - (cf.get("score") or 0) rec["dsize"] = (pk.get("size_gb") or 0) - (cf.get("size_gb") or 0) rec["dgbh"] = (pk.get("gbh") or 0) - (cf.get("gbh") or 0) + pick_clo = pk.get("closeness") + rec["dclo"] = ( + (pick_clo - cur_clo) if (pick_clo is not None and cur_clo is not None) else None + ) rec["quad"] = _quadrant(rec["dscore"], rec["dsize"]) records.append(rec) return records @@ -198,18 +223,20 @@ def _write_md(records: list[dict], s: dict, out: Path) -> None: L.append("\n## Original file -> new pick (ACT only)\n") L.append( "| movie | profile | quadrant | current (score / GB / gb·h) | " - "new pick (score / GB / gb·h) | Δscore | Δsize | Δgb·h |" + "new pick (score / GB / gb·h) | Δscore | Δsize | Δgb·h | Δclo |" ) - L.append("| --- | --- | --- | --- | --- | ---: | ---: | ---: |") + L.append("| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: |") for r in records: if r["action"] != "ACT": continue cf, pk = r["cur"], r["pick"] + dclo = r.get("dclo") + dclo_s = f"{dclo:+.3f}" if dclo is not None else "-" L.append( f"| {r['title']} | {r['profile']} | {r['quad']} | " f"{cf.get('score', 0):,} / {cf.get('size_gb', 0):.1f} / {cf.get('gbh', 0):.2f} | " f"{pk.get('score', 0):,} / {pk.get('size_gb', 0):.1f} / {pk.get('gbh', 0):.2f} | " - f"{r['dscore']:+,} | {r['dsize']:+.1f}GB | {r['dgbh']:+.2f} |" + f"{r['dscore']:+,} | {r['dsize']:+.1f}GB | {r['dgbh']:+.2f} | {dclo_s} |" ) out.write_text("\n".join(L) + "\n") @@ -300,6 +327,12 @@ def _tbl(tid: str, cols: list[tuple[str, bool]], rows: list[list[str]], filt: bo ) +def _clo_cell(v: float | None, cls: str = "num") -> str: + if v is None: + return _cell("-", -999, cls) + return _cell(f"{v:.3f}", v, cls) + + def _write_html(records: list[dict], s: dict, out: Path) -> None: cards = ( f"
    " @@ -331,18 +364,24 @@ def _write_html(records: list[dict], s: dict, out: Path) -> None: ("Cur score", True), ("Cur GB", True), ("Cur gb/h", True), + ("Cur clo", True), ("Pick score", True), ("Pick GB", True), ("Pick gb/h", True), + ("Pick clo", True), ("Δscore", True), ("Δsize GB", True), ("Δgb/h", True), + ("Δclo", True), ] prows = [] for r in records: if r["action"] != "ACT": continue cf, pk = r["cur"], r["pick"] + cur_clo = r.get("cur_clo") + pick_clo = pk.get("closeness") if pk else None + dclo = r.get("dclo") prows.append( [ _cell(r["title"], cls="txt link"), @@ -351,12 +390,19 @@ def _write_html(records: list[dict], s: dict, out: Path) -> None: _cell(f"{cf.get('score', 0):,}", cf.get("score") or 0, "num"), _cell(f"{cf.get('size_gb', 0):.1f}", cf.get("size_gb") or 0, "num"), _cell(f"{cf.get('gbh', 0):.2f}", cf.get("gbh") or 0, "num"), + _clo_cell(cur_clo), _cell(f"{pk.get('score', 0):,}", pk.get("score") or 0, "num"), _cell(f"{pk.get('size_gb', 0):.1f}", pk.get("size_gb") or 0, "num"), _cell(f"{pk.get('gbh', 0):.2f}", pk.get("gbh") or 0, "num"), + _clo_cell(pick_clo), _cell(f"{r['dscore']:+,}", r["dscore"], "num"), _cell(f"{r['dsize']:+.1f}", r["dsize"], "num"), _cell(f"{r['dgbh']:+.2f}", r["dgbh"], "num"), + _cell( + f"{dclo:+.3f}" if dclo is not None else "-", + dclo if dclo is not None else -999, + "num", + ), ] ) picks = "
    Click a movie title to see all its in-window candidates.
    " + _tbl( @@ -372,6 +418,7 @@ def _write_html(records: list[dict], s: dict, out: Path) -> None: ("Res", True), ("gb/h", True), ("GB", True), + ("Closeness", True), ] crows = [] for r in records: @@ -385,11 +432,13 @@ def _write_html(records: list[dict], s: dict, out: Path) -> None: _cell(f"{c['res']}p", c["res"], "num"), _cell(f"{c['gbh']:.2f}", c["gbh"], "num"), _cell(f"{c['size_gb']:.1f}", c["size_gb"], "num"), + _clo_cell(c.get("closeness")), ] ) cands_tab = ( "
    In-window candidates (score within the downgrade budget). " - "PICK = chosen; wrong-res / below-floor / above-ceiling = filtered out.
    " + "PICK = chosen; wrong-res / below-floor / above-ceiling = filtered out. " + "Closeness is relative to the surviving pool (blank when filtered before scoring).
    " + _tbl("c-tbl", ccols, crows) )