Files
JRay-public-server/src/castcheck.rs
T
dtourolleandClaude Opus 5 a1e789a6fe
CI / fmt, clippy, test (push) Failing after 1m21s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 24s
Traceability: vendor the shared gate, annotate the source
Adds jray-project as a submodule at scripts/vendor/jray-project, so this repo
runs the same extractor as every other component rather than its own copy, and
gains the system spec that defines the PR/SR requirements its register traces
up to.

scripts/traceability-gate.sh is a thin wrapper holding only what is specific to
this repo: UR/DR prefixes, .rs sources, and REPO_ROOT — which the shared gate
cannot infer once vendored, since its default resolves to the submodule itself.
Each override fails silently in a way that looks like "no work done" rather
than "misconfigured", so the wrapper documents why each is needed.

Annotates 35 units with TRACES tags, on the code that decides rather than every
helper it calls. Coverage is 23/32 (71.9%) with no orphan tags. The nine
untraced are genuinely unimplemented: UR-007 is plugin-side, UR-008 is
federation, and UR-015..018 are the pending SR-003 schema bump.

The gate caught a real error in the first pass: several tags separated IDs of
different types with commas. A comma joins IDs within one type; a pipe
separates types. Fixed, and the diagnostics are now clean.

MIN_COVERAGE stays 0 deliberately. The gate still fails on orphan tags, a >100%
ratio, a register parsing to nothing, or an empty source scan — raise the
threshold as a ratchet once the remaining work lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:27:16 +02:00

487 lines
18 KiB
Rust

//! §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<MatchedActor>,
/// Actors that matched nothing and will be dropped.
pub unmatched_person_ids: Vec<u64>,
pub reason: Option<String>,
}
#[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<u64>,
pub imdb_id: Option<String>,
/// Used only for matching here, then discarded (§5a).
pub name: Option<String>,
}
/// 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<MatchedActor> = Vec::new();
let mut unmatched: Vec<u64> = 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<MatchedActor>, 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<u64> {
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<CastMember> {
(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);
}
}