From 3a303e22d36f04d10b4e109e9561b9f81ca32d2d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:04:07 +0100 Subject: [PATCH] fix(core): tighten policy verdicts --- crates/arr-core/src/lib.rs | 10 -- crates/arr-core/src/policy.rs | 191 ++++++++++++++++++---------------- 2 files changed, 101 insertions(+), 100 deletions(-) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 154ca15..04ef0a4 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -112,14 +112,6 @@ pub enum HdrFormat { DolbyVision(DolbyVisionProfile), } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum HdrClaim { - Hdr10, - Hdr10Plus, - Hlg, - DolbyVision, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum RequiredAudio { OriginalLanguage, @@ -204,9 +196,7 @@ pub enum Rule { RequiredAudio, DubBlacklist(Language), DolbyVisionProfile(DolbyVisionProfile), - ResolutionUnknown, Resolution(Resolution), - SourceUnknown, Source(Source), Size, Other(String), diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index bd92064..531cc2e 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -60,11 +60,38 @@ pub enum RuleOutcome { HardFail, } -/// The result of evaluating one named rule. +/// A rule's identity when no violation exists to carry concrete evidence. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct RuleEvaluation { - pub rule: Rule, - pub outcome: RuleOutcome, +pub enum RuleKind { + RequiredAudio, + DubBlacklist, + DolbyVisionProfile, + Resolution, + Source, + Size, + Other(String), +} + +/// The result of evaluating one rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleEvaluation { + Pass(RuleKind), + Unknown(RuleKind), + SoftFail(Rule), + HardFail(Rule), +} + +impl RuleEvaluation { + /// The outcome category used by verdict reduction. + #[must_use] + pub const fn outcome(&self) -> RuleOutcome { + match self { + Self::Pass(_) => RuleOutcome::Pass, + Self::Unknown(_) => RuleOutcome::Unknown, + Self::SoftFail(_) => RuleOutcome::SoftFail, + Self::HardFail(_) => RuleOutcome::HardFail, + } + } } /// A policy rule that can participate in the verdict pipeline. @@ -79,15 +106,9 @@ pub struct Evaluation { pub rules: Vec, } -/// Apply the built-in filter rules and return the candidate's bucket. +/// Apply the built-in filter rules and retain the verdict and every rule result. #[must_use] -pub fn evaluate(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate_with_report(policy, overrides, candidate).verdict -} - -/// Apply the built-in filter rules and retain each rule's result. -#[must_use] -pub fn evaluate_with_report( +pub fn evaluate( policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>, @@ -104,20 +125,31 @@ pub fn evaluate_with_report( /// Reduce an ordered set of policy rules to a verdict. /// /// The first hard failure decides rejection. If none exists, the first soft -/// failure decides the waiver. Unknown evidence never becomes a pass. +/// failure decides the waiver. Unknown evaluations abstain from reduction and +/// remain visible in the returned report. #[must_use] pub fn evaluate_rules(context: &EvaluationContext<'_>, rules: &[&dyn PolicyRule]) -> Evaluation { let evaluations: Vec = rules.iter().map(|rule| rule.evaluate(context)).collect(); - let deciding_rule = |outcome| { - evaluations - .iter() - .find(|evaluation| evaluation.outcome == outcome) - .map(|evaluation| evaluation.rule.clone()) + let hard_failure = evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::HardFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }); + let soft_failure = || { + evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::SoftFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }) }; - let verdict = deciding_rule(RuleOutcome::HardFail).map_or_else( - || deciding_rule(RuleOutcome::SoftFail).map_or(Verdict::Eligible, Verdict::Waived), + let verdict = hard_failure.map_or_else( + || soft_failure().map_or(Verdict::Eligible, Verdict::Waived), Verdict::Rejected, ); @@ -134,25 +166,19 @@ pub struct ResolutionRule; impl PolicyRule for ResolutionRule { fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { let Some(resolution) = context.candidate.resolution() else { - return RuleEvaluation { - rule: Rule::ResolutionUnknown, - outcome: RuleOutcome::Unknown, - }; + return RuleEvaluation::Unknown(RuleKind::Resolution); }; - let accepted = context.policy.resolution_preference.contains(&resolution) - && (!context.overrides.only_4k || resolution == Resolution::R2160p); - let outcome = if accepted { - RuleOutcome::Pass - } else if context.candidate.phase() == EvaluationPhase::PostDownload { - RuleOutcome::SoftFail + let allowed_by_root = context.policy.resolution_preference.contains(&resolution); + if allowed_by_root && (!context.overrides.only_4k || resolution == Resolution::R2160p) { + RuleEvaluation::Pass(RuleKind::Resolution) + } else if allowed_by_root + && context.overrides.only_4k + && context.candidate.phase() == EvaluationPhase::PostDownload + { + RuleEvaluation::SoftFail(Rule::Resolution(resolution)) } else { - RuleOutcome::HardFail - }; - - RuleEvaluation { - rule: Rule::Resolution(resolution), - outcome, + RuleEvaluation::HardFail(Rule::Resolution(resolution)) } } } @@ -164,26 +190,20 @@ pub struct SourceRule; impl PolicyRule for SourceRule { fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { let Some(source) = context.candidate.source() else { - return RuleEvaluation { - rule: Rule::SourceUnknown, - outcome: RuleOutcome::Unknown, - }; + return RuleEvaluation::Unknown(RuleKind::Source); }; - let outcome = match source { - Source::Cam | Source::Telesync | Source::Screener => RuleOutcome::HardFail, - Source::Other => RuleOutcome::Unknown, + match source { + Source::Cam | Source::Telesync | Source::Screener => { + RuleEvaluation::HardFail(Rule::Source(source)) + } + Source::Other => RuleEvaluation::Unknown(RuleKind::Source), Source::Dvd | Source::Telecine | Source::Hdtv | Source::WebRip | Source::WebDl | Source::BluRay - | Source::Remux => RuleOutcome::Pass, - }; - - RuleEvaluation { - rule: Rule::Source(source), - outcome, + | Source::Remux => RuleEvaluation::Pass(RuleKind::Source), } } } @@ -230,11 +250,15 @@ mod tests { } } + fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { + evaluate(policy, overrides, candidate).verdict + } + #[test] fn missing_pre_grab_evidence_stays_unknown() { let policy = policy(); let claims = claims(None, None); - let report = evaluate_with_report( + let report = evaluate( &policy, &MovieOverrides::default(), Candidate::PreGrab(&claims), @@ -244,14 +268,8 @@ mod tests { assert_eq!( report.rules, vec![ - RuleEvaluation { - rule: Rule::ResolutionUnknown, - outcome: RuleOutcome::Unknown, - }, - RuleEvaluation { - rule: Rule::SourceUnknown, - outcome: RuleOutcome::Unknown, - }, + RuleEvaluation::Unknown(RuleKind::Resolution), + RuleEvaluation::Unknown(RuleKind::Source), ] ); } @@ -264,21 +282,21 @@ mod tests { for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] { let claims = claims(Some(claimed), Some(ClaimedSource::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), Verdict::Eligible ); } for resolution in [Resolution::R2160p, Resolution::R1080p] { let media = probed(resolution, Some(Source::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + verdict(&policy, &overrides, Candidate::PostDownload(&media)), Verdict::Eligible ); } } #[test] - fn every_disallowed_resolution_is_hard_before_grab_and_soft_after_download() { + fn resolution_outside_root_policy_hard_fails_in_both_phases() { let policy = policy(); let overrides = MovieOverrides::default(); let cases = [ @@ -291,12 +309,12 @@ mod tests { let claims = claims(Some(claimed_resolution), Some(ClaimedSource::WebDl)); let media = probed(resolution, Some(Source::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), Verdict::Rejected(Rule::Resolution(resolution)) ); assert_eq!( - evaluate(&policy, &overrides, Candidate::PostDownload(&media)), - Verdict::Waived(Rule::Resolution(resolution)) + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Rejected(Rule::Resolution(resolution)) ); } } @@ -312,17 +330,17 @@ mod tests { let media = probed(Resolution::R1080p, Some(Source::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims_1080)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims_1080)), Verdict::Rejected(Rule::Resolution(Resolution::R1080p)) ); assert_eq!( - evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + verdict(&policy, &overrides, Candidate::PostDownload(&media)), Verdict::Waived(Rule::Resolution(Resolution::R1080p)) ); let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), Verdict::Eligible ); } @@ -338,7 +356,7 @@ mod tests { let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), Verdict::Rejected(Rule::Resolution(Resolution::R2160p)) ); } @@ -358,11 +376,11 @@ mod tests { let media = probed(Resolution::R2160p, Some(source)); let expected = Verdict::Rejected(Rule::Source(source)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), expected ); assert_eq!( - evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + verdict(&policy, &overrides, Candidate::PostDownload(&media)), expected ); } @@ -374,7 +392,7 @@ mod tests { let claims = arr_parse::parse("Movie.2024.1080p.TS-GROUP"); assert_eq!( - evaluate( + verdict( &policy, &MovieOverrides::default(), Candidate::PreGrab(&claims), @@ -401,43 +419,40 @@ mod tests { let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); let media = probed(Resolution::R2160p, Some(source)); assert_eq!( - evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), Verdict::Eligible ); assert_eq!( - evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + verdict(&policy, &overrides, Candidate::PostDownload(&media)), Verdict::Eligible ); } } #[test] - fn unknown_post_download_source_is_not_treated_as_a_pass() { + fn unknown_post_download_source_is_preserved_in_report() { let policy = policy(); for source in [None, Some(Source::Other)] { let media = probed(Resolution::R2160p, source); - let report = evaluate_with_report( + let report = evaluate( &policy, &MovieOverrides::default(), Candidate::PostDownload(&media), ); - assert_eq!(report.rules[1].outcome, RuleOutcome::Unknown); + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!(report.rules[1], RuleEvaluation::Unknown(RuleKind::Source)); } } #[derive(Debug)] struct FixedRule { - rule: Rule, - outcome: RuleOutcome, + evaluation: RuleEvaluation, } impl PolicyRule for FixedRule { fn evaluate(&self, _context: &EvaluationContext<'_>) -> RuleEvaluation { - RuleEvaluation { - rule: self.rule.clone(), - outcome: self.outcome, - } + self.evaluation.clone() } } @@ -452,12 +467,10 @@ mod tests { candidate: Candidate::PreGrab(&claims), }; let soft = FixedRule { - rule: Rule::Other("soft".to_owned()), - outcome: RuleOutcome::SoftFail, + evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), }; let hard = FixedRule { - rule: Rule::Other("hard".to_owned()), - outcome: RuleOutcome::HardFail, + evaluation: RuleEvaluation::HardFail(Rule::Other("hard".to_owned())), }; let report = evaluate_rules(&context, &[&soft, &hard]); @@ -479,12 +492,10 @@ mod tests { candidate: Candidate::PreGrab(&claims), }; let first = FixedRule { - rule: Rule::Other("first".to_owned()), - outcome: RuleOutcome::HardFail, + evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), }; let second = FixedRule { - rule: Rule::Other("second".to_owned()), - outcome: RuleOutcome::HardFail, + evaluation: RuleEvaluation::HardFail(Rule::Other("second".to_owned())), }; let report = evaluate_rules(&context, &[&first, &second]);