From a5b3680746f7abd5bda3649a314288099f08d040 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 12:01:24 +0100 Subject: [PATCH] feat(core): rank resolutions in the score `resolution_pref` only gated eligibility, so every release was scored against its own resolution's size band and a 23 GB 4K lost to an at-target 1080p. Each step up the list is now worth `resolution_step` points, seeded at 300: five gibibytes of 4K overshoot, so a 4K up to 27 GB wins and a bloated one still does not. Co-Authored-By: Claude Opus 5 --- DESIGN.md | 19 +++ crates/arr-api/src/search.rs | 132 +++++++++++++++- crates/arr-core/src/score.rs | 144 +++++++++++++++++- .../0015_policy_resolution_rank.sql | 18 +++ crates/arr-db/src/lib.rs | 2 + crates/arr-db/src/policy.rs | 46 +++++- 6 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 crates/arr-db/migrations/0015_policy_resolution_rank.sql diff --git a/DESIGN.md b/DESIGN.md index f988a21..7443c30 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -261,6 +261,25 @@ tiebreaker. Seeders are log-scaled and small: enough to complete, past that it does not matter. Telesync, CAM and screener are **hard filters**, not low scores. +**How resolutions rank against each other.** Size is scored against the band +for the release's own resolution, so on its own it says nothing across +resolutions: an at-target 4K and an at-target 1080p both score the top of the +size term, and a 4K a couple of gigabytes over target loses to a 1080p that is +merely on target. That is wrong — `resolution_pref` is an ordered list, and the +order is a preference, not just an eligibility filter. + +So each step up `resolution_pref` is worth a fixed number of points. The last +entry is worth nothing and every earlier one a step more. A resolution the list +does not carry scores nothing rather than being penalised, the same as an +unclaimed resolution: no ranking is no opinion. + +The step is sized against the size term, not chosen in isolation. With the +seeded 4K band — target 22 GB, 60 points per gigabyte over — a step of 300 is +five gigabytes of overshoot: a 4K up to about 27 GB beats an at-target 1080p, +and a bloated 40 GB 4K does not. Below target the same arithmetic asks a 4K to +be within about four gigabytes of its target to win, which is what keeps an 8 GB +4K that looks like mud from beating a good 1080p. + Exact numbers are policy rows, tuned by hand. The model is the decision. ### 5.6 Two phases of truth diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2596886..b6c1a0e 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -59,6 +59,23 @@ pub struct TmdbMovie { pub poster_path: Option, } +/// One release's score, kept as its terms so the UI can explain a ranking +/// (`DESIGN.md` §5.5) rather than showing a bare number. +#[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)] +pub struct ScoreTerms { + /// Distance from the target size for this release's own resolution. + /// Zero when the release carries no size, or the policy no band for it. + pub size: i64, + /// The source-tier tiebreaker. + pub source: i64, + /// The log-scaled seeder term. + pub seeders: i64, + /// Where the release sits in the policy's resolution preference. This is + /// what makes a 4K release outrank a 1080p one that scores the same + /// against its own size band. + pub resolution: i64, +} + #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ClassifiedRelease { pub indexer_id: i64, @@ -70,6 +87,8 @@ pub struct ClassifiedRelease { pub download_url: String, pub parsed: serde_json::Value, pub score: i64, + /// `score` broken into the terms that produced it. + pub score_terms: ScoreTerms, pub verdict: String, pub rule: Option, } @@ -487,11 +506,24 @@ fn classify( release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), ); - let score = if release.size.is_some() { - score.total - } else { - score.source.saturating_add(score.seeders) + // A release with no size has nothing to say about its size band, so that + // term is dropped rather than scored as if it were at the floor. Every + // other term still stands — the resolution rank comes from the name. + let terms = ScoreTerms { + size: if release.size.is_some() { + score.size + } else { + 0 + }, + source: score.source, + seeders: score.seeders, + resolution: score.resolution, }; + let score = terms + .size + .saturating_add(terms.source) + .saturating_add(terms.seeders) + .saturating_add(terms.resolution); Ok(ClassifiedRelease { indexer_id: release.indexer_id, guid: release.guid, @@ -509,6 +541,7 @@ fn classify( parsed: serde_json::to_value(parsed) .map_err(|error| ApiError::Database(error.to_string()))?, score, + score_terms: terms, verdict: verdict.to_owned(), rule, }) @@ -681,7 +714,7 @@ mod tests { sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (693134, 'Dune Part Two', 2024, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); sqlx::query( - "UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0}'", + "UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0,\"resolution_step\":0}'", ) .execute(state.database().expect("database").pool()) .await @@ -801,6 +834,88 @@ mod tests { } } + /// Issue #113: the breakdown is what the UI explains a ranking from, so + /// the resolution term has to be visible in it, not folded into a total. + #[test] + fn the_score_breakdown_carries_the_resolution_term() { + let policy = scoring_policy(); + let classify_at = |name: &str, size: u64| { + let release = SearchRelease { + indexer_id: 1, + guid: name.into(), + name: name.into(), + size: Some(size), + seeders: Some(20), + publish_date: None, + download_url: "https://tracker/release".into(), + tmdb_id: None, + imdb_id: None, + }; + classify( + release, + &policy, + &TitleOverrides::default(), + &Language::Other("en".into()), + &Blacklist::default(), + ) + .expect("classified release") + }; + + let uhd = classify_at("Dune.Part.Two.2024.2160p.WEB-DL", 23 << 30); + let hd = classify_at("Dune.Part.Two.2024.1080p.WEB-DL", 8 << 30); + + assert_eq!( + uhd.score_terms.resolution, + i64::from(policy.score_weights.resolution_step) + ); + assert_eq!(hd.score_terms.resolution, 0); + // The 4K is over its own target and so scores worse on size, and + // still ranks first. + assert!(uhd.score_terms.size < hd.score_terms.size); + assert!(uhd.score > hd.score); + assert_eq!( + uhd.score, + uhd.score_terms.size + + uhd.score_terms.source + + uhd.score_terms.seeders + + uhd.score_terms.resolution + ); + } + + /// The seeded movie policy's scoring numbers (§5.5). + fn scoring_policy() -> Policy { + Policy { + id: PolicyId(1), + name: "test".into(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: Vec::new(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: std::collections::BTreeMap::from([ + ( + Resolution::R2160p, + SizeBand { + floor_bytes: 8 << 30, + target_bytes: 22 << 30, + penalty_points_per_gib_over: 60, + }, + ), + ( + Resolution::R1080p, + SizeBand { + floor_bytes: 3 << 30, + target_bytes: 8 << 30, + penalty_points_per_gib_over: 60, + }, + ), + ]), + resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], + source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]), + score_weights: ScoreWeights::default(), + } + } + #[test] fn releases_without_sizes_skip_the_size_score() { let policy = Policy { @@ -848,8 +963,13 @@ mod tests { assert_eq!( classified.score, - core_score.source.saturating_add(core_score.seeders) + core_score + .source + .saturating_add(core_score.seeders) + .saturating_add(core_score.resolution) ); + assert_eq!(classified.score_terms.size, 0); + assert_eq!(classified.score_terms.resolution, core_score.resolution); assert_ne!(classified.score, core_score.total); } diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index 2e0e088..bc3bd92 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -5,6 +5,11 @@ //! WEB-DL at target beats a 60 GB remux while the remux keeps a score and //! stays eligible — it wins when nothing smaller exists. //! +//! Size is scored against the band for the release's own resolution, so it +//! says nothing about how resolutions rank against each other. That is the +//! resolution-rank term: the policy's `resolution_preference` is ordered, and +//! each step up it is worth a fixed number of points. +//! //! Below the floor is a hard filter rather than a low score, and lives in //! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise //! select a 3 GB 4K encode that looks like mud. @@ -31,6 +36,18 @@ pub struct ScoreWeights { /// Points per doubling of the seeder count. Log-scaled and small — /// enough seeders to complete matters, past that it does not. pub seeder_doubling: i32, + /// Points per step up the policy's `resolution_preference`. The last + /// entry is worth nothing and each earlier one a step more, so the list + /// that already decides eligibility also decides how resolutions rank + /// against each other. + /// + /// Sized against the size term deliberately. With the seeded bands — 4K + /// target 22 GiB, 60 points per gibibyte over — 300 points is five + /// gibibytes of overshoot: a 4K release up to 27 GiB outranks an + /// at-target 1080p, and a bloated 4K past that does not. Below its + /// target a 4K has to be within about four gibibytes of it to win, which + /// keeps a mud-quality 8 GiB 4K from beating a good 1080p. + pub resolution_step: i32, } impl Default for ScoreWeights { @@ -39,6 +56,7 @@ impl Default for ScoreWeights { size_at_target: 1000, source_tier: 25, seeder_doubling: 8, + resolution_step: 300, } } } @@ -54,6 +72,10 @@ pub struct Score { pub source: i64, /// The log-scaled seeder term. pub seeders: i64, + /// The resolution-rank term: where the release sits in the policy's + /// `resolution_preference`. Zero when the name claims no resolution, or + /// claims one the policy does not rank. + pub resolution: i64, } /// Score a candidate against a policy. @@ -61,23 +83,29 @@ pub struct Score { /// A candidate with no applicable size band — unknown resolution, or a /// resolution the policy carries no band for — scores zero on the dominant /// term rather than being penalised for what the release name failed to say. +/// The resolution-rank term treats an unclaimed resolution the same way. #[must_use] pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score { let weights = &policy.score_weights; - let size = candidate - .resolution() + let claimed = candidate.resolution(); + let size = claimed .and_then(|resolution| policy.size_bands.get(&resolution)) .map_or(0, |band| size_points(band, weights, size_bytes)); let source = candidate .source() .map_or(0, |source| source_points(policy, source)); let seeders = seeder_points(weights, seeders); + let resolution = claimed.map_or(0, |resolution| resolution_points(policy, resolution)); Score { - total: size.saturating_add(source).saturating_add(seeders), + total: size + .saturating_add(source) + .saturating_add(seeders) + .saturating_add(resolution), size, source, seeders, + resolution, } } @@ -129,6 +157,24 @@ fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 at_target.saturating_mul(size.saturating_sub(floor)) / span } +/// The resolution-rank term (`DESIGN.md` §5.5). The policy's +/// `resolution_preference` is an ordered list, so it says more than which +/// resolutions are eligible: the last entry is worth nothing and every +/// earlier one a step more, which is what makes a 4K release outrank a 1080p +/// one that scores the same against its own size band. +/// +/// A resolution the list does not carry is worth nothing rather than being +/// penalised — it is already ineligible, and no ranking is no opinion. +fn resolution_points(policy: &Policy, resolution: Resolution) -> i64 { + let preference = &policy.resolution_preference; + let Some(rank) = preference.iter().position(|entry| *entry == resolution) else { + return 0; + }; + let steps = preference.len().saturating_sub(1).saturating_sub(rank); + i64::from(policy.score_weights.resolution_step) + .saturating_mul(i64::try_from(steps).unwrap_or(i64::MAX)) +} + /// The source tiebreaker. A source the policy does not weight is worth /// nothing rather than being penalised. fn source_points(policy: &Policy, source: Source) -> i64 { @@ -219,6 +265,21 @@ mod tests { score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) } + /// The same, at a resolution the caller picks. + fn scored_at( + resolution: ClaimedResolution, + source: ClaimedSource, + size_bytes: u64, + seeders: u32, + ) -> Score { + let claims = NameClaims { + resolution: Some(resolution), + source: Some(source), + ..NameClaims::default() + }; + score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + } + fn size_rule(size_bytes: u64) -> RuleEvaluation { let policy = policy(); let overrides = TitleOverrides::default(); @@ -362,4 +423,81 @@ mod tests { assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(under.size, 0); } + + /// Issue #113, observed live: a 23 GB 4K release ranked below several + /// 1080p ones because each was scored against its own size band. + #[test] + fn a_slightly_over_target_4k_outranks_an_at_target_1080p() { + let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(23), 20); + let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20); + + assert!(uhd.size < hd.size); + assert!(uhd.total > hd.total); + } + + #[test] + fn a_4k_far_over_target_still_loses_to_an_at_target_1080p() { + let bloated = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(40), 20); + let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20); + + assert!(bloated.total < hd.total); + } + + #[test] + fn the_last_preferred_resolution_is_worth_nothing() { + let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20); + let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(22), 20); + + assert_eq!(hd.resolution, 0); + assert_eq!( + uhd.resolution, + i64::from(ScoreWeights::default().resolution_step) + ); + } + + #[test] + fn every_step_up_the_preference_is_worth_the_same() { + let mut policy = policy(); + policy.resolution_preference = + vec![Resolution::R2160p, Resolution::R1080p, Resolution::R720p]; + let step = i64::from(policy.score_weights.resolution_step); + let at = |resolution| { + let claims = NameClaims { + resolution: Some(resolution), + ..NameClaims::default() + }; + score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution + }; + + assert_eq!(at(ClaimedResolution::P2160), 2 * step); + assert_eq!(at(ClaimedResolution::P1080), step); + assert_eq!(at(ClaimedResolution::P720), 0); + } + + #[test] + fn an_unranked_resolution_scores_no_resolution_term() { + // 720p is not in the seeded preference, and an unclaimed resolution + // has said nothing at all. Neither is a penalty. + let unranked = scored_at(ClaimedResolution::P720, ClaimedSource::WebDl, gib(4), 10); + let unclaimed = NameClaims { + source: Some(ClaimedSource::WebDl), + ..NameClaims::default() + }; + let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10); + + assert_eq!(unranked.resolution, 0); + assert_eq!(unclaimed.resolution, 0); + } + + #[test] + fn the_resolution_term_outweighs_the_tiebreakers() { + // A 1080p remux with every seeder in the world must not beat a 4K + // WEB-DL sitting at its own target. + let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(22), 5); + let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::Remux, gib(8), 4096); + + assert!(hd.source > uhd.source); + assert!(hd.seeders > uhd.seeders); + assert!(uhd.total > hd.total); + } } diff --git a/crates/arr-db/migrations/0015_policy_resolution_rank.sql b/crates/arr-db/migrations/0015_policy_resolution_rank.sql new file mode 100644 index 0000000..7c0682b --- /dev/null +++ b/crates/arr-db/migrations/0015_policy_resolution_rank.sql @@ -0,0 +1,18 @@ +-- §5.5 cross-resolution ranking, issue #113. The size term scores a release +-- against the band for its own resolution, so it says nothing about how +-- resolutions rank against each other: an at-target 1080p and an at-target +-- 4K both score `size_at_target`, and a 4K a couple of gibibytes over target +-- lost to a 1080p that was merely on target. `resolution_pref` being an +-- ordered list already implies a preference; this is the weight that makes +-- the scorer honour it. +-- +-- The last entry of `resolution_pref` is worth nothing and each earlier one +-- `resolution_step` points more. 300 is chosen against the seeded 4K band +-- (target 22 GiB, 60 points per gibibyte over): five gibibytes of overshoot, +-- so a 4K up to 27 GiB beats an at-target 1080p and a bloated 4K past that +-- does not. Below target the same arithmetic asks a 4K to be within about +-- four gibibytes of its target to win, which keeps an 8 GiB 4K that looks +-- like mud from beating a good 1080p. +UPDATE policies +SET score_weights = json_set(score_weights, '$.resolution_step', 300), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 90da074..cdb95cf 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -223,6 +223,8 @@ mod tests { assert!(score_weights.contains("size_at_target"), "{score_weights}"); assert!(score_weights.contains("source_tier"), "{score_weights}"); assert!(score_weights.contains("seeder_doubling"), "{score_weights}"); + // §5.5: and the step that ranks resolutions against each other. + assert!(score_weights.contains("resolution_step"), "{score_weights}"); } } diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index b2655da..656dc44 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -128,6 +128,7 @@ impl PolicyColumns { size_at_target: score_weights.size_at_target, source_tier: score_weights.source_tier, seeder_doubling: score_weights.seeder_doubling, + resolution_step: score_weights.resolution_step, }, }) } @@ -340,6 +341,16 @@ struct ScoreWeightsJson { size_at_target: i32, source_tier: i32, seeder_doubling: i32, + /// Added in 0015. The column default written by 0004 predates it, so a + /// row inserted without the column would carry no resolution rank at + /// all; fall back to the engine's own default rather than silently + /// scoring every resolution the same. + #[serde(default = "default_resolution_step")] + resolution_step: i32, +} + +fn default_resolution_step() -> i32 { + ScoreWeights::default().resolution_step } #[derive(Debug, Default, Deserialize)] @@ -427,6 +438,36 @@ mod tests { assert_eq!(loaded.policy.score_weights.seeder_doubling, 1); } + /// §5.5, issue #113: the seeded rows carry a resolution rank, and a row + /// written before 0015 falls back to the engine's default rather than + /// ranking every resolution the same. + #[tokio::test] + async fn the_resolution_step_is_seeded_and_defaults_when_absent() { + let (_dir, db) = seeded().await; + let seeded_step = db + .movie_policy(1) + .await + .unwrap() + .expect("movie 1") + .policy + .score_weights + .resolution_step; + assert_eq!(seeded_step, 300); + + sqlx::query( + r#"UPDATE policies SET score_weights = '{"size_at_target":500,"source_tier":10,"seeder_doubling":1}'"#, + ) + .execute(db.pool()) + .await + .unwrap(); + let loaded = db.movie_policy(1).await.unwrap().expect("movie 1"); + + assert_eq!( + loaded.policy.score_weights.resolution_step, + ScoreWeights::default().resolution_step + ); + } + #[tokio::test] async fn per_title_overrides_ride_along() { let (_dir, db) = seeded().await; @@ -456,7 +497,9 @@ mod tests { .into(), resolution_pref: r#"["2160p"]"#.into(), source_weights: r#"{"WEB-DL":2}"#.into(), - score_weights: r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11}"#.into(), + score_weights: + r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11,"resolution_step":42}"# + .into(), } .to_policy() .unwrap(); @@ -467,6 +510,7 @@ mod tests { size_at_target: 2000, source_tier: 7, seeder_doubling: 11, + resolution_step: 42, } ); assert_eq!(policy.source_weights[&Source::WebDl], 2);