From 77b947a85dd164308ac04cbad70596fc1555803f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:56:59 +0100 Subject: [PATCH 1/3] feat(core): add policy verdict pipeline --- Cargo.lock | 3 + crates/arr-core/Cargo.toml | 1 + crates/arr-core/src/lib.rs | 45 ++- crates/arr-core/src/policy.rs | 497 ++++++++++++++++++++++++++++++++++ 4 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 crates/arr-core/src/policy.rs diff --git a/Cargo.lock b/Cargo.lock index 5040cca..d97e8e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,9 @@ version = "0.1.0" [[package]] name = "arr-core" version = "0.1.0" +dependencies = [ + "arr-parse", +] [[package]] name = "arr-daemon" diff --git a/crates/arr-core/Cargo.toml b/crates/arr-core/Cargo.toml index d0c87cd..bc13fef 100644 --- a/crates/arr-core/Cargo.toml +++ b/crates/arr-core/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true publish = false [dependencies] +arr-parse.workspace = true [lints] workspace = true diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index be1badf..154ca15 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -4,6 +4,10 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; +pub mod policy; + +pub use arr_parse::NameClaims as ParsedRelease; + macro_rules! id_type { ($name:ident) => { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -56,12 +60,43 @@ pub enum Source { WebDl, WebRip, Hdtv, + Dvd, + Telecine, Telesync, Cam, Screener, Other, } +impl From for Resolution { + fn from(value: arr_parse::Resolution) -> Self { + match value { + arr_parse::Resolution::P480 => Self::Other(480), + arr_parse::Resolution::P576 => Self::Other(576), + arr_parse::Resolution::P720 => Self::R720p, + arr_parse::Resolution::P1080 => Self::R1080p, + arr_parse::Resolution::P2160 => Self::R2160p, + } + } +} + +impl From for Source { + fn from(value: arr_parse::Source) -> Self { + match value { + arr_parse::Source::Cam => Self::Cam, + arr_parse::Source::Telesync => Self::Telesync, + arr_parse::Source::Telecine => Self::Telecine, + arr_parse::Source::Screener => Self::Screener, + arr_parse::Source::Dvd => Self::Dvd, + arr_parse::Source::Hdtv => Self::Hdtv, + arr_parse::Source::WebRip => Self::WebRip, + arr_parse::Source::WebDl => Self::WebDl, + arr_parse::Source::BluRay => Self::BluRay, + arr_parse::Source::Remux => Self::Remux, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DolbyVisionProfile { pub profile: u8, @@ -169,7 +204,9 @@ pub enum Rule { RequiredAudio, DubBlacklist(Language), DolbyVisionProfile(DolbyVisionProfile), + ResolutionUnknown, Resolution(Resolution), + SourceUnknown, Source(Source), Size, Other(String), @@ -192,14 +229,6 @@ pub struct MediaFile { pub waiver: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ParsedRelease { - pub resolution: Option, - pub source: Option, - pub hdr: Vec, - pub audio_languages: Vec, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Release { pub id: ReleaseId, diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs new file mode 100644 index 0000000..bd92064 --- /dev/null +++ b/crates/arr-core/src/policy.rs @@ -0,0 +1,497 @@ +use arr_parse::NameClaims; + +use crate::{MovieOverrides, Policy, ProbedMedia, Resolution, Rule, Source, Verdict}; + +/// The evidence available while evaluating a candidate. +#[derive(Clone, Copy, Debug)] +pub enum Candidate<'a> { + /// Claims parsed from a release name before a grab. + PreGrab(&'a NameClaims), + /// Facts reported by `ffprobe` after a download. + PostDownload(&'a ProbedMedia), +} + +impl Candidate<'_> { + /// Which side of the grab boundary supplied the evidence. + #[must_use] + pub const fn phase(&self) -> EvaluationPhase { + match self { + Self::PreGrab(_) => EvaluationPhase::PreGrab, + Self::PostDownload(_) => EvaluationPhase::PostDownload, + } + } + + fn resolution(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.resolution.map(Resolution::from), + Self::PostDownload(media) => Some(media.resolution), + } + } + + fn source(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.source.map(Source::from), + Self::PostDownload(media) => media.source, + } + } +} + +/// The two phases of policy evaluation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvaluationPhase { + PreGrab, + PostDownload, +} + +/// Everything a policy rule may inspect. +#[derive(Clone, Copy, Debug)] +pub struct EvaluationContext<'a> { + pub policy: &'a Policy, + pub overrides: &'a MovieOverrides, + pub candidate: Candidate<'a>, +} + +/// A rule's result before the pipeline reduces all results to one verdict. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuleOutcome { + Pass, + Unknown, + SoftFail, + HardFail, +} + +/// The result of evaluating one named rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RuleEvaluation { + pub rule: Rule, + pub outcome: RuleOutcome, +} + +/// A policy rule that can participate in the verdict pipeline. +pub trait PolicyRule: std::fmt::Debug { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation; +} + +/// Full evaluation details, including rules whose evidence was unknown. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Evaluation { + pub verdict: Verdict, + pub rules: Vec, +} + +/// Apply the built-in filter rules and return the candidate's bucket. +#[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( + policy: &Policy, + overrides: &MovieOverrides, + candidate: Candidate<'_>, +) -> Evaluation { + let context = EvaluationContext { + policy, + overrides, + candidate, + }; + let rules: [&dyn PolicyRule; 2] = [&ResolutionRule, &SourceRule]; + evaluate_rules(&context, &rules) +} + +/// 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. +#[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 verdict = deciding_rule(RuleOutcome::HardFail).map_or_else( + || deciding_rule(RuleOutcome::SoftFail).map_or(Verdict::Eligible, Verdict::Waived), + Verdict::Rejected, + ); + + Evaluation { + verdict, + rules: evaluations, + } +} + +/// Checks whether the candidate's resolution is allowed by the effective policy. +#[derive(Clone, Copy, Debug, Default)] +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, + }; + }; + + 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 + } else { + RuleOutcome::HardFail + }; + + RuleEvaluation { + rule: Rule::Resolution(resolution), + outcome, + } + } +} + +/// Rejects sources that are never watchable release candidates. +#[derive(Clone, Copy, Debug, Default)] +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, + }; + }; + let outcome = match source { + Source::Cam | Source::Telesync | Source::Screener => RuleOutcome::HardFail, + Source::Other => RuleOutcome::Unknown, + Source::Dvd + | Source::Telecine + | Source::Hdtv + | Source::WebRip + | Source::WebDl + | Source::BluRay + | Source::Remux => RuleOutcome::Pass, + }; + + RuleEvaluation { + rule: Rule::Source(source), + outcome, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; + + use super::*; + use crate::{HdrFormat, HdrRules, PolicyId, RequiredAudio}; + + fn policy() -> Policy { + Policy { + id: PolicyId(1), + name: "test".to_owned(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: Vec::new(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: BTreeMap::new(), + resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], + source_weights: BTreeMap::new(), + } + } + + fn claims(resolution: Option, source: Option) -> NameClaims { + NameClaims { + resolution, + source, + ..NameClaims::default() + } + } + + fn probed(resolution: Resolution, source: Option) -> ProbedMedia { + ProbedMedia { + resolution, + source, + hdr: HdrFormat::Sdr, + audio_tracks: Vec::new(), + subtitle_tracks: Vec::new(), + } + } + + #[test] + fn missing_pre_grab_evidence_stays_unknown() { + let policy = policy(); + let claims = claims(None, None); + let report = evaluate_with_report( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ); + + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!( + report.rules, + vec![ + RuleEvaluation { + rule: Rule::ResolutionUnknown, + outcome: RuleOutcome::Unknown, + }, + RuleEvaluation { + rule: Rule::SourceUnknown, + outcome: RuleOutcome::Unknown, + }, + ] + ); + } + + #[test] + fn preferred_resolutions_pass_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + + for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] { + let claims = claims(Some(claimed), Some(ClaimedSource::WebDl)); + assert_eq!( + evaluate(&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::Eligible + ); + } + } + + #[test] + fn every_disallowed_resolution_is_hard_before_grab_and_soft_after_download() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedResolution::P480, Resolution::Other(480)), + (ClaimedResolution::P576, Resolution::Other(576)), + (ClaimedResolution::P720, Resolution::R720p), + ]; + + for (claimed_resolution, resolution) in cases { + 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::Rejected(Rule::Resolution(resolution)) + ); + assert_eq!( + evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Waived(Rule::Resolution(resolution)) + ); + } + } + + #[test] + fn only_4k_tightens_the_root_policy_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + let media = probed(Resolution::R1080p, Some(Source::WebDl)); + + assert_eq!( + evaluate(&policy, &overrides, Candidate::PreGrab(&claims_1080)), + Verdict::Rejected(Rule::Resolution(Resolution::R1080p)) + ); + assert_eq!( + evaluate(&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::Eligible + ); + } + + #[test] + fn only_4k_never_expands_the_root_policy() { + let mut policy = policy(); + policy.resolution_preference = vec![Resolution::R1080p]; + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); + + assert_eq!( + evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Rejected(Rule::Resolution(Resolution::R2160p)) + ); + } + + #[test] + fn every_unsafe_source_hard_fails_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Cam, Source::Cam), + (ClaimedSource::Telesync, Source::Telesync), + (ClaimedSource::Screener, Source::Screener), + ]; + + for (claimed_source, source) in cases { + let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); + let media = probed(Resolution::R2160p, Some(source)); + let expected = Verdict::Rejected(Rule::Source(source)); + assert_eq!( + evaluate(&policy, &overrides, Candidate::PreGrab(&claims)), + expected + ); + assert_eq!( + evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + expected + ); + } + } + + #[test] + fn parsed_telesync_name_is_rejected() { + let policy = policy(); + let claims = arr_parse::parse("Movie.2024.1080p.TS-GROUP"); + + assert_eq!( + evaluate( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ), + Verdict::Rejected(Rule::Source(Source::Telesync)) + ); + } + + #[test] + fn every_safe_source_passes_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Dvd, Source::Dvd), + (ClaimedSource::Telecine, Source::Telecine), + (ClaimedSource::Hdtv, Source::Hdtv), + (ClaimedSource::WebRip, Source::WebRip), + (ClaimedSource::WebDl, Source::WebDl), + (ClaimedSource::BluRay, Source::BluRay), + (ClaimedSource::Remux, Source::Remux), + ]; + + for (claimed_source, source) in cases { + 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::Eligible + ); + assert_eq!( + evaluate(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Eligible + ); + } + } + + #[test] + fn unknown_post_download_source_is_not_treated_as_a_pass() { + let policy = policy(); + for source in [None, Some(Source::Other)] { + let media = probed(Resolution::R2160p, source); + let report = evaluate_with_report( + &policy, + &MovieOverrides::default(), + Candidate::PostDownload(&media), + ); + + assert_eq!(report.rules[1].outcome, RuleOutcome::Unknown); + } + } + + #[derive(Debug)] + struct FixedRule { + rule: Rule, + outcome: RuleOutcome, + } + + impl PolicyRule for FixedRule { + fn evaluate(&self, _context: &EvaluationContext<'_>) -> RuleEvaluation { + RuleEvaluation { + rule: self.rule.clone(), + outcome: self.outcome, + } + } + } + + #[test] + fn hard_failure_wins_over_an_earlier_soft_failure() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let soft = FixedRule { + rule: Rule::Other("soft".to_owned()), + outcome: RuleOutcome::SoftFail, + }; + let hard = FixedRule { + rule: Rule::Other("hard".to_owned()), + outcome: RuleOutcome::HardFail, + }; + + let report = evaluate_rules(&context, &[&soft, &hard]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("hard".to_owned())) + ); + } + + #[test] + fn first_failure_of_the_deciding_kind_is_stable() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let first = FixedRule { + rule: Rule::Other("first".to_owned()), + outcome: RuleOutcome::HardFail, + }; + let second = FixedRule { + rule: Rule::Other("second".to_owned()), + outcome: RuleOutcome::HardFail, + }; + + let report = evaluate_rules(&context, &[&first, &second]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("first".to_owned())) + ); + } +} -- 2.47.3 From 3a303e22d36f04d10b4e109e9561b9f81ca32d2d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:04:07 +0100 Subject: [PATCH 2/3] 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]); -- 2.47.3 From 4e1876df0510e832e96ba2b23e36b229cba61cc9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:05:55 +0100 Subject: [PATCH 3/3] refactor(core): trim policy result API --- crates/arr-core/src/policy.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 531cc2e..1cb0b6c 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -51,15 +51,6 @@ pub struct EvaluationContext<'a> { pub candidate: Candidate<'a>, } -/// A rule's result before the pipeline reduces all results to one verdict. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum RuleOutcome { - Pass, - Unknown, - SoftFail, - HardFail, -} - /// A rule's identity when no violation exists to carry concrete evidence. #[derive(Clone, Debug, Eq, PartialEq)] pub enum RuleKind { @@ -81,19 +72,6 @@ pub enum RuleEvaluation { 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. pub trait PolicyRule: std::fmt::Debug { fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation; -- 2.47.3