diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 8422911..c6b335f 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -128,14 +128,17 @@ pub struct HdrRules { /// One resolution's size band (`DESIGN.md` §5.5). Below the floor is a hard /// filter; the target is where the score peaks; above it the penalty grows -/// with every gigabyte. +/// with every gibibyte. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SizeBand { pub floor_bytes: u64, pub target_bytes: u64, - /// Points lost per gigabyte above `target_bytes`, against the scale set - /// by [`ScoreWeights::size_at_target`]. - pub penalty_per_gb_over: i32, + /// Points lost per gibibyte above `target_bytes`, on the scale set by + /// [`ScoreWeights::size_at_target`] — 1000 points at target means one + /// point is a tenth of a percent, fine enough to tune by hand without a + /// fractional type. Applied per byte over, so a partial gibibyte costs + /// its fraction. + pub penalty_points_per_gib_over: i32, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index 7f9a8f8..e1cb689 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -116,7 +116,8 @@ fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 if size > target { let over = size.saturating_sub(target); - let penalty = i64::from(band.penalty_per_gb_over).saturating_mul(over) / BYTES_PER_GIB; + let penalty = + i64::from(band.penalty_points_per_gib_over).saturating_mul(over) / BYTES_PER_GIB; return at_target.saturating_sub(penalty); } @@ -181,7 +182,7 @@ mod tests { SizeBand { floor_bytes: gib(8), target_bytes: gib(22), - penalty_per_gb_over: 60, + penalty_points_per_gib_over: 60, }, ), ( @@ -189,7 +190,7 @@ mod tests { SizeBand { floor_bytes: gib(3), target_bytes: gib(8), - penalty_per_gb_over: 60, + penalty_points_per_gib_over: 60, }, ), ]), @@ -351,7 +352,7 @@ mod tests { SizeBand { floor_bytes: gib(10), target_bytes: gib(10), - penalty_per_gb_over: 60, + penalty_points_per_gib_over: 60, }, ); let claims = claims(ClaimedSource::WebDl); diff --git a/crates/arr-db/migrations/0004_policy_score_weights.sql b/crates/arr-db/migrations/0004_policy_score_weights.sql new file mode 100644 index 0000000..0d22673 --- /dev/null +++ b/crates/arr-db/migrations/0004_policy_score_weights.sql @@ -0,0 +1,20 @@ +-- §5.5 scoring. The size bands seeded in 0002 predate the scoring model and +-- carry a penalty on no particular scale; the weights that make the terms +-- comparable had nowhere to live at all. Both are policy rows, tuned by hand, +-- so both belong here rather than in code. +-- +-- Points are the unit. A release at its band's target scores `size_at_target`; +-- every other term is small against that. Numbers stay placeholders pending +-- the remux playback test in §14. + +ALTER TABLE policies ADD COLUMN score_weights TEXT NOT NULL + DEFAULT '{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}' + CHECK (json_valid(score_weights)); + +-- Sizes in gibibytes, matching the bytes the scorer compares, and the penalty +-- restated in points: 60 points per gibibyte over target puts a 60 GiB remux +-- far below a 20 GiB WEB-DL while leaving it eligible. +UPDATE policies SET size_bands = json('{ + "2160p": {"floor_gib": 8, "target_gib": 22, "penalty_points_per_gib_over": 60}, + "1080p": {"floor_gib": 3, "target_gib": 8, "penalty_points_per_gib_over": 60} +}'); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 61b35a9..877beed 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -191,6 +191,35 @@ mod tests { ); } + #[tokio::test] + async fn every_policy_carries_its_scoring_numbers() { + let (_dir, db) = fresh().await; + + let rows = sqlx::query("SELECT size_bands, score_weights FROM policies") + .fetch_all(db.pool()) + .await + .expect("policies"); + + assert_eq!(rows.len(), 2); + for row in rows { + let size_bands: String = row.get(0); + let score_weights: String = row.get(1); + + // §5.5: floor, target and the penalty above target, per resolution. + assert!(size_bands.contains("floor_gib"), "{size_bands}"); + assert!(size_bands.contains("target_gib"), "{size_bands}"); + assert!( + size_bands.contains("penalty_points_per_gib_over"), + "{size_bands}" + ); + // §5.5: the weights that keep source tier and seeders small + // against the size term are tuned by hand too. + assert!(score_weights.contains("size_at_target"), "{score_weights}"); + assert!(score_weights.contains("source_tier"), "{score_weights}"); + assert!(score_weights.contains("seeder_doubling"), "{score_weights}"); + } + } + #[tokio::test] async fn foreign_keys_are_enforced() { let (_dir, db) = fresh().await;