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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user