feat: let a size rejection be waived

A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.

`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.

Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.

Closes #210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 22:15:01 +01:00
parent 405358bf24
commit 9fee07f080
16 changed files with 1098 additions and 20 deletions
+8
View File
@@ -237,6 +237,14 @@ pub struct Policy {
pub struct TitleOverrides {
pub only_4k: bool,
pub allow_english_audio: bool,
/// Take a release below its size band's floor on this title (§5.5).
///
/// No band is right for every title, and the floor is a hard reject, so
/// a title the band is wrong about has nothing grabbable at all. This
/// relaxes the floor to a soft fail rather than removing it: the release
/// is waived, never eligible, so it stays a deliberate manual grab and
/// imports on the record as a §5.7 waiver.
pub allow_below_floor: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+141
View File
@@ -224,6 +224,12 @@ impl PolicyRule for SourceRule {
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
/// not this rule's.
///
/// `allow_below_floor` softens the floor for one title rather than lifting
/// it: a below-floor release becomes a waiver, so it is never picked
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
/// [`ResolutionRule`], where an override moves a failure between hard and
/// soft and never makes the rule stop applying.
#[derive(Clone, Copy, Debug, Default)]
pub struct SizeRule;
@@ -236,6 +242,9 @@ impl PolicyRule for SizeRule {
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
{
None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
}
@@ -655,6 +664,138 @@ mod tests {
);
}
/// A policy with one 1080p band, so the floor has something to say.
fn banded_policy() -> Policy {
Policy {
size_bands: BTreeMap::from([(
Resolution::R1080p,
crate::SizeBand {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
},
)]),
resolution_preference: vec![Resolution::R1080p],
..policy()
}
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
let evaluation = evaluate(
&banded_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
);
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
}
#[test]
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
let policy = banded_policy();
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Pre-grab the deck offers it, and only as a waiver: `waived` is
// never picked automatically (§9.3), so the operator still decides.
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
)
.verdict,
Verdict::Waived(Rule::Size)
);
// And the import records the waiver rather than hard-failing (§5.7).
let media = probed(Resolution::R1080p, Some(Source::WebDl));
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
Some(1 << 30),
1,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(
&banded_policy(),
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(4 << 30),
1,
)
.verdict,
Verdict::Eligible
);
}
/// §5.5: the floor takes the per-episode figure, so the override that
/// relaxes it has to travel the same divisor.
#[test]
fn allow_below_floor_waives_a_pack_measured_per_episode() {
let policy = banded_policy();
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Ten episodes at 1 GiB each: over the floor in total, under it per
// episode, which is the figure the floor compares.
let size = Some(10 << 30);
assert_eq!(
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
size,
10,
)
.verdict,
Verdict::Rejected(Rule::Size)
);
assert_eq!(
evaluate(
&policy,
&TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
},
&en(),
Candidate::PreGrab(&claims),
size,
10,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy();