Initial implementation: core vertical slice
CI / fmt, clippy, test (push) Failing after 2m46s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 4m22s

Implements the core of SPEC.md — the manifest exchange, less audio-tier
matching (§3) and federation (§9a), both of which the spec sequences as
later work.

- §2 Jmanifest format and series bundles
- §3 cut matching: exact / runtime / loose tiers
- §4 API, less POST /manifests/search
- §5 rate limiting; §5a trust model, anonymous bearer tokens
- §6 upload validation, all four stages
- §7 relational storage, no JSON blob on the write path
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing, computed on upload

Reconciled against the system spec:

- anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows
  track extent, so a track survives its own gaps and there is nothing to
  anneal. Its successor extinction_sec and the new gallery_scope are accepted
  and stored; scope enters the §7 ranking. A manifest still carrying
  anneal_sec is a hard 400, not silently ignored — it came from a pipeline
  whose window semantics differ from what this server assumes.
- Audio signature: media under 120 s now emits no signature at all, matching
  scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened
  window under 150 s, which was the weaker rule — a caller-varying length is
  the property SR-004 forbids.
- UR IDs regularised to UR-nnn; docs/requirements.md registers 32
  requirements, each tracing to an SR-nnn or PR-nnn.

189 tests: unit, end-to-end through the real router, and an injection suite
covering SQL, JSON, header and Unicode payloads. Writing that suite found two
real gaps, both fixed here: compatibility homoglyphs passed the §5a character
class, and a one-frame audio signature was accepted on a feature-length item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:14:02 +02:00
co-authored by Claude Opus 5
commit a848750a65
38 changed files with 13014 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
//! §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.
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.
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);
}
}