diff --git a/ALGORITHM.md b/ALGORITHM.md index 4794bf1..b46a016 100644 --- a/ALGORITHM.md +++ b/ALGORITHM.md @@ -1,286 +1,206 @@ # 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 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. +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 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 removes re-evaluation loops + that could oscillate. --- -## 1. The size model: per-preset tables, one-sided curve - -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**: - -``` -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 -``` - -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. +## 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 three axes +## 2. Relative scoring -Each surviving release is described by three attributes, all normalized to `[0, 1]`: +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 = 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 = (score − min_score) / (max_score − min_score) # higher score -> 1 +n_size = (max_gbh − gbh) / (max_gbh − min_gbh) # smaller file -> 1 ``` -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). - -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 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: - -| 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` | - -`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. - ---- - -## 3. The decision pipeline +The weights are the **only** per-profile knob. The shipped profiles: -```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 > bloat (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"] - GATE --> LEFT{"any legal candidate?"} - LEFT -->|no| HOLD["HOLD: mark satisfied"] - LEFT -->|yes| PICK["PICK best survivor
topsis / max_score / min_size"] - PICK --> ACT["ACT: POST grab {guid, indexerId}"] -``` +| Profile | score | size | leans | +| --- | --- | --- | --- | +| 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 | -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). - -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: +## 3. The decision -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. +`decision.py::decide`: resolve the profile → run the filters → relatively score the survivors → +compare to the current file. -### Why there is no explicit "no senseless swap" rule +- 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 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. -"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). - -### 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 worse on both axes (lower score, bigger size) +cannot clear either threshold and therefore never triggers ACT. --- -## 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_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.presets.Efficient] -score = 0.40 # weights (sum 1.0) -resolution = 0.10 -size = 0.50 -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 } +[optimizer.topsis.size_bounds] # shared per-resolution {floor, ceiling} GiB/h legitimacy band +"2160" = { floor = 3.0, ceiling = 30.0 } +"1080" = { floor = 1.0, ceiling = 15.0 } # … 720 / 480 … + +[optimizer.topsis.presets.Efficient] +score = 0.40 # weights (score + size, sum 1.0) — the only per-profile knob +size = 0.60 ``` -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 < target ≤ bloat`, 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 +or its weights 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. +- 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`. -> **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. +### 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). -## 7. The Unmonitor job +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. -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). +## 6. The Unmonitor job -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. +A separate, cron-scheduled pass (`[unmonitor]`, default `0 4 * * *`) that **unmonitors** items so +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 e7a94aa..aeb617b 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 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)**. @@ -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-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 774671c..ae8e1fd 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. @@ -91,101 +92,98 @@ 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 +# 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_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 +# 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 = 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 +# 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 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 -# ----- 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. -# -# 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. +# 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). +# 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 everything, size barely weighs. Table sits at remux sizes. -score = 0.80 -resolution = 0.15 -size = 0.05 -pick = "max_score" -[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 } +score = 0.90 +size = 0.10 [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 -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 } +score = 0.75 +size = 0.25 [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 -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 } +size = 0.50 [optimizer.topsis.presets.Efficient] -# Good score AND smaller, leaning size. score = 0.40 -resolution = 0.10 -size = 0.50 -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 } +size = 0.60 [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" -[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 } - -# Per-profile-name overrides. Reference a preset, or override weights / pick / reference / margin: +score = 0.25 +size = 0.75 + +# 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"] -# weights = { score = 0.5, resolution = 0.1, size = 0.4 } -# [optimizer.topsis.profiles."My Custom 1080p".reference] -# "1080" = { floor = 1.5, target = 4.0, bloat = 9.0 } +# weights = { score = 0.6, size = 0.4 } diff --git a/optimizarr/features/optimizer/config.py b/optimizarr/features/optimizer/config.py index 9075a53..168d702 100644 --- a/optimizarr/features/optimizer/config.py +++ b/optimizarr/features/optimizer/config.py @@ -1,25 +1,21 @@ """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 `{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). - -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. +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. 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 @@ -34,53 +30,58 @@ "release_date_asc", # oldest release first "release_date_desc", # newest release first } -PICK_METHODS = {"topsis", "max_score", "min_size"} - -# resolution -> (floor, target, bloat) GiB/h. -Reference = dict[int, tuple[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. The pick is always max closeness.""" - weights: dict[str, float] # keys: score, resolution, size (sum 1.0) - pick: str # "topsis" | "max_score" | "min_size" - reference: Reference # res -> (floor, target, bloat) GiB/h - min_closeness_gain: float # swap only if closeness improves by at least this + weights: dict[str, float] # keys: score, size (sum 1.0) @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.""" 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 - resolution_ideal: int - resolution_anti_ideal: int - score_gap: float 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). + # 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 = 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 + # Shared per-resolution {floor, ceiling} legitimacy band (GiB/h). + size_bounds: SizeBounds = field(default_factory=dict) profiles: dict[str, ProfileOverride] = field(default_factory=dict) @@ -92,8 +93,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 @@ -101,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 @@ -109,18 +117,21 @@ 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()) + # 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 ----- 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()) @@ -129,84 +140,42 @@ 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 - if not isinstance(entry, dict) or not {"floor", "target", "bloat"} <= entry.keys(): - raise ValueError(f"{where}.{res}: expected {{floor, target, bloat}}, 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"]) - target = float(entry["target"]) - bloat = float(entry["bloat"]) - if not (floor < target <= bloat): + ceiling = float(entry["ceiling"]) + if not (0.0 <= floor < ceiling): raise ValueError( - f"{where}.{res}: must satisfy floor < target <= bloat, " - f"got floor={floor} target={target} bloat={bloat}" + f"{where}.{res}: must satisfy 0 <= floor < ceiling, got floor={floor} " + f"ceiling={ceiling}" ) - out[res_int] = (floor, target, bloat) + 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): - 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: - 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, - ) +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 - 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) def _parse_topsis(raw: dict) -> TopsisConfig: - default_min_gain = _parse_min_gain(raw.get("min_closeness_gain", 0.02), "optimizer.topsis") - presets = { - name: _parse_preset(p, f"presets.{name}", default_min_gain) - for name, p in raw.get("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.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 = { @@ -216,15 +185,42 @@ 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_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}") + 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}") + 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"]), - 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_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, ) @@ -246,55 +242,76 @@ 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"]), ) +_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.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"]), + schedule=_parse_schedule(raw.get("schedule", {}), "optimizer.schedule"), ) diff --git a/optimizarr/features/optimizer/decision.py b/optimizarr/features/optimizer/decision.py index 1fed738..208cab3 100644 --- a/optimizarr/features/optimizer/decision.py +++ b/optimizarr/features/optimizer/decision.py @@ -1,31 +1,34 @@ """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..bloat), gap-cut the score tail, then score the survivors by TOPSIS closeness. - 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. - 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 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 -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} @@ -33,30 +36,27 @@ 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, +def _worth_grabbing( + pick_raw: dict, + cur_info: dict, + min_score_delta: int, + min_size_delta_gb: 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 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 - return cand_closeness >= cur_closeness + min_gain + 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 + 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( @@ -69,8 +69,9 @@ def decide( allow_size_increase: bool = True, allow_quality_downgrade: bool = True, ) -> 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; @@ -84,42 +85,57 @@ 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: - 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 {})} + + 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: - 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) + # 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", + "current is good enough (no candidate cleared axis + closeness gates)", + 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 d3f7077..bc77c0f 100644 --- a/optimizarr/features/optimizer/topsis.py +++ b/optimizarr/features/optimizer/topsis.py @@ -1,26 +1,26 @@ -"""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. +"""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 @@ -56,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.""" @@ -74,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, 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: @@ -85,229 +126,161 @@ 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]: - """A preset's (floor, target, bloat) for a resolution; nearest-defined-at-or-below.""" - if res in reference: - return reference[res] - keys = sorted(reference) + 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.""" + 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 `bloat` (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, _target, bloat = self.reference_for(_release_resolution(r), reference) - gbh = _release_gbh(r, runtime_h) - if floor <= gbh <= bloat: + floor, ceiling = self.bounds_for(_release_resolution(r)) + if floor <= _release_gbh(r, runtime_h) <= ceiling: keep.append(r) return keep - 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 drop_size_outliers(self, releases: list[dict], runtime_h: float) -> list[dict]: + """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 + 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 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) - 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)" + kept = self.drop_size_outliers(after_band, runtime_h) + diag["after_outlier_drop"] = len(kept) + diag["inclusion"] = ( + f"score >= {score_floor:,} (tier {window_tier})" + f" + target res + [floor,ceiling] band + lone-small drop" + ) return kept, diag - # ----- normalization ----- + # ----- scoring ----- - def normalize_score(self, s: float) -> float: - cfg = self.cfg - 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 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"]} + 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 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: - 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 <= target: - return 1.0 - if gbh >= bloat or bloat <= target: - return 0.0 - return (bloat - gbh) / (bloat - target) - - def _attrs( - self, - score: float, - res: int, - gbh: float, - size_gb: float, - resolved: ResolvedProfile, - target_resolution: int | None, - ) -> dict: - floor, target, bloat = self.reference_for(res, resolved.reference) + def _attrs(self, score, res, gbh, size_gb, smin, smax, gmin, gmax) -> dict: 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), - "raw": { - "score": score, - "resolution": res, - "gbh": gbh, - "size_gb": size_gb, - "reference": (floor, target, bloat), - "target": target, - }, + "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 attributes_for( + def score_pool( self, - release: dict, + kept: list[dict], + current_file: dict | None, runtime_h: float, resolved: ResolvedProfile, - target_resolution: int | None = None, - ) -> dict: - """Normalized [0,1] attributes + raw values for one release.""" - 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, - 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"], - } - 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 - return 0.0 if total == 0 else d_anti / total + ) -> 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"])) - 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) + current = self._current_attrs(current_file, runtime_h, resolved, smin, smax, gmin, gmax) + return scored, current - 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: + def _current_attrs(self, cf, runtime_h, resolved, smin, smax, gmin, gmax) -> dict | None: + if not cf: return None - size = movie_file.get("size", 0) or 0 - size_gb = size / GB + 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 = self._current_resolution(movie_file) - return self._attrs(score, res, gbh, size_gb, resolved, target_resolution) - - 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, + 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}, } - return self.closeness(attrs, resolved.weights), attrs["raw"] - - # ----- scoring & picking ----- - - def score_candidates( - self, - releases: list[dict], - 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 - ) - ] - 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: - 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]) + 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..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} @@ -226,15 +227,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 +253,45 @@ 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 _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 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 +323,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 @@ -437,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. @@ -462,7 +516,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/tests/test_config.py b/tests/test_config.py index 53de663..0acb6e2 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 @@ -170,31 +170,14 @@ 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_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_target_above_bloat(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, target = 20, bloat = 18 }\n', + '[optimizer.topsis.size_bounds]\n"2160" = { floor = 40, ceiling = 10 }\n', ) - with pytest.raises(ValueError, match="floor < target <= bloat"): + with pytest.raises(ValueError, match="floor < ceiling"): load_config(path) @@ -202,11 +185,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,13 +294,13 @@ 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" [optimizer.topsis.profiles."Custom 1080p"] - weights = { score = 0.5, resolution = 0.1, size = 0.4 } + weights = { score = 0.6, size = 0.4 } """, ) @@ -326,29 +308,81 @@ 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.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) - # 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 + # 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 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_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") - 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 5dab129..96ce4c7 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,155 +29,96 @@ 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_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 - 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) - assert d.action == "ACT" - assert d.release is not None and d.release["guid"] == "b" # min_size among legal survivors - - -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" + assert d.action == "ACT" and d.release["guid"] == "hi" -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" +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" -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) - 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_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 f06f1e2..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,106 +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_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.)""" - 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(4.0, 12.0), 2) - cand_score = rnd.randint(0, cur_score) # <= current - cand_gbh = round(rnd.uniform(cur_gbh, 18.0), 2) # >= current - 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 b445d29..ce57b45 100644 --- a/tests/test_topsis.py +++ b/tests/test_topsis.py @@ -1,12 +1,12 @@ 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}}, @@ -28,124 +28,140 @@ def test_eligible_drops_blocklisted_and_temp(): assert len(keep) == 1 -def test_size_band_drops_below_floor_and_above_bloat(): - 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 - 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 - kept = t.filter_by_size_band([fake, real, bloated], 2.0, ref) - assert kept == [real] - +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 -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 + # 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_normalize_size_degenerate_bloat_equals_target(): - 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 +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_gap_keeps_cluster_drops_tail_and_negatives(): - t = _topsis() # default score_gap = 0.20 +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 + 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_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=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, 6.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.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(): +def test_bounds_for_nearest_below(): 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 - 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 + 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_max_score_for_remux(): +def test_resolve_profile_matches_by_name(): 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) - selected = t.select(scored, rp) - assert selected is not None - rel, _attrs, _clo = selected - assert rel is big # highest score wins regardless of size + rp = t.resolve_profile("1080p Efficient") + assert rp.weights == t.cfg.presets["Efficient"].weights -def test_select_min_size_for_compact(): +def test_score_pool_relative_smallest_and_highest_get_ideal(): 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") + 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..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 ( @@ -229,6 +234,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,8 +257,11 @@ 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 + w._schedule_active = None return w @@ -264,12 +275,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 +292,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 +399,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 +442,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 +807,128 @@ 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 + + +# ----- 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/cutoff_viz.py b/tools/cutoff_viz.py deleted file mode 100644 index 04a4ffc..0000000 --- a/tools/cutoff_viz.py +++ /dev/null @@ -1,358 +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, _t, bloat = 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}" - 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 new file mode 100644 index 0000000..773fcd0 --- /dev/null +++ b/tools/diagnose.py @@ -0,0 +1,481 @@ +"""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 +""" + +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 +from optimizarr.features.optimizer.topsis import HARD_REJECT_KEYWORDS, Topsis, _score_floor_tier + + +def _cand(r: dict) -> dict: + return { + "guid": r.get("title"), + "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 _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, + clo_map: dict[str, float], +) -> list[dict]: + """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"] + 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 + title = x.get("title") or "?" + out.append( + { + "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"])) + return out + + +def _evaluate(rows: list[dict], t: Topsis) -> list[dict]: + records = [] + for r in rows: + cf = r.get("current_file") + 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, + 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, + "action": d.action, + "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, 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 + + +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], + } + + +_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 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 | Δclo |" + ) + 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} | {dclo_s} |" + ) + out.write_text("\n".join(L) + "\n") + + +# ----- interactive HTML ----- + +_HEAD = """ + +Optimizarr diagnose

Optimizarr diagnose

+
{n} movies · {ts} · own-profile relative model
+
{tabbtns}
+""" + +_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]], filt: bool = True) -> str: + head = "".join(f'{html.escape(c)}' for c, num in cols) + body = "".join("" + "".join(r) + "" for r in rows) + f = ( + f'' + if filt + else "" + ) + return ( + f'{f}{head}' + f"{body}
" + ) + + +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"
" + 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 + ) + ) + + cls = {"score+ size-": "ideal", "score- size+": "bug"} + cols = [ + ("Movie", False), + ("Profile", False), + ("Quadrant", False), + ("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"), + _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"), + _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( + "p-tbl", cols, prows + ) + + # Candidates drill-down: every in-window candidate for every movie, annotated. + ccols = [ + ("Movie", False), + ("Profile", False), + ("Status", False), + ("Score", True), + ("Res", True), + ("gb/h", True), + ("GB", True), + ("Closeness", True), + ] + crows = [] + for r in records: + for c in r["cands"]: + crows.append( + [ + _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"), + _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. " + "Closeness is relative to the surviving pool (blank when filtered before scoring).
" + + _tbl("c-tbl", ccols, crows) + ) + + tabs = [ + ("summary", "Summary", summary), + ("picks", "Picks (ACT)", picks), + ("cands", "Candidates", cands_tab), + ] + 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 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'])}" + ) + + +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 95fc9eb..0000000 --- a/tools/weight_lab.py +++ /dev/null @@ -1,413 +0,0 @@ -"""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: - - - 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 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.", - "", - "## Presets", - "", - "| preset | score | res | size | pick | gain | 2160 (floor/target/bloat) | 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 "-" - - 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"{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()