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