feat(core): rank resolutions in the score
ci / web (push) Successful in 42s
e2e / e2e (push) Successful in 1m13s
ci / rust (push) Successful in 1m18s

`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:
Miguel Palhas
2026-08-23 12:01:24 +01:00
parent 532eee7dbe
commit a5b3680746
6 changed files with 351 additions and 10 deletions
+126 -6
View File
@@ -59,6 +59,23 @@ pub struct TmdbMovie {
pub poster_path: Option<String>,
}
/// 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<String>,
}
@@ -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);
}