feat: rank 4K first, fall back to good 1080p
ci / web (push) Successful in 1m3s
e2e / e2e (push) Failing after 1m37s
ci / rust (push) Successful in 2m21s
ci / image (push) Successful in 2m33s

Resolution preference becomes strict tiers (step 10 000) and every
size band gains a ceiling that waives bloated releases, so a 60 GB
remux cannot win the top tier unattended. Movie grab, TV grab, RSS
and the search deck now share one ranking key from arr-core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-11-07 18:02:12 +00:00
co-authored by Claude Opus 5.5
parent abb7e0a489
commit cd9e8a891c
13 changed files with 290 additions and 69 deletions
+33 -14
View File
@@ -290,9 +290,18 @@ in search results means "the name claims DV" and its absence means nothing.
### 5.4 Quality, and why there is no upgrade loop
Default: take 4K if it exists now, otherwise 1080p. Per-title `only_4k`
rejects 1080p outright and keeps searching indefinitely — the escape hatch for
the handful of titles worth waiting on.
Default: take 4K if it exists now, otherwise a good 1080p. The resolution
preference is a list of tiers, not a weighting: any eligible 4K outranks every
1080p, and 1080p is only ever the fallback when no eligible 4K exists. "Good"
means eligible under the same rules as 4K — inside its size band, between the
floor and the ceiling (§5.5), with the right audio and a watchable source. A
1080p that fails that bar is not taken, and the title keeps waiting. Per-title
`only_4k` rejects 1080p outright and keeps searching indefinitely — the escape
hatch for the handful of titles worth waiting on.
A 1080p taken as the fallback is final. The file row shows its resolution, and
the manual search is how it becomes 4K later; nothing re-searches it on its
own (below).
**No grab delay.** Deliberately rejected. Radarr's delay profile exists to stop
first-match-wins from always yielding 1080p; here the `only_4k` flag solves the
@@ -307,11 +316,18 @@ a marker in the UI and a manual "search again" button, not a background job.
Source tier is **not** the dominant term. A remux should win only when nothing
smaller exists.
Per resolution, a floor, a target and a growing penalty above target. For 4K:
floor ~8 GB, target ~22 GB. A 20 GB WEB-DL sits at target and wins; a 60 GB
remux scores badly but stays eligible, so it is picked when it is the only
option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K
encode that looks like mud.
Per resolution, a floor, a target, a growing penalty above target and a
ceiling. For 4K: floor ~8 GB, target ~22 GB, ceiling ~44 GB. A 20 GB WEB-DL
sits at target and wins; a 34 GB BluRay scores worse but stays eligible. The
floor matters — unbounded "smaller is better" selects a 3 GB 4K encode that
looks like mud.
**The ceiling waives, it does not reject.** Strict resolution tiers would
otherwise prefer a 60 GB remux over every good 1080p, which is a bloated
download for something already watchable. Above the ceiling a release is
`waived`: automatic selection never takes it, the manual deck still offers it.
The ceiling is measured exactly as the floor is — per episode, scaled by
runtime — and a band without one has no ceiling.
**A band describes one episode.** For a movie the question never arises — one
release is one film. For TV a release may carry a single episode, several, or a
@@ -372,12 +388,15 @@ 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.
The step is a tier, not a trade-off (§5.4). At 10 000 points it is larger than
everything the size, source and seeder terms can add or take away inside a
band, so the step decides between resolutions and the other terms only order
releases of the same one. The band's floor and ceiling are what keep a mud
encode or a bloated remux out of the top tier, not the step.
Every selection — targeted movie search, TV search, RSS and the manual deck —
orders candidates through one key in `arr-core`: score, then indexer id, then
guid. The deck's top eligible row is the release an automatic grab would take.
Exact numbers are policy rows, tuned by hand. The model is the decision.
+26 -1
View File
@@ -39,6 +39,10 @@ pub struct SizeBandSpec {
pub floor_gib: u64,
pub target_gib: u64,
pub penalty_points_per_gib_over: i32,
/// Above this a release is waived, never picked automatically. Absent
/// is no ceiling.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ceiling_gib: Option<u64>,
}
/// The scoring weights (`DESIGN.md` §5.5). `resolution_step` defaults to the
@@ -117,10 +121,18 @@ impl PolicyInput {
));
}
}
for resolution in self.size_bands.keys() {
for (resolution, band) in &self.size_bands {
if arr_db::policy::resolution_value(resolution).is_none() {
return Err(format!("size_bands: unknown resolution '{resolution}'"));
}
if band
.ceiling_gib
.is_some_and(|ceiling| ceiling < band.target_gib)
{
return Err(format!(
"size_bands.{resolution}.ceiling_gib: must not be below target_gib"
));
}
}
let mut seen = std::collections::BTreeSet::new();
for resolution in &self.resolution_pref {
@@ -696,6 +708,19 @@ mod tests {
}),
"source_weights",
),
(
with(&|payload| {
payload["size_bands"] = serde_json::json!({
"1080p": {
"floor_gib": 3,
"target_gib": 8,
"penalty_points_per_gib_over": 60,
"ceiling_gib": 6
}
});
}),
"ceiling_gib",
),
];
for (payload, field) in cases {
+19 -2
View File
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::time::UNIX_EPOCH;
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::{claimed_episode_count, score};
use arr_core::score::{claimed_episode_count, score, RankKey};
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
use arr_db::policy::language;
use arr_db::{blacklist, Blacklist};
@@ -482,7 +482,13 @@ pub async fn releases(
))
}
};
classified.sort_by_key(|release| (bucket(&release.verdict), -release.score));
// The daemon's own order inside each bucket, so the deck's top eligible
// row is the release an automatic grab would take.
classified.sort_by(|left, right| {
bucket(&left.verdict)
.cmp(&bucket(&right.verdict))
.then_with(|| rank_key(left).cmp(&rank_key(right)))
});
Ok(Json(classified))
}
@@ -949,6 +955,14 @@ fn rule_name(rule: &Rule) -> String {
}
}
fn rank_key(release: &ClassifiedRelease) -> RankKey<'_> {
RankKey {
score: release.score,
indexer_id: release.indexer_id,
guid: &release.guid,
}
}
fn bucket(verdict: &str) -> u8 {
match verdict {
"eligible" => 0,
@@ -1661,6 +1675,7 @@ mod tests {
floor_bytes: 8 << 30,
target_bytes: 22 << 30,
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
(
@@ -1669,6 +1684,7 @@ mod tests {
floor_bytes: 3 << 30,
target_bytes: 8 << 30,
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
]),
@@ -1694,6 +1710,7 @@ mod tests {
floor_bytes: 8 << 30,
target_bytes: 22 << 30,
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
)]),
resolution_preference: vec![Resolution::R2160p],
+7 -1
View File
@@ -212,7 +212,8 @@ 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 gibibyte.
/// with every gibibyte, and above the ceiling the release is waived rather
/// than eligible.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SizeBand {
pub floor_bytes: u64,
@@ -223,6 +224,11 @@ pub struct SizeBand {
/// fractional type. Applied per byte over, so a partial gibibyte costs
/// its fraction.
pub penalty_points_per_gib_over: i32,
/// Above this the release is bloated: automatic selection never takes
/// it, a manual grab still can. `None` is no ceiling. It is what keeps
/// strict resolution tiers from preferring a 60 GB 4K remux over every
/// good 1080p (§5.4).
pub ceiling_bytes: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
+39
View File
@@ -256,6 +256,18 @@ impl PolicyRule for SizeRule {
else {
return RuleEvaluation::Unknown(RuleKind::Size);
};
// Bloat is never a reason to throw a release away, only a reason not
// to pick it unattended: the ceiling waives in both phases.
if crate::score::is_above_ceiling(
context.policy,
resolution,
size,
context.episode_count,
context.runtime_minutes,
) == Some(true)
{
return RuleEvaluation::SoftFail(Rule::Size);
}
match crate::score::is_below_floor(
context.policy,
resolution,
@@ -700,6 +712,7 @@ mod tests {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
ceiling_bytes: None,
},
)]),
resolution_preference: vec![Resolution::R1080p],
@@ -707,6 +720,32 @@ mod tests {
}
}
#[test]
fn above_the_ceiling_is_waived_and_a_pack_is_measured_per_episode() {
let mut policy = banded_policy();
if let Some(band) = policy.size_bands.get_mut(&Resolution::R1080p) {
band.ceiling_bytes = Some(10 << 30);
}
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::Remux));
let verdict_at = |size: u64, episodes: u32| {
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(size),
episodes,
0,
)
.verdict
};
assert_eq!(verdict_at(12 << 30, 1), Verdict::Waived(Rule::Size));
assert_eq!(verdict_at(10 << 30, 1), Verdict::Eligible);
// Ten episodes at 3 GiB each sit well under a 10 GiB ceiling.
assert_eq!(verdict_at(30 << 30, 10), Verdict::Eligible);
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
+80 -19
View File
@@ -8,7 +8,8 @@
//! 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.
//! each step up it is worth more than every other term together, so the list
//! reads as tiers — 4K if there is any, otherwise the best 1080p.
//!
//! Below the floor is a hard filter rather than a low score, and lives in
//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise
@@ -50,12 +51,11 @@ pub struct ScoreWeights {
/// 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.
/// Sized to dwarf every other term, so the steps are tiers: any eligible
/// release of a preferred resolution outranks every release of the next
/// one, and the size, source and seeder terms only order releases inside
/// a tier (§5.4). The size band's floor and ceiling are what keep a mud
/// encode or a bloated remux out of the top tier, not this weight.
pub resolution_step: i32,
}
@@ -65,7 +65,7 @@ impl Default for ScoreWeights {
size_at_target: 1000,
source_tier: 25,
seeder_doubling: 8,
resolution_step: 300,
resolution_step: 10_000,
}
}
}
@@ -141,6 +141,35 @@ pub fn score(
}
}
/// Where a candidate sits in the one order every selection uses: targeted
/// movie search, TV search, RSS and the manual-search deck. Higher score
/// first, then lower indexer id, then guid, so the same candidates always
/// produce the same winner whichever trigger looked at them.
///
/// Sorting ascending by this key puts the best candidate first.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RankKey<'a> {
pub score: i64,
pub indexer_id: i64,
pub guid: &'a str,
}
impl Ord for RankKey<'_> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.score
.cmp(&self.score)
.then_with(|| self.indexer_id.cmp(&other.indexer_id))
.then_with(|| self.guid.cmp(other.guid))
}
}
impl PartialOrd for RankKey<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// Score a stored release from the claims parsed out of its name.
///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
@@ -185,6 +214,25 @@ pub fn is_below_floor(
})
}
/// Whether a size sits above its band's ceiling: bloated, so waived rather
/// than eligible (`DESIGN.md` §5.4). Measured and scaled exactly as
/// [`is_below_floor`] measures the floor.
///
/// `None` when the policy carries no band for that resolution, or the band
/// carries no ceiling.
#[must_use]
pub fn is_above_ceiling(
policy: &Policy,
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> {
let band = scaled_band(policy.size_bands.get(&resolution)?, runtime_minutes);
band.ceiling_bytes
.map(|ceiling| per_episode_size(size_bytes, episode_count) > ceiling)
}
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
/// behind [`score`] and [`is_below_floor`].
///
@@ -230,6 +278,9 @@ fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
ceiling_bytes: band
.ceiling_bytes
.map(|ceiling| scale_by_runtime(ceiling, runtime_minutes)),
}
}
@@ -334,6 +385,7 @@ mod tests {
floor_bytes: gib(8),
target_bytes: gib(22),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
(
@@ -342,6 +394,7 @@ mod tests {
floor_bytes: gib(3),
target_bytes: gib(8),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
]),
@@ -456,7 +509,7 @@ mod tests {
// A bad score, but a score: nothing filters it out, so a selection
// with one candidate takes it.
assert!(scored(ClaimedSource::Remux, gib(60), 12).total < 0);
assert!(scored(ClaimedSource::Remux, gib(60), 12).size < 0);
assert_eq!(evaluation.verdict, Verdict::Eligible);
}
@@ -548,6 +601,7 @@ mod tests {
floor_bytes: gib(10),
target_bytes: gib(10),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
);
let claims = claims(ClaimedSource::WebDl);
@@ -569,12 +623,16 @@ mod tests {
assert!(uhd.total > hd.total);
}
/// §5.4: 4K if there is any. Inside the band a 4K outranks every 1080p,
/// however far over target it is and however good the 1080p.
#[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);
fn a_4k_far_over_target_still_outranks_an_at_target_1080p() {
let bloated = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(40), 2);
let mud = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(9), 2);
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::Remux, gib(8), 4096);
assert!(bloated.total < hd.total);
assert!(bloated.total > hd.total);
assert!(mud.total > hd.total);
}
#[test]
@@ -644,11 +702,11 @@ mod tests {
assert_eq!(
at_target,
Score {
total: 1398,
total: 11098,
size: 1000,
source: 50,
seeders: 48,
resolution: 300,
resolution: 10_000,
}
);
@@ -656,11 +714,11 @@ mod tests {
assert_eq!(
over_target,
Score {
total: 918,
total: 10618,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
resolution: 10_000,
}
);
}
@@ -774,6 +832,7 @@ mod tests {
floor_bytes: gib(1),
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
);
let episodes = 10;
@@ -802,11 +861,11 @@ mod tests {
assert_eq!(
missing,
Score {
total: 918,
total: 10618,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
resolution: 10_000,
}
);
assert_eq!(
@@ -881,6 +940,7 @@ mod tests {
floor_bytes: GIB / 4,
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
(
@@ -889,6 +949,7 @@ mod tests {
floor_bytes: gib(1),
target_bytes: gib(6),
penalty_points_per_gib_over: 60,
ceiling_bytes: None,
},
),
]),
+13 -9
View File
@@ -19,7 +19,7 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::{claimed_episode_count, score};
use arr_core::score::{claimed_episode_count, score, RankKey};
use arr_core::{Language, Policy, TitleOverrides, Verdict};
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
use arr_dl::{AddTorrent, QbitClient, TorrentSource};
@@ -262,13 +262,7 @@ impl GrabAction {
candidates.push(candidate);
}
}
candidates.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.indexer_id.cmp(&right.indexer_id))
.then_with(|| left.guid.cmp(&right.guid))
});
candidates.sort_by(|left, right| left.rank_key().cmp(&right.rank_key()));
Ok(candidates)
}
@@ -885,6 +879,16 @@ pub(crate) struct Eligible {
pub(crate) score: i64,
}
impl Eligible {
pub(crate) fn rank_key(&self) -> RankKey<'_> {
RankKey {
score: self.score,
indexer_id: self.indexer_id,
guid: &self.guid,
}
}
}
/// The gap, straight out of the domain rows (§8).
///
/// A title with an unfinished grab is not a gap — that is what stops a
@@ -1648,7 +1652,7 @@ mod tests {
<item>
<title>Dune.Part.Two.2024.2160p.REMUX</title>
<guid>huge</guid><link>https://tracker/huge.torrent</link>
<size>64424509440</size>
<size>42949672960</size>
<torznab:attr xmlns:torznab="http://torznab.com/schemas/2015/feed" name="seeders" value="12"/>
</item>
<item>
+2 -7
View File
@@ -425,12 +425,7 @@ struct Grabbable {
/// Better score first, then the tiebreaks targeted search sorts by, so both
/// triggers pick the same release out of the same candidates.
fn beats(candidate: &Eligible, incumbent: &Eligible) -> bool {
candidate
.score
.cmp(&incumbent.score)
.then_with(|| incumbent.indexer_id.cmp(&candidate.indexer_id))
.then_with(|| incumbent.guid.cmp(&candidate.guid))
== std::cmp::Ordering::Greater
candidate.rank_key() < incumbent.rank_key()
}
/// Every wanted title with neither a file nor a grab in flight.
@@ -736,7 +731,7 @@ mod tests {
<item>
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP</title>
<guid>tv-pack</guid><link>https://indexer.invalid/download/tv-pack</link>
<enclosure url="https://indexer.invalid/download/tv-pack" length="85899345920"
<enclosure url="https://indexer.invalid/download/tv-pack" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="120"/>
</item>
+2 -8
View File
@@ -1186,13 +1186,7 @@ fn best_candidate<'a>(
let mut eligible: Vec<&Eligible> = candidates
.filter_map(|candidate| candidate.eligible.as_ref())
.collect();
eligible.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.indexer_id.cmp(&right.indexer_id))
.then_with(|| left.guid.cmp(&right.guid))
});
eligible.sort_by(|left, right| left.rank_key().cmp(&right.rank_key()));
eligible
.into_iter()
.find(|candidate| !blacklist.blocks_candidate(&candidate.name, &candidate.download_url))
@@ -1239,7 +1233,7 @@ mod tests {
<item>
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos</title>
<guid>pack</guid><link>https://tracker/pack.torrent</link>
<size>85899345920</size>
<size>32212254720</size>
<torznab:attr xmlns:torznab="http://torznab.com/schemas/2015/feed" name="seeders" value="120"/>
</item>
<item>
@@ -0,0 +1,35 @@
-- §5.4: 4K if there is any, otherwise a good 1080p. The resolution step was
-- 300 points, small enough that an on-target 1080p outranked a 4K a few
-- gibibytes off its own target. 10 000 dwarfs every other term, so the
-- preference list reads as tiers and the size, source and seeder terms only
-- order releases inside one.
--
-- With strict tiers a bloated 4K remux would beat every 1080p, so each band
-- gains a ceiling. Above it a release is waived: never picked automatically,
-- still a manual grab. Twice the target for 4K and two and a half times for
-- 1080p, which on the seeded bands is 44 GiB and 20 GiB for movies, 12 GiB
-- and 5 GiB per episode for TV. A band that already carries a ceiling keeps
-- it.
UPDATE policies
SET score_weights = json_set(score_weights, '$.resolution_step', 10000),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
UPDATE policies
SET size_bands = json_set(
size_bands,
'$."2160p".ceiling_gib',
json_extract(size_bands, '$."2160p".target_gib') * 2
),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE json_extract(size_bands, '$."2160p"') IS NOT NULL
AND json_extract(size_bands, '$."2160p".ceiling_gib') IS NULL;
UPDATE policies
SET size_bands = json_set(
size_bands,
'$."1080p".ceiling_gib',
CAST(json_extract(size_bands, '$."1080p".target_gib') * 5 / 2 AS INTEGER)
),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE json_extract(size_bands, '$."1080p"') IS NOT NULL
AND json_extract(size_bands, '$."1080p".ceiling_gib') IS NULL;
+8 -1
View File
@@ -111,6 +111,7 @@ impl PolicyColumns {
floor_bytes: gib(band.floor_gib),
target_bytes: gib(band.target_gib),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
ceiling_bytes: band.ceiling_gib.map(gib),
},
)
})
@@ -474,6 +475,10 @@ struct SizeBandJson {
floor_gib: u64,
target_gib: u64,
penalty_points_per_gib_over: i32,
/// Added in 0035. Absent means no ceiling, which is what every band
/// carried before it.
#[serde(default)]
ceiling_gib: Option<u64>,
}
#[derive(Debug, Deserialize)]
@@ -575,6 +580,7 @@ mod tests {
assert_eq!(band.floor_bytes, 8 << 30);
assert_eq!(band.target_bytes, 22 << 30);
assert_eq!(band.penalty_points_per_gib_over, 60);
assert_eq!(band.ceiling_bytes, Some(44 << 30));
assert_eq!(loaded.policy.score_weights.size_at_target, 500);
assert_eq!(loaded.policy.score_weights.source_tier, 10);
assert_eq!(loaded.policy.score_weights.seeder_doubling, 1);
@@ -594,7 +600,8 @@ mod tests {
.policy
.score_weights
.resolution_step;
assert_eq!(seeded_step, 300);
// 0015 seeded 300; 0035 raised it to a tier (§5.4).
assert_eq!(seeded_step, 10_000);
sqlx::query(
r#"UPDATE policies SET score_weights = '{"size_at_target":500,"source_tier":10,"seeder_doubling":1}'"#,
+21 -6
View File
@@ -17,7 +17,12 @@ export interface PolicyDoc {
hdr_rules: { dv_profile_reject: string[] };
size_bands: Record<
string,
{ floor_gib: number; target_gib: number; penalty_points_per_gib_over: number }
{
floor_gib: number;
target_gib: number;
penalty_points_per_gib_over: number;
ceiling_gib?: number;
}
>;
resolution_pref: string[];
source_weights: Record<string, number>;
@@ -501,7 +506,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
size_at_target: 1000,
source_tier: 25,
seeder_doubling: 8,
resolution_step: 300,
resolution_step: 10000,
},
};
}
@@ -557,7 +562,8 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
profilesInput = profiles.querySelector("input") ?? document.createElement("input");
grid.append(profiles);
// one line per resolution: floor, target, penalty — numbers stay numbers
// one line per resolution: floor, target, penalty, ceiling — numbers stay
// numbers, and an empty ceiling is no ceiling
for (const resolution of RESOLUTIONS) {
const band = current.size_bands[resolution];
const line = el("div", "band-line");
@@ -566,10 +572,16 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
const floor = numberControl(band?.floor_gib ?? 0);
const target = numberControl(band?.target_gib ?? 0);
const penalty = numberControl(band?.penalty_points_per_gib_over ?? 0);
const ceiling = numberControl(band?.ceiling_gib ?? 0);
if (band?.ceiling_gib === undefined) {
ceiling.value = "";
}
ceiling.placeholder = "∞";
floor.setAttribute("aria-label", `${resolution} floor GiB`);
target.setAttribute("aria-label", `${resolution} target GiB`);
penalty.setAttribute("aria-label", `${resolution} penalty points per GiB over`);
for (const input of [floor, target, penalty]) {
ceiling.setAttribute("aria-label", `${resolution} ceiling GiB, empty for none`);
for (const input of [floor, target, penalty, ceiling]) {
input.classList.add("band-num");
line.append(input);
}
@@ -630,14 +642,17 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
const sizeBands: PolicyDoc["size_bands"] = {};
for (const line of panel.querySelectorAll<HTMLDivElement>(".band-line")) {
const [floor, target, penalty] = [...line.querySelectorAll<HTMLInputElement>("input")];
if (!floor || !target || !penalty) {
const [floor, target, penalty, ceiling] = [
...line.querySelectorAll<HTMLInputElement>("input"),
];
if (!floor || !target || !penalty || !ceiling) {
continue;
}
sizeBands[line.dataset.resolution ?? ""] = {
floor_gib: Number(floor.value),
target_gib: Number(target.value),
penalty_points_per_gib_over: Number(penalty.value),
...(ceiling.value === "" ? {} : { ceiling_gib: Number(ceiling.value) }),
};
}
+5 -1
View File
@@ -2462,8 +2462,12 @@ select.form-input {
width: 3.5rem;
}
/* four numbers and a label must share a phone's width, so the inputs shrink
rather than push the line past the gutter */
.band-num {
width: 4.5rem;
flex: 1 1 0;
min-width: 0;
max-width: 4.5rem;
}
.subtitle-panel .field-label {