//! §6 stage 3 cast-match scoring, as pure functions. //! //! The thresholds here are the load-bearing part of UR-3 and §5a Threat 2, and //! §10 (5) wants them retuned against the 331-file extraction corpus. Keeping //! the decision logic free of I/O is what makes that a test-data exercise rather //! than a code change. use crate::tmdb::CastMember; /// §6: thresholds over the ratio `|M ∩ C| / |M|`. pub const LISTED_THRESHOLD: f64 = 0.6; pub const FLAGGED_THRESHOLD: f64 = 0.3; /// Below this size a ratio is meaningless (§6 small-|M| handling). pub const SMALL_M_LIMIT: usize = 5; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { /// Normal case. Listed, /// Served with reduced ranking, flagged for review. Flagged, /// Deleted, and the contributor's counter bumped. Rejected, } impl Verdict { pub fn status(self) -> &'static str { match self { Verdict::Listed => "listed", Verdict::Flagged => "flagged", Verdict::Rejected => "rejected", } } } #[derive(Debug, Clone)] pub struct CastCheckOutcome { pub verdict: Verdict, pub ratio: f64, /// Manifest actors resolved to a TMDB person id, with the TMDB-authoritative /// name. Only these are kept; §6 drops unmatched actors rather than storing /// them, which is what closes §5a's free-text channel. pub matched: Vec, /// Actors that matched nothing and will be dropped. pub unmatched_person_ids: Vec, pub reason: Option, } #[derive(Debug, Clone)] pub struct MatchedActor { pub tmdb_person_id: u64, /// From TMDB, never from the upload. pub name: String, pub adult: bool, /// True when the match came from name comparison rather than an id. pub by_name: bool, } /// An actor as submitted, after §6 stage 2 validation. #[derive(Debug, Clone)] pub struct SubmittedActor { pub tmdb_id: Option, pub imdb_id: Option, /// Used only for matching here, then discarded (§5a). pub name: Option, } /// Case- and accent-insensitive comparison key for the name fallback (§6). fn name_key(s: &str) -> String { use unicode_normalization::UnicodeNormalization; s.nfd() .filter(|c| !unicode_normalization::char::is_combining_mark(*c)) .flat_map(|c| c.to_lowercase()) .filter(|c| !c.is_whitespace() && *c != '.' && *c != ',' && *c != '-' && *c != '\'') .collect() } /// Runs the §6 stage 3 comparison. /// /// `credits` is the reference set *C*: for a movie, its credits; for an episode, /// the union of per-episode credits and the series' aggregate credits. /// TRACES: UR-003, UR-005, UR-010 | SR-001, SR-004 pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome { let m = submitted.len(); // §6: `|M| == 0` is rejected. These are extraction failures, not // contributions — validation already refuses them, so reaching here means a // manifest lost every actor upstream. if m == 0 { return CastCheckOutcome { verdict: Verdict::Rejected, ratio: 0.0, matched: Vec::new(), unmatched_person_ids: Vec::new(), reason: Some("empty_actor_list".into()), }; } // TMDB has no credits for the id: absent data is not evidence of a bad // manifest, so this is flagged rather than rejected (§6). if credits.is_empty() { return CastCheckOutcome { verdict: Verdict::Flagged, ratio: 0.0, matched: Vec::new(), unmatched_person_ids: submitted.iter().filter_map(|a| a.tmdb_id).collect(), reason: Some("tmdb_no_credits".into()), }; } let mut matched: Vec = Vec::new(); let mut unmatched: Vec = Vec::new(); let mut id_matches = 0usize; let mut name_matches = 0usize; for actor in submitted { // Join on `tmdb_id` — grounded in the pipeline's actual output, where // 330 of 331 manifests have `imdb_id: ""` and `tmdb_id` set (§6). let by_id = actor.tmdb_id.and_then(|id| credits.iter().find(|c| c.id == id)); if let Some(c) = by_id { id_matches += 1; push_unique( &mut matched, MatchedActor { tmdb_person_id: c.id, name: c.name.clone(), adult: c.adult, by_name: false, }, ); continue; } // Fall back to case- and accent-insensitive name comparison. let by_name = actor.name.as_deref().and_then(|n| { let key = name_key(n); (!key.is_empty()).then(|| credits.iter().find(|c| name_key(&c.name) == key))? }); if let Some(c) = by_name { name_matches += 1; push_unique( &mut matched, MatchedActor { tmdb_person_id: c.id, name: c.name.clone(), adult: c.adult, by_name: true, }, ); continue; } if let Some(id) = actor.tmdb_id { unmatched.push(id); } } // §6: name-only matches are counted but capped at half the intersection, so // a manifest cannot pass on name collisions alone. let capped_name_matches = name_matches.min(id_matches); let effective = id_matches + capped_name_matches; let ratio = effective as f64 / m as f64; let verdict = classify(m, effective, ratio); let reason = match verdict { Verdict::Rejected => Some("cast_match_below_threshold".into()), Verdict::Flagged => Some("cast_match_marginal".into()), Verdict::Listed => None, }; CastCheckOutcome { verdict, ratio, matched, unmatched_person_ids: unmatched, reason } } fn push_unique(matched: &mut Vec, actor: MatchedActor) { if !matched.iter().any(|m| m.tmdb_person_id == actor.tmdb_person_id) { matched.push(actor); } } /// §6 small-*M* handling. With a median of 7 actors a ratio threshold is coarse /// — one mismatch moves it by 14% — so small manifests use counts, not ratios. fn classify(m: usize, matches: usize, ratio: f64) -> Verdict { if m >= SMALL_M_LIMIT { if ratio >= LISTED_THRESHOLD { Verdict::Listed } else if ratio >= FLAGGED_THRESHOLD { Verdict::Flagged } else { Verdict::Rejected } } else if m >= 2 { // Require all but one actor to match. if matches + 1 >= m { Verdict::Listed } else { Verdict::Rejected } } else { // |M| <= 1: accept only if the single actor matches. Such a manifest is // near-worthless anyway and is ranked last. if matches >= 1 { Verdict::Listed } else { Verdict::Rejected } } } /// §5a additional layer 1 — category guard. /// /// Rejects when a matched person is flagged adult by TMDB and the target title /// is not, which targets the stated prank without needing a blocklist of names. /// TRACES: UR-005 | SR-004 pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option { if title_is_adult { return None; } matched.iter().find(|m| m.adult).map(|m| m.tmdb_person_id) } /// §5a additional layer 2 — age-appropriateness guard. /// /// On a children's certification, apply the strictest cast-match threshold and /// require an `exact` or `runtime` cut match. Mismatched content on children's /// titles is the highest-harm case and deserves the tightest gate. pub fn is_childrens_certification(cert: &str) -> bool { matches!( cert.trim().to_ascii_uppercase().as_str(), "G" | "TV-Y" | "TV-Y7" | "TV-G" | "U" | "0+" | "6+" | "PG" | "TV-PG" ) } pub const CHILDRENS_LISTED_THRESHOLD: f64 = 0.8; /// Applies the children's-title gate to an already-computed outcome. pub fn apply_childrens_guard(outcome: &mut CastCheckOutcome, m: usize) { if m >= SMALL_M_LIMIT && outcome.ratio < CHILDRENS_LISTED_THRESHOLD { outcome.verdict = match outcome.verdict { Verdict::Listed => Verdict::Flagged, other => other, }; if outcome.reason.is_none() { outcome.reason = Some("childrens_title_strict_threshold".into()); } } } #[cfg(test)] mod tests { use super::*; fn credit(id: u64, name: &str) -> CastMember { CastMember { id, name: name.to_string(), adult: false } } fn adult_credit(id: u64, name: &str) -> CastMember { CastMember { id, name: name.to_string(), adult: true } } fn by_id(id: u64) -> SubmittedActor { SubmittedActor { tmdb_id: Some(id), imdb_id: None, name: None } } fn by_name(name: &str) -> SubmittedActor { SubmittedActor { tmdb_id: None, imdb_id: None, name: Some(name.to_string()) } } /// A realistic reference cast — feature casts are several times larger than /// the manifests extracted from them (§6). fn cast_of_20() -> Vec { (1..=20).map(|i| credit(i, &format!("Actor {i}"))).collect() } #[test] fn full_subset_of_the_cast_is_listed() { // §6: the ratio is over *M*, not *C* — a manifest legitimately contains // only actors both credited and detected on screen, so penalising it for // missing credited actors would fail every honest upload. let submitted: Vec<_> = (1..=7).map(by_id).collect(); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Listed); assert_eq!(out.ratio, 1.0); assert_eq!(out.matched.len(), 7); } #[test] fn threshold_boundaries_at_point_six_and_point_three() { // 6 of 10 matching == 0.6 exactly: listed. let mut submitted: Vec<_> = (1..=6).map(by_id).collect(); submitted.extend((900..904).map(by_id)); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.matched.len(), 6); assert!((out.ratio - 0.6).abs() < 1e-9); assert_eq!(out.verdict, Verdict::Listed); // 5 of 10 == 0.5: flagged, served with reduced ranking. let mut submitted: Vec<_> = (1..=5).map(by_id).collect(); submitted.extend((900..905).map(by_id)); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Flagged); // 3 of 10 == 0.3 exactly: still flagged, not rejected. let mut submitted: Vec<_> = (1..=3).map(by_id).collect(); submitted.extend((900..907).map(by_id)); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Flagged); // 2 of 10 == 0.2: rejected. let mut submitted: Vec<_> = (1..=2).map(by_id).collect(); submitted.extend((900..908).map(by_id)); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Rejected); } #[test] fn prank_manifest_is_rejected() { // §5a Threat 2: performers who are not credited cast on the title. let submitted: Vec<_> = (500..510).map(by_id).collect(); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Rejected); assert_eq!(out.ratio, 0.0); assert_eq!(out.reason.as_deref(), Some("cast_match_below_threshold")); } #[test] fn small_m_requires_all_but_one_to_match() { // §6: `2 <= |M| < 5` — a ratio is meaningless at this size. let out = evaluate(&[by_id(1), by_id(2), by_id(3), by_id(999)], &cast_of_20()); assert_eq!(out.verdict, Verdict::Listed, "3 of 4 is all-but-one"); let out = evaluate(&[by_id(1), by_id(2), by_id(998), by_id(999)], &cast_of_20()); assert_eq!(out.verdict, Verdict::Rejected, "2 of 4 fails all-but-one"); // 0.5 would be `Flagged` under the ratio table, so this proves the // small-|M| branch is actually taken. let out = evaluate(&[by_id(1), by_id(999)], &cast_of_20()); assert_eq!(out.verdict, Verdict::Listed, "1 of 2 is all-but-one"); } #[test] fn single_actor_manifest_needs_that_actor_to_match() { assert_eq!(evaluate(&[by_id(1)], &cast_of_20()).verdict, Verdict::Listed); assert_eq!(evaluate(&[by_id(999)], &cast_of_20()).verdict, Verdict::Rejected); } #[test] fn empty_manifest_is_rejected() { let out = evaluate(&[], &cast_of_20()); assert_eq!(out.verdict, Verdict::Rejected); assert_eq!(out.reason.as_deref(), Some("empty_actor_list")); } #[test] fn missing_tmdb_credits_flags_rather_than_rejects() { // §6: absent data is not evidence of a bad manifest. let submitted: Vec<_> = (1..=7).map(by_id).collect(); let out = evaluate(&submitted, &[]); assert_eq!(out.verdict, Verdict::Flagged); assert_eq!(out.reason.as_deref(), Some("tmdb_no_credits")); } #[test] fn name_matching_is_case_and_accent_insensitive() { let credits = vec![credit(1, "Renée Zellweger"), credit(2, "Miloš Forman")]; let out = evaluate(&[by_name("renee zellweger"), by_name("MILOS FORMAN")], &credits); assert_eq!(out.matched.len(), 2); } #[test] fn name_only_matches_cannot_carry_a_manifest_alone() { // §6: name-only matches are capped at half the intersection, so a // manifest cannot pass on name collisions alone. let credits: Vec<_> = (1..=20).map(|i| credit(i, &format!("Actor {i}"))).collect(); let submitted: Vec<_> = (1..=10).map(|i| by_name(&format!("Actor {i}"))).collect(); let out = evaluate(&submitted, &credits); assert_eq!(out.ratio, 0.0, "with no id matches, name matches cap to zero"); assert_eq!(out.verdict, Verdict::Rejected); } #[test] fn name_matches_count_up_to_the_number_of_id_matches() { let credits: Vec<_> = (1..=20).map(|i| credit(i, &format!("Actor {i}"))).collect(); // 4 by id + 6 by name, of 10 => capped to 4 + 4 = 8 => 0.8. let mut submitted: Vec<_> = (1..=4).map(by_id).collect(); submitted.extend((5..=10).map(|i| by_name(&format!("Actor {i}")))); let out = evaluate(&submitted, &credits); assert!((out.ratio - 0.8).abs() < 1e-9, "got {}", out.ratio); assert_eq!(out.verdict, Verdict::Listed); } #[test] fn unmatched_actors_are_reported_for_dropping() { // §6: unmatched actors are dropped rather than stored. let submitted: Vec<_> = (1..=6).map(by_id).chain([by_id(777)]).collect(); let out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.unmatched_person_ids, vec![777]); assert!(out.matched.iter().all(|m| m.tmdb_person_id != 777)); } #[test] fn matched_names_come_from_tmdb_not_the_upload() { // §5a: the server stores references to TMDB entities, not // attacker-authored text. let credits = vec![credit(884, "Steve Buscemi")]; let submitted = vec![SubmittedActor { tmdb_id: Some(884), imdb_id: None, name: Some("Definitely Not Him".into()), }]; let out = evaluate(&submitted, &credits); assert_eq!(out.matched[0].name, "Steve Buscemi"); } #[test] fn duplicate_credits_do_not_double_count() { // TMDB aggregate credits can list a person more than once. let credits = vec![credit(1, "A"), credit(1, "A")]; let out = evaluate(&[by_id(1)], &credits); assert_eq!(out.matched.len(), 1); } #[test] fn category_guard_catches_adult_performers_on_a_non_adult_title() { // §5a layer 1, aimed squarely at the stated prank. let matched = vec![ MatchedActor { tmdb_person_id: 1, name: "A".into(), adult: false, by_name: false }, MatchedActor { tmdb_person_id: 2, name: "B".into(), adult: true, by_name: false }, ]; assert_eq!(category_guard_violation(&matched, false), Some(2)); // Unless the target title is itself flagged adult. assert_eq!(category_guard_violation(&matched, true), None); } #[test] fn category_guard_ignores_clean_casts() { let matched = vec![MatchedActor { tmdb_person_id: 1, name: "A".into(), adult: false, by_name: false, }]; assert_eq!(category_guard_violation(&matched, false), None); } #[test] fn adult_credit_is_carried_through_matching() { let out = evaluate(&[by_id(9)], &[adult_credit(9, "X")]); assert!(out.matched[0].adult); } #[test] fn childrens_certifications_are_recognised() { for c in ["G", "TV-Y", "tv-y7", "U", " PG "] { assert!(is_childrens_certification(c), "{c} should be a children's rating"); } for c in ["R", "NC-17", "TV-MA", "18", ""] { assert!(!is_childrens_certification(c), "{c} should not be"); } } #[test] fn childrens_guard_tightens_the_threshold() { // §5a layer 2: the highest-harm case gets the tightest gate. A ratio of // 0.7 lists normally but only reaches `flagged` on a children's title. let mut submitted: Vec<_> = (1..=7).map(by_id).collect(); submitted.extend((900..903).map(by_id)); let mut out = evaluate(&submitted, &cast_of_20()); assert_eq!(out.verdict, Verdict::Listed); let m = submitted.len(); apply_childrens_guard(&mut out, m); assert_eq!(out.verdict, Verdict::Flagged); assert_eq!(out.reason.as_deref(), Some("childrens_title_strict_threshold")); } #[test] fn childrens_guard_leaves_strong_matches_listed() { let submitted: Vec<_> = (1..=10).map(by_id).collect(); let mut out = evaluate(&submitted, &cast_of_20()); let m = submitted.len(); apply_childrens_guard(&mut out, m); assert_eq!(out.verdict, Verdict::Listed); } }