a5b3680746
`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 <noreply@anthropic.com>
504 lines
19 KiB
Rust
504 lines
19 KiB
Rust
//! 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<bool> {
|
|
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);
|
|
}
|
|
}
|