Initial implementation: core vertical slice
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user