//! Scoring by distance from a target size (`DESIGN.md` §5.5). //! //! Source tier is not the dominant term. Per resolution the policy carries a //! floor, a target and a penalty for every gigabyte above target, so a 20 GB //! 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. //! //! Every number here comes from the policy row. The placeholders in //! [`ScoreWeights::default`] and in the seeded size bands are pending the //! remux playback test in `DESIGN.md` §14; the model is the decision, not the //! constants. use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; const BYTES_PER_GIB: i64 = 1 << 30; /// How much each scoring term is worth. Policy data, not constants in the /// code, for the same reason the size bands are. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ScoreWeights { /// Points awarded to a release sitting exactly at its band's target. The /// top of the size term, and the scale every other term is small against. pub size_at_target: i32, /// Points per step of the policy's `source_weights`. Small: the source /// tier is a tiebreaker. pub source_tier: i32, /// 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 { fn default() -> Self { Self { size_at_target: 1000, source_tier: 25, seeder_doubling: 8, resolution_step: 300, } } } /// One candidate's score, kept as its terms so the UI can explain a ranking. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct Score { pub total: i64, /// Distance from the band's target. Zero when no band applies, which is /// also what an unknown resolution scores. pub size: i64, /// The source-tier tiebreaker. 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. /// /// 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 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) .saturating_add(resolution), size, source, seeders, resolution, } } /// Score a stored release from the claims parsed out of its name. /// /// Pre-grab, the name is all there is (`DESIGN.md` §5.6). #[must_use] pub fn score_release(policy: &Policy, release: &Release) -> Score { score( policy, Candidate::PreGrab(&release.parsed), release.size, release.seeders, ) } /// Whether a size falls below its band's floor, which is a hard filter. /// /// `None` when the policy carries no band for that resolution: no band is no /// opinion, not a rejection. #[must_use] pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option { policy .size_bands .get(&resolution) .map(|band| size_bytes < band.floor_bytes) } /// The size term: a ramp from the floor up to the target, then a penalty that /// grows with every gigabyte above it. fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 { let at_target = i64::from(weights.size_at_target); let size = as_i64(size_bytes); let floor = as_i64(band.floor_bytes); let target = as_i64(band.target_bytes); if size > target { let over = size.saturating_sub(target); let penalty = i64::from(band.penalty_points_per_gib_over).saturating_mul(over) / BYTES_PER_GIB; return at_target.saturating_sub(penalty); } let span = target.saturating_sub(floor); if span <= 0 { // A degenerate band, floor at or above target: nothing to interpolate. return if size >= target { at_target } else { 0 }; } 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 { let tier = policy.source_weights.get(&source).copied().unwrap_or(0); i64::from(policy.score_weights.source_tier).saturating_mul(i64::from(tier)) } /// The seeder term, log-scaled: one step per doubling, and nothing at all for /// a release nobody is seeding. fn seeder_points(weights: &ScoreWeights, seeders: u32) -> i64 { let doublings = seeders.checked_ilog2().map_or(0, |log| i64::from(log) + 1); i64::from(weights.seeder_doubling).saturating_mul(doublings) } fn as_i64(value: u64) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) } #[cfg(test)] mod tests { use std::collections::BTreeMap; use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; use super::*; use crate::{ policy::{evaluate, EvaluationContext, PolicyRule, RuleEvaluation, SizeRule}, HdrRules, Language, PolicyId, RequiredAudio, Rule, TitleOverrides, Verdict, }; const GIB: u64 = 1 << 30; fn gib(n: u64) -> u64 { n * GIB } /// The seeded `main` policy from `DESIGN.md` §5.5: 4K floor 8 GB, target /// 22 GB; 1080p floor 3 GB, target 8 GB. fn policy() -> Policy { Policy { id: PolicyId(1), name: "test".to_owned(), required_audio: RequiredAudio::OriginalLanguage, dub_blacklist: vec![Language::PortugueseBrazil], hdr_rules: HdrRules { rejected_dolby_vision_profiles: Vec::new(), }, size_bands: BTreeMap::from([ ( Resolution::R2160p, SizeBand { floor_bytes: gib(8), target_bytes: gib(22), penalty_points_per_gib_over: 60, }, ), ( Resolution::R1080p, SizeBand { floor_bytes: gib(3), target_bytes: gib(8), penalty_points_per_gib_over: 60, }, ), ]), resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], source_weights: BTreeMap::from([ (Source::Remux, 4), (Source::BluRay, 3), (Source::WebDl, 2), (Source::WebRip, 1), (Source::Hdtv, 0), ]), score_weights: ScoreWeights::default(), } } fn claims(source: ClaimedSource) -> NameClaims { NameClaims { resolution: Some(ClaimedResolution::P2160), source: Some(source), ..NameClaims::default() } } fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score { let claims = claims(source); 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(); let language = Language::Other("en".to_owned()); let claims = claims(ClaimedSource::WebDl); SizeRule.evaluate(&EvaluationContext { policy: &policy, overrides: &overrides, original_language: &language, candidate: Candidate::PreGrab(&claims), size_bytes: Some(size_bytes), }) } /// The acceptance case from issue #13, over a realistic candidate set. #[test] fn twenty_gigabyte_web_dl_beats_a_sixty_gigabyte_remux() { let candidates = [ ("web-dl 20 GB", scored(ClaimedSource::WebDl, gib(20), 40)), ("remux 60 GB", scored(ClaimedSource::Remux, gib(60), 12)), ("bluray 34 GB", scored(ClaimedSource::BluRay, gib(34), 25)), ("web-dl 12 GB", scored(ClaimedSource::WebDl, gib(12), 90)), ]; let best = candidates .iter() .max_by_key(|(_, score)| score.total) .map(|(name, _)| *name); assert_eq!(best, Some("web-dl 20 GB")); } #[test] fn the_remux_still_wins_when_it_is_the_only_candidate() { let policy = policy(); let claims = claims(ClaimedSource::Remux); let evaluation = evaluate( &policy, &TitleOverrides::default(), &Language::Other("en".to_owned()), Candidate::PreGrab(&claims), Some(gib(60)), ); // A bad score, but a score: nothing filters it out, so a selection // with one candidate takes it. assert!(scored(ClaimedSource::Remux, gib(60), 12).total < 0); assert_eq!(evaluation.verdict, Verdict::Eligible); } #[test] fn size_beats_source_tier() { let at_target = scored(ClaimedSource::WebRip, gib(22), 10); let far_over = scored(ClaimedSource::Remux, gib(40), 10); assert!(at_target.total > far_over.total); assert!(at_target.source < far_over.source); } #[test] fn source_tier_breaks_a_tie_between_equal_sizes() { let web = scored(ClaimedSource::WebDl, gib(20), 10); let bluray = scored(ClaimedSource::BluRay, gib(20), 10); assert_eq!(web.size, bluray.size); assert!(bluray.total > web.total); } #[test] fn the_size_term_peaks_at_target() { let below = scored(ClaimedSource::WebDl, gib(15), 10).size; let at = scored(ClaimedSource::WebDl, gib(22), 10).size; let above = scored(ClaimedSource::WebDl, gib(30), 10).size; assert_eq!(at, i64::from(ScoreWeights::default().size_at_target)); assert!(below < at); assert!(above < at); } #[test] fn the_penalty_grows_with_every_gigabyte_over_target() { let over_by_ten = scored(ClaimedSource::WebDl, gib(32), 10).size; let over_by_twenty = scored(ClaimedSource::WebDl, gib(42), 10).size; let over_by_thirty = scored(ClaimedSource::WebDl, gib(52), 10).size; assert!(over_by_ten > over_by_twenty); assert_eq!( over_by_ten - over_by_twenty, over_by_twenty - over_by_thirty ); } #[test] fn seeders_are_log_scaled_and_small() { let few = scored(ClaimedSource::WebDl, gib(20), 8); let many = scored(ClaimedSource::WebDl, gib(20), 4096); // Nine doublings apart, and still worth less than one source tier. assert_eq!(many.seeders - few.seeders, 9 * 8); assert!(many.seeders - few.seeders < few.size); assert_eq!(scored(ClaimedSource::WebDl, gib(20), 0).seeders, 0); } #[test] fn below_the_floor_is_a_hard_filter_not_a_low_score() { assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert_eq!( is_below_floor(&policy(), Resolution::R2160p, gib(3)), Some(true) ); assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None); } #[test] fn an_unknown_resolution_scores_no_size_term() { let claims = NameClaims { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10); assert_eq!(score.size, 0); assert_eq!(score.total, score.source + score.seeders); } #[test] fn a_degenerate_band_does_not_divide_by_zero() { let mut policy = policy(); policy.size_bands.insert( Resolution::R2160p, SizeBand { floor_bytes: gib(10), target_bytes: gib(10), penalty_points_per_gib_over: 60, }, ); let claims = claims(ClaimedSource::WebDl); let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10); let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10); 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); } }