From 29d6831c833c64a7767ba6fe5291549687087ad3 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 21:15:26 +0100 Subject: [PATCH] feat(core): score by distance from target size (#63) --- crates/arr-core/src/lib.rs | 12 + crates/arr-core/src/policy.rs | 76 +++- crates/arr-core/src/score.rs | 365 ++++++++++++++++++ .../migrations/0004_policy_score_weights.sql | 20 + crates/arr-db/src/lib.rs | 29 ++ 5 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 crates/arr-core/src/score.rs create mode 100644 crates/arr-db/migrations/0004_policy_score_weights.sql diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 9a76d00..c6b335f 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -6,8 +6,10 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; pub mod lang; pub mod policy; +pub mod score; pub use arr_parse::NameClaims as ParsedRelease; +pub use score::{Score, ScoreWeights}; macro_rules! id_type { ($name:ident) => { @@ -124,10 +126,19 @@ pub struct HdrRules { pub rejected_dolby_vision_profiles: Vec, } +/// One resolution's size band (`DESIGN.md` §5.5). Below the floor is a hard +/// filter; the target is where the score peaks; above it the penalty grows +/// with every gibibyte. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SizeBand { pub floor_bytes: u64, pub target_bytes: u64, + /// Points lost per gibibyte above `target_bytes`, on the scale set by + /// [`ScoreWeights::size_at_target`] — 1000 points at target means one + /// point is a tenth of a percent, fine enough to tune by hand without a + /// fractional type. Applied per byte over, so a partial gibibyte costs + /// its fraction. + pub penalty_points_per_gib_over: i32, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -140,6 +151,7 @@ pub struct Policy { pub size_bands: BTreeMap, pub resolution_preference: Vec, pub source_weights: BTreeMap, + pub score_weights: ScoreWeights, } #[derive(Clone, Debug, Default, Eq, PartialEq)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 223b248..800947d 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -25,14 +25,18 @@ impl Candidate<'_> { } } - fn resolution(&self) -> Option { + /// The candidate's resolution, from whichever evidence it carries. + #[must_use] + pub fn resolution(&self) -> Option { match self { Self::PreGrab(claims) => claims.resolution.map(Resolution::from), Self::PostDownload(media) => Some(media.resolution), } } - fn source(&self) -> Option { + /// The candidate's source tier, from whichever evidence it carries. + #[must_use] + pub fn source(&self) -> Option { match self { Self::PreGrab(claims) => claims.source.map(Source::from), Self::PostDownload(media) => media.source, @@ -56,6 +60,10 @@ pub struct EvaluationContext<'a> { /// distinct input from any track's language. pub original_language: &'a Language, pub candidate: Candidate<'a>, + /// The release's size pre-grab, the file's size post-download. `None` + /// when the caller has no size to offer, which abstains rather than + /// rejects. + pub size_bytes: Option, } /// A rule's identity when no violation exists to carry concrete evidence. @@ -98,16 +106,19 @@ pub fn evaluate( overrides: &MovieOverrides, original_language: &Language, candidate: Candidate<'_>, + size_bytes: Option, ) -> Evaluation { let context = EvaluationContext { policy, overrides, original_language, candidate, + size_bytes, }; - let rules: [&dyn PolicyRule; 5] = [ + let rules: [&dyn PolicyRule; 6] = [ &ResolutionRule, &SourceRule, + &SizeRule, &HdrRule, &DubBlacklistRule, &RequiredAudioRule, @@ -201,6 +212,29 @@ impl PolicyRule for SourceRule { } } +/// Rejects anything below its size band's floor (`DESIGN.md` §5.5). +/// +/// The floor is a hard filter rather than a low score, because unbounded +/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far +/// a candidate sits from the band's *target* is [`crate::score`]'s question, +/// not this rule's. +#[derive(Clone, Copy, Debug, Default)] +pub struct SizeRule; + +impl PolicyRule for SizeRule { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { + let (Some(resolution), Some(size)) = (context.candidate.resolution(), context.size_bytes) + else { + return RuleEvaluation::Unknown(RuleKind::Size); + }; + match crate::score::is_below_floor(context.policy, resolution, size) { + None => RuleEvaluation::Unknown(RuleKind::Size), + Some(true) => RuleEvaluation::HardFail(Rule::Size), + Some(false) => RuleEvaluation::Pass(RuleKind::Size), + } + } +} + /// Applies Dolby Vision profile policy using post-download probe evidence. #[derive(Clone, Copy, Debug, Default)] pub struct HdrRule; @@ -427,6 +461,7 @@ mod tests { size_bands: BTreeMap::new(), resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], source_weights: BTreeMap::new(), + score_weights: crate::ScoreWeights::default(), } } @@ -480,7 +515,7 @@ mod tests { } fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, &en(), candidate).verdict + evaluate(policy, overrides, &en(), candidate, None).verdict } fn verdict_for( @@ -493,6 +528,7 @@ mod tests { &MovieOverrides::default(), original_language, candidate, + None, ) .verdict } @@ -506,6 +542,7 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PreGrab(&claims), + None, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -514,6 +551,7 @@ mod tests { vec![ RuleEvaluation::Unknown(RuleKind::Resolution), RuleEvaluation::Unknown(RuleKind::Source), + RuleEvaluation::Unknown(RuleKind::Size), RuleEvaluation::Unknown(RuleKind::DolbyVisionProfile), RuleEvaluation::Unknown(RuleKind::DubBlacklist), RuleEvaluation::Unknown(RuleKind::RequiredAudio), @@ -686,6 +724,7 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PostDownload(&media), + None, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -741,6 +780,7 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PostDownload(&media), + None, ); let expected = if rejected { Verdict::Rejected(Rule::DolbyVisionProfile(profile)) @@ -759,11 +799,12 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PreGrab(&claims), + None, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( - report.rules[2], + report.rules[3], RuleEvaluation::Unknown(RuleKind::DolbyVisionProfile) ); } @@ -790,6 +831,7 @@ mod tests { overrides: &overrides, original_language: &original, candidate: Candidate::PreGrab(&claims), + size_bytes: None, }; let soft = FixedRule { evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), @@ -817,6 +859,7 @@ mod tests { overrides: &overrides, original_language: &original, candidate: Candidate::PreGrab(&claims), + size_bytes: None, }; let first = FixedRule { evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), @@ -947,13 +990,27 @@ mod tests { }; let claims = name_claims(&[LanguageMarker::English]); assert_eq!( - evaluate(&policy, &overrides, &en(), Candidate::PreGrab(&claims)).verdict, + evaluate( + &policy, + &overrides, + &en(), + Candidate::PreGrab(&claims), + None + ) + .verdict, Verdict::Eligible ); let media = probed_audio(vec![track(en())]); assert_eq!( - evaluate(&policy, &overrides, &en(), Candidate::PostDownload(&media)).verdict, + evaluate( + &policy, + &overrides, + &en(), + Candidate::PostDownload(&media), + None + ) + .verdict, Verdict::Waived(Rule::RequiredAudio) ); } @@ -1024,6 +1081,7 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PostDownload(&media), + None, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); @@ -1034,6 +1092,7 @@ mod tests { &MovieOverrides::default(), &Language::PortugueseBrazil, Candidate::PostDownload(&media), + None, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); } @@ -1046,10 +1105,11 @@ mod tests { &MovieOverrides::default(), &en(), Candidate::PreGrab(&claims), + None, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( - report.rules[4], + report.rules[5], RuleEvaluation::Unknown(RuleKind::RequiredAudio) ); } diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs new file mode 100644 index 0000000..e1cb689 --- /dev/null +++ b/crates/arr-core/src/score.rs @@ -0,0 +1,365 @@ +//! Scoring by distance from a target size (`DESIGN.md` §5.5). +//! +//! Source tier is not the dominant term. Per resolution the policy carries a +//! floor, a target and a penalty for every gigabyte above target, so a 20 GB +//! WEB-DL at target beats a 60 GB remux while the remux keeps a score and +//! stays eligible — it wins when nothing smaller exists. +//! +//! Below the floor is a hard filter rather than a low score, and lives in +//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise +//! select a 3 GB 4K encode that looks like mud. +//! +//! Every number here comes from the policy row. The placeholders in +//! [`ScoreWeights::default`] and in the seeded size bands are pending the +//! remux playback test in `DESIGN.md` §14; the model is the decision, not the +//! constants. + +use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; + +const BYTES_PER_GIB: i64 = 1 << 30; + +/// How much each scoring term is worth. Policy data, not constants in the +/// code, for the same reason the size bands are. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ScoreWeights { + /// Points awarded to a release sitting exactly at its band's target. The + /// top of the size term, and the scale every other term is small against. + pub size_at_target: i32, + /// Points per step of the policy's `source_weights`. Small: the source + /// tier is a tiebreaker. + pub source_tier: i32, + /// Points per doubling of the seeder count. Log-scaled and small — + /// enough seeders to complete matters, past that it does not. + pub seeder_doubling: i32, +} + +impl Default for ScoreWeights { + fn default() -> Self { + Self { + size_at_target: 1000, + source_tier: 25, + seeder_doubling: 8, + } + } +} + +/// One candidate's score, kept as its terms so the UI can explain a ranking. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct Score { + pub total: i64, + /// Distance from the band's target. Zero when no band applies, which is + /// also what an unknown resolution scores. + pub size: i64, + /// The source-tier tiebreaker. + pub source: i64, + /// The log-scaled seeder term. + pub seeders: i64, +} + +/// Score a candidate against a policy. +/// +/// A candidate with no applicable size band — unknown resolution, or a +/// resolution the policy carries no band for — scores zero on the dominant +/// term rather than being penalised for what the release name failed to say. +#[must_use] +pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score { + let weights = &policy.score_weights; + let size = candidate + .resolution() + .and_then(|resolution| policy.size_bands.get(&resolution)) + .map_or(0, |band| size_points(band, weights, size_bytes)); + let source = candidate + .source() + .map_or(0, |source| source_points(policy, source)); + let seeders = seeder_points(weights, seeders); + + Score { + total: size.saturating_add(source).saturating_add(seeders), + size, + source, + seeders, + } +} + +/// Score a stored release from the claims parsed out of its name. +/// +/// Pre-grab, the name is all there is (`DESIGN.md` §5.6). +#[must_use] +pub fn score_release(policy: &Policy, release: &Release) -> Score { + score( + policy, + Candidate::PreGrab(&release.parsed), + release.size, + release.seeders, + ) +} + +/// Whether a size falls below its band's floor, which is a hard filter. +/// +/// `None` when the policy carries no band for that resolution: no band is no +/// opinion, not a rejection. +#[must_use] +pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option { + policy + .size_bands + .get(&resolution) + .map(|band| size_bytes < band.floor_bytes) +} + +/// The size term: a ramp from the floor up to the target, then a penalty that +/// grows with every gigabyte above it. +fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 { + let at_target = i64::from(weights.size_at_target); + let size = as_i64(size_bytes); + let floor = as_i64(band.floor_bytes); + let target = as_i64(band.target_bytes); + + if size > target { + let over = size.saturating_sub(target); + let penalty = + i64::from(band.penalty_points_per_gib_over).saturating_mul(over) / BYTES_PER_GIB; + return at_target.saturating_sub(penalty); + } + + let span = target.saturating_sub(floor); + if span <= 0 { + // A degenerate band, floor at or above target: nothing to interpolate. + return if size >= target { at_target } else { 0 }; + } + at_target.saturating_mul(size.saturating_sub(floor)) / span +} + +/// The source tiebreaker. A source the policy does not weight is worth +/// nothing rather than being penalised. +fn source_points(policy: &Policy, source: Source) -> i64 { + let tier = policy.source_weights.get(&source).copied().unwrap_or(0); + i64::from(policy.score_weights.source_tier).saturating_mul(i64::from(tier)) +} + +/// The seeder term, log-scaled: one step per doubling, and nothing at all for +/// a release nobody is seeding. +fn seeder_points(weights: &ScoreWeights, seeders: u32) -> i64 { + let doublings = seeders.checked_ilog2().map_or(0, |log| i64::from(log) + 1); + i64::from(weights.seeder_doubling).saturating_mul(doublings) +} + +fn as_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; + + use super::*; + use crate::{ + policy::{evaluate, EvaluationContext, PolicyRule, RuleEvaluation, SizeRule}, + HdrRules, Language, MovieOverrides, PolicyId, RequiredAudio, Rule, Verdict, + }; + + const GIB: u64 = 1 << 30; + + fn gib(n: u64) -> u64 { + n * GIB + } + + /// The seeded `main` policy from `DESIGN.md` §5.5: 4K floor 8 GB, target + /// 22 GB; 1080p floor 3 GB, target 8 GB. + fn policy() -> Policy { + Policy { + id: PolicyId(1), + name: "test".to_owned(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: vec![Language::PortugueseBrazil], + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: BTreeMap::from([ + ( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(8), + target_bytes: gib(22), + penalty_points_per_gib_over: 60, + }, + ), + ( + Resolution::R1080p, + SizeBand { + floor_bytes: gib(3), + target_bytes: gib(8), + penalty_points_per_gib_over: 60, + }, + ), + ]), + resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], + source_weights: BTreeMap::from([ + (Source::Remux, 4), + (Source::BluRay, 3), + (Source::WebDl, 2), + (Source::WebRip, 1), + (Source::Hdtv, 0), + ]), + score_weights: ScoreWeights::default(), + } + } + + fn claims(source: ClaimedSource) -> NameClaims { + NameClaims { + resolution: Some(ClaimedResolution::P2160), + source: Some(source), + ..NameClaims::default() + } + } + + fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score { + let claims = claims(source); + score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + } + + fn size_rule(size_bytes: u64) -> RuleEvaluation { + let policy = policy(); + let overrides = MovieOverrides::default(); + let language = Language::Other("en".to_owned()); + let claims = claims(ClaimedSource::WebDl); + SizeRule.evaluate(&EvaluationContext { + policy: &policy, + overrides: &overrides, + original_language: &language, + candidate: Candidate::PreGrab(&claims), + size_bytes: Some(size_bytes), + }) + } + + /// The acceptance case from issue #13, over a realistic candidate set. + #[test] + fn twenty_gigabyte_web_dl_beats_a_sixty_gigabyte_remux() { + let candidates = [ + ("web-dl 20 GB", scored(ClaimedSource::WebDl, gib(20), 40)), + ("remux 60 GB", scored(ClaimedSource::Remux, gib(60), 12)), + ("bluray 34 GB", scored(ClaimedSource::BluRay, gib(34), 25)), + ("web-dl 12 GB", scored(ClaimedSource::WebDl, gib(12), 90)), + ]; + let best = candidates + .iter() + .max_by_key(|(_, score)| score.total) + .map(|(name, _)| *name); + + assert_eq!(best, Some("web-dl 20 GB")); + } + + #[test] + fn the_remux_still_wins_when_it_is_the_only_candidate() { + let policy = policy(); + let claims = claims(ClaimedSource::Remux); + let evaluation = evaluate( + &policy, + &MovieOverrides::default(), + &Language::Other("en".to_owned()), + Candidate::PreGrab(&claims), + Some(gib(60)), + ); + + // A bad score, but a score: nothing filters it out, so a selection + // with one candidate takes it. + assert!(scored(ClaimedSource::Remux, gib(60), 12).total < 0); + assert_eq!(evaluation.verdict, Verdict::Eligible); + } + + #[test] + fn size_beats_source_tier() { + let at_target = scored(ClaimedSource::WebRip, gib(22), 10); + let far_over = scored(ClaimedSource::Remux, gib(40), 10); + + assert!(at_target.total > far_over.total); + assert!(at_target.source < far_over.source); + } + + #[test] + fn source_tier_breaks_a_tie_between_equal_sizes() { + let web = scored(ClaimedSource::WebDl, gib(20), 10); + let bluray = scored(ClaimedSource::BluRay, gib(20), 10); + + assert_eq!(web.size, bluray.size); + assert!(bluray.total > web.total); + } + + #[test] + fn the_size_term_peaks_at_target() { + let below = scored(ClaimedSource::WebDl, gib(15), 10).size; + let at = scored(ClaimedSource::WebDl, gib(22), 10).size; + let above = scored(ClaimedSource::WebDl, gib(30), 10).size; + + assert_eq!(at, i64::from(ScoreWeights::default().size_at_target)); + assert!(below < at); + assert!(above < at); + } + + #[test] + fn the_penalty_grows_with_every_gigabyte_over_target() { + let over_by_ten = scored(ClaimedSource::WebDl, gib(32), 10).size; + let over_by_twenty = scored(ClaimedSource::WebDl, gib(42), 10).size; + let over_by_thirty = scored(ClaimedSource::WebDl, gib(52), 10).size; + + assert!(over_by_ten > over_by_twenty); + assert_eq!( + over_by_ten - over_by_twenty, + over_by_twenty - over_by_thirty + ); + } + + #[test] + fn seeders_are_log_scaled_and_small() { + let few = scored(ClaimedSource::WebDl, gib(20), 8); + let many = scored(ClaimedSource::WebDl, gib(20), 4096); + + // Nine doublings apart, and still worth less than one source tier. + assert_eq!(many.seeders - few.seeders, 9 * 8); + assert!(many.seeders - few.seeders < few.size); + assert_eq!(scored(ClaimedSource::WebDl, gib(20), 0).seeders, 0); + } + + #[test] + fn below_the_floor_is_a_hard_filter_not_a_low_score() { + assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); + assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(3)), + Some(true) + ); + assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None); + } + + #[test] + fn an_unknown_resolution_scores_no_size_term() { + let claims = NameClaims { + source: Some(ClaimedSource::WebDl), + ..NameClaims::default() + }; + let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10); + + assert_eq!(score.size, 0); + assert_eq!(score.total, score.source + score.seeders); + } + + #[test] + fn a_degenerate_band_does_not_divide_by_zero() { + let mut policy = policy(); + policy.size_bands.insert( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(10), + target_bytes: gib(10), + penalty_points_per_gib_over: 60, + }, + ); + let claims = claims(ClaimedSource::WebDl); + let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10); + let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10); + + assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); + assert_eq!(under.size, 0); + } +} diff --git a/crates/arr-db/migrations/0004_policy_score_weights.sql b/crates/arr-db/migrations/0004_policy_score_weights.sql new file mode 100644 index 0000000..0d22673 --- /dev/null +++ b/crates/arr-db/migrations/0004_policy_score_weights.sql @@ -0,0 +1,20 @@ +-- §5.5 scoring. The size bands seeded in 0002 predate the scoring model and +-- carry a penalty on no particular scale; the weights that make the terms +-- comparable had nowhere to live at all. Both are policy rows, tuned by hand, +-- so both belong here rather than in code. +-- +-- Points are the unit. A release at its band's target scores `size_at_target`; +-- every other term is small against that. Numbers stay placeholders pending +-- the remux playback test in §14. + +ALTER TABLE policies ADD COLUMN score_weights TEXT NOT NULL + DEFAULT '{"size_at_target":1000,"source_tier":25,"seeder_doubling":8}' + CHECK (json_valid(score_weights)); + +-- Sizes in gibibytes, matching the bytes the scorer compares, and the penalty +-- restated in points: 60 points per gibibyte over target puts a 60 GiB remux +-- far below a 20 GiB WEB-DL while leaving it eligible. +UPDATE policies SET size_bands = json('{ + "2160p": {"floor_gib": 8, "target_gib": 22, "penalty_points_per_gib_over": 60}, + "1080p": {"floor_gib": 3, "target_gib": 8, "penalty_points_per_gib_over": 60} +}'); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 61b35a9..877beed 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -191,6 +191,35 @@ mod tests { ); } + #[tokio::test] + async fn every_policy_carries_its_scoring_numbers() { + let (_dir, db) = fresh().await; + + let rows = sqlx::query("SELECT size_bands, score_weights FROM policies") + .fetch_all(db.pool()) + .await + .expect("policies"); + + assert_eq!(rows.len(), 2); + for row in rows { + let size_bands: String = row.get(0); + let score_weights: String = row.get(1); + + // §5.5: floor, target and the penalty above target, per resolution. + assert!(size_bands.contains("floor_gib"), "{size_bands}"); + assert!(size_bands.contains("target_gib"), "{size_bands}"); + assert!( + size_bands.contains("penalty_points_per_gib_over"), + "{size_bands}" + ); + // §5.5: the weights that keep source tier and seeders small + // against the size term are tuned by hand too. + assert!(score_weights.contains("size_at_target"), "{score_weights}"); + assert!(score_weights.contains("source_tier"), "{score_weights}"); + assert!(score_weights.contains("seeder_doubling"), "{score_weights}"); + } + } + #[tokio::test] async fn foreign_keys_are_enforced() { let (_dir, db) = fresh().await;