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:
@@ -261,6 +261,25 @@ tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
|||||||
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
||||||
scores.
|
scores.
|
||||||
|
|
||||||
|
**How resolutions rank against each other.** Size is scored against the band
|
||||||
|
for the release's own resolution, so on its own it says nothing across
|
||||||
|
resolutions: an at-target 4K and an at-target 1080p both score the top of the
|
||||||
|
size term, and a 4K a couple of gigabytes over target loses to a 1080p that is
|
||||||
|
merely on target. That is wrong — `resolution_pref` is an ordered list, and the
|
||||||
|
order is a preference, not just an eligibility filter.
|
||||||
|
|
||||||
|
So each step up `resolution_pref` is worth a fixed number of points. The last
|
||||||
|
entry is worth nothing and every earlier one a step more. A resolution the list
|
||||||
|
does not carry scores nothing rather than being penalised, the same as an
|
||||||
|
unclaimed resolution: no ranking is no opinion.
|
||||||
|
|
||||||
|
The step is sized against the size term, not chosen in isolation. With the
|
||||||
|
seeded 4K band — target 22 GB, 60 points per gigabyte over — a step of 300 is
|
||||||
|
five gigabytes of overshoot: a 4K up to about 27 GB beats an at-target 1080p,
|
||||||
|
and a bloated 40 GB 4K does not. Below target the same arithmetic asks a 4K to
|
||||||
|
be within about four gigabytes of its target to win, which is what keeps an 8 GB
|
||||||
|
4K that looks like mud from beating a good 1080p.
|
||||||
|
|
||||||
Exact numbers are policy rows, tuned by hand. The model is the decision.
|
Exact numbers are policy rows, tuned by hand. The model is the decision.
|
||||||
|
|
||||||
### 5.6 Two phases of truth
|
### 5.6 Two phases of truth
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ pub struct TmdbMovie {
|
|||||||
pub poster_path: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
pub struct ClassifiedRelease {
|
pub struct ClassifiedRelease {
|
||||||
pub indexer_id: i64,
|
pub indexer_id: i64,
|
||||||
@@ -70,6 +87,8 @@ pub struct ClassifiedRelease {
|
|||||||
pub download_url: String,
|
pub download_url: String,
|
||||||
pub parsed: serde_json::Value,
|
pub parsed: serde_json::Value,
|
||||||
pub score: i64,
|
pub score: i64,
|
||||||
|
/// `score` broken into the terms that produced it.
|
||||||
|
pub score_terms: ScoreTerms,
|
||||||
pub verdict: String,
|
pub verdict: String,
|
||||||
pub rule: Option<String>,
|
pub rule: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -487,11 +506,24 @@ fn classify(
|
|||||||
release.size.unwrap_or_default(),
|
release.size.unwrap_or_default(),
|
||||||
release.seeders.unwrap_or_default(),
|
release.seeders.unwrap_or_default(),
|
||||||
);
|
);
|
||||||
let score = if release.size.is_some() {
|
// A release with no size has nothing to say about its size band, so that
|
||||||
score.total
|
// term is dropped rather than scored as if it were at the floor. Every
|
||||||
} else {
|
// other term still stands — the resolution rank comes from the name.
|
||||||
score.source.saturating_add(score.seeders)
|
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 {
|
Ok(ClassifiedRelease {
|
||||||
indexer_id: release.indexer_id,
|
indexer_id: release.indexer_id,
|
||||||
guid: release.guid,
|
guid: release.guid,
|
||||||
@@ -509,6 +541,7 @@ fn classify(
|
|||||||
parsed: serde_json::to_value(parsed)
|
parsed: serde_json::to_value(parsed)
|
||||||
.map_err(|error| ApiError::Database(error.to_string()))?,
|
.map_err(|error| ApiError::Database(error.to_string()))?,
|
||||||
score,
|
score,
|
||||||
|
score_terms: terms,
|
||||||
verdict: verdict.to_owned(),
|
verdict: verdict.to_owned(),
|
||||||
rule,
|
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)")
|
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");
|
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||||
sqlx::query(
|
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())
|
.execute(state.database().expect("database").pool())
|
||||||
.await
|
.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]
|
#[test]
|
||||||
fn releases_without_sizes_skip_the_size_score() {
|
fn releases_without_sizes_skip_the_size_score() {
|
||||||
let policy = Policy {
|
let policy = Policy {
|
||||||
@@ -848,8 +963,13 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classified.score,
|
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);
|
assert_ne!(classified.score, core_score.total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
//! WEB-DL at target beats a 60 GB remux while the remux keeps a score and
|
//! WEB-DL at target beats a 60 GB remux while the remux keeps a score and
|
||||||
//! stays eligible — it wins when nothing smaller exists.
|
//! 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
|
//! Below the floor is a hard filter rather than a low score, and lives in
|
||||||
//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise
|
//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise
|
||||||
//! select a 3 GB 4K encode that looks like mud.
|
//! 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 —
|
/// Points per doubling of the seeder count. Log-scaled and small —
|
||||||
/// enough seeders to complete matters, past that it does not.
|
/// enough seeders to complete matters, past that it does not.
|
||||||
pub seeder_doubling: i32,
|
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 {
|
impl Default for ScoreWeights {
|
||||||
@@ -39,6 +56,7 @@ impl Default for ScoreWeights {
|
|||||||
size_at_target: 1000,
|
size_at_target: 1000,
|
||||||
source_tier: 25,
|
source_tier: 25,
|
||||||
seeder_doubling: 8,
|
seeder_doubling: 8,
|
||||||
|
resolution_step: 300,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +72,10 @@ pub struct Score {
|
|||||||
pub source: i64,
|
pub source: i64,
|
||||||
/// The log-scaled seeder term.
|
/// The log-scaled seeder term.
|
||||||
pub seeders: i64,
|
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.
|
/// Score a candidate against a policy.
|
||||||
@@ -61,23 +83,29 @@ pub struct Score {
|
|||||||
/// A candidate with no applicable size band — unknown resolution, or a
|
/// A candidate with no applicable size band — unknown resolution, or a
|
||||||
/// resolution the policy carries no band for — scores zero on the dominant
|
/// 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.
|
/// 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]
|
#[must_use]
|
||||||
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
||||||
let weights = &policy.score_weights;
|
let weights = &policy.score_weights;
|
||||||
let size = candidate
|
let claimed = candidate.resolution();
|
||||||
.resolution()
|
let size = claimed
|
||||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
.and_then(|resolution| policy.size_bands.get(&resolution))
|
||||||
.map_or(0, |band| size_points(band, weights, size_bytes));
|
.map_or(0, |band| size_points(band, weights, size_bytes));
|
||||||
let source = candidate
|
let source = candidate
|
||||||
.source()
|
.source()
|
||||||
.map_or(0, |source| source_points(policy, source));
|
.map_or(0, |source| source_points(policy, source));
|
||||||
let seeders = seeder_points(weights, seeders);
|
let seeders = seeder_points(weights, seeders);
|
||||||
|
let resolution = claimed.map_or(0, |resolution| resolution_points(policy, resolution));
|
||||||
|
|
||||||
Score {
|
Score {
|
||||||
total: size.saturating_add(source).saturating_add(seeders),
|
total: size
|
||||||
|
.saturating_add(source)
|
||||||
|
.saturating_add(seeders)
|
||||||
|
.saturating_add(resolution),
|
||||||
size,
|
size,
|
||||||
source,
|
source,
|
||||||
seeders,
|
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
|
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
|
/// The source tiebreaker. A source the policy does not weight is worth
|
||||||
/// nothing rather than being penalised.
|
/// nothing rather than being penalised.
|
||||||
fn source_points(policy: &Policy, source: Source) -> i64 {
|
fn source_points(policy: &Policy, source: Source) -> i64 {
|
||||||
@@ -219,6 +265,21 @@ mod tests {
|
|||||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
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 {
|
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||||
let policy = policy();
|
let policy = policy();
|
||||||
let overrides = TitleOverrides::default();
|
let overrides = TitleOverrides::default();
|
||||||
@@ -362,4 +423,81 @@ mod tests {
|
|||||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||||
assert_eq!(under.size, 0);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- §5.5 cross-resolution ranking, issue #113. The size term scores a release
|
||||||
|
-- against the band for its own resolution, so it says nothing about how
|
||||||
|
-- resolutions rank against each other: an at-target 1080p and an at-target
|
||||||
|
-- 4K both score `size_at_target`, and a 4K a couple of gibibytes over target
|
||||||
|
-- lost to a 1080p that was merely on target. `resolution_pref` being an
|
||||||
|
-- ordered list already implies a preference; this is the weight that makes
|
||||||
|
-- the scorer honour it.
|
||||||
|
--
|
||||||
|
-- The last entry of `resolution_pref` is worth nothing and each earlier one
|
||||||
|
-- `resolution_step` points more. 300 is chosen against the seeded 4K band
|
||||||
|
-- (target 22 GiB, 60 points per gibibyte over): five gibibytes of overshoot,
|
||||||
|
-- so a 4K up to 27 GiB beats an at-target 1080p and a bloated 4K past that
|
||||||
|
-- does not. Below target the same arithmetic asks a 4K to be within about
|
||||||
|
-- four gibibytes of its target to win, which keeps an 8 GiB 4K that looks
|
||||||
|
-- like mud from beating a good 1080p.
|
||||||
|
UPDATE policies
|
||||||
|
SET score_weights = json_set(score_weights, '$.resolution_step', 300),
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
|
||||||
@@ -223,6 +223,8 @@ mod tests {
|
|||||||
assert!(score_weights.contains("size_at_target"), "{score_weights}");
|
assert!(score_weights.contains("size_at_target"), "{score_weights}");
|
||||||
assert!(score_weights.contains("source_tier"), "{score_weights}");
|
assert!(score_weights.contains("source_tier"), "{score_weights}");
|
||||||
assert!(score_weights.contains("seeder_doubling"), "{score_weights}");
|
assert!(score_weights.contains("seeder_doubling"), "{score_weights}");
|
||||||
|
// §5.5: and the step that ranks resolutions against each other.
|
||||||
|
assert!(score_weights.contains("resolution_step"), "{score_weights}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ impl PolicyColumns {
|
|||||||
size_at_target: score_weights.size_at_target,
|
size_at_target: score_weights.size_at_target,
|
||||||
source_tier: score_weights.source_tier,
|
source_tier: score_weights.source_tier,
|
||||||
seeder_doubling: score_weights.seeder_doubling,
|
seeder_doubling: score_weights.seeder_doubling,
|
||||||
|
resolution_step: score_weights.resolution_step,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -340,6 +341,16 @@ struct ScoreWeightsJson {
|
|||||||
size_at_target: i32,
|
size_at_target: i32,
|
||||||
source_tier: i32,
|
source_tier: i32,
|
||||||
seeder_doubling: i32,
|
seeder_doubling: i32,
|
||||||
|
/// Added in 0015. The column default written by 0004 predates it, so a
|
||||||
|
/// row inserted without the column would carry no resolution rank at
|
||||||
|
/// all; fall back to the engine's own default rather than silently
|
||||||
|
/// scoring every resolution the same.
|
||||||
|
#[serde(default = "default_resolution_step")]
|
||||||
|
resolution_step: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_resolution_step() -> i32 {
|
||||||
|
ScoreWeights::default().resolution_step
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -427,6 +438,36 @@ mod tests {
|
|||||||
assert_eq!(loaded.policy.score_weights.seeder_doubling, 1);
|
assert_eq!(loaded.policy.score_weights.seeder_doubling, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5.5, issue #113: the seeded rows carry a resolution rank, and a row
|
||||||
|
/// written before 0015 falls back to the engine's default rather than
|
||||||
|
/// ranking every resolution the same.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_resolution_step_is_seeded_and_defaults_when_absent() {
|
||||||
|
let (_dir, db) = seeded().await;
|
||||||
|
let seeded_step = db
|
||||||
|
.movie_policy(1)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("movie 1")
|
||||||
|
.policy
|
||||||
|
.score_weights
|
||||||
|
.resolution_step;
|
||||||
|
assert_eq!(seeded_step, 300);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"UPDATE policies SET score_weights = '{"size_at_target":500,"source_tier":10,"seeder_doubling":1}'"#,
|
||||||
|
)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let loaded = db.movie_policy(1).await.unwrap().expect("movie 1");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
loaded.policy.score_weights.resolution_step,
|
||||||
|
ScoreWeights::default().resolution_step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn per_title_overrides_ride_along() {
|
async fn per_title_overrides_ride_along() {
|
||||||
let (_dir, db) = seeded().await;
|
let (_dir, db) = seeded().await;
|
||||||
@@ -456,7 +497,9 @@ mod tests {
|
|||||||
.into(),
|
.into(),
|
||||||
resolution_pref: r#"["2160p"]"#.into(),
|
resolution_pref: r#"["2160p"]"#.into(),
|
||||||
source_weights: r#"{"WEB-DL":2}"#.into(),
|
source_weights: r#"{"WEB-DL":2}"#.into(),
|
||||||
score_weights: r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11}"#.into(),
|
score_weights:
|
||||||
|
r#"{"size_at_target":2000,"source_tier":7,"seeder_doubling":11,"resolution_step":42}"#
|
||||||
|
.into(),
|
||||||
}
|
}
|
||||||
.to_policy()
|
.to_policy()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -467,6 +510,7 @@ mod tests {
|
|||||||
size_at_target: 2000,
|
size_at_target: 2000,
|
||||||
source_tier: 7,
|
source_tier: 7,
|
||||||
seeder_doubling: 11,
|
seeder_doubling: 11,
|
||||||
|
resolution_step: 42,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(policy.source_weights[&Source::WebDl], 2);
|
assert_eq!(policy.source_weights[&Source::WebDl], 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user