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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user