//! CLPI vs MPLS cross-validation diagnostic. //! //! Walks both per-clip CLPI program info and per-playlist MPLS STN //! tables, normalizes their stream lists by `(PID, language, //! coding_type)`, and classifies each PID into one of four buckets: //! //! 1. **CLPI only** — a stream present in a `.clpi` ProgramInfo that no //! playlist STN table references (orphan on disc). //! 2. **MPLS only** — a stream a playlist references that no `.clpi` //! ProgramInfo lists (indicates a parser disagreement). //! 3. **Match** — both sources agree on coding_type and language. //! 4. **Divergent** — both sources see the PID but disagree on //! coding_type or language. //! //! [`audit`] returns a structured [`ClpiVsMplsAudit`] report. This is a //! diagnostic surface only; it does not feed the label-selection //! pipeline. use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; /// One row in the audit: a stream PID that's known to one source or /// both, with the fields each source reported. #[derive(Debug, Clone)] pub struct ClpiVsMplsRow { pub pid: u16, pub clpi_coding_type: Option, pub clpi_language: Option, pub mpls_coding_type: Option, pub mpls_language: Option, } impl ClpiVsMplsRow { /// Classification rules: /// - one coding_type present, the other missing → `ClpiOnly` / /// `MplsOnly` /// - both coding_types present, fields differ → `Divergent` /// - both coding_types present and identical → `Match` /// - both coding_types missing (`audit` never builds this, but a /// caller can construct such a row) → compare the language fields: /// `Divergent` if they differ, else `Match` pub fn class(&self) -> ClpiVsMplsClass { match ( self.clpi_coding_type.is_some(), self.mpls_coding_type.is_some(), ) { (true, false) => ClpiVsMplsClass::ClpiOnly, (false, true) => ClpiVsMplsClass::MplsOnly, (true, true) => { let coding_match = self.clpi_coding_type == self.mpls_coding_type; let lang_match = self.clpi_language == self.mpls_language; if coding_match && lang_match { ClpiVsMplsClass::Match } else { ClpiVsMplsClass::Divergent } } (false, false) => { if self.clpi_language == self.mpls_language { ClpiVsMplsClass::Match } else { ClpiVsMplsClass::Divergent } } } } } /// Classification of one (PID) row. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClpiVsMplsClass { /// PID seen in CLPI ProgramInfo but no MPLS STN table references /// it. Orphan on disc. ClpiOnly, /// PID seen in MPLS STN table but no CLPI ProgramInfo includes it. /// One of our parsers probably has a bug. MplsOnly, /// Both sources see this PID with the same coding_type + language. Match, /// Both sources see this PID but disagree on coding_type or language. /// MPLS wins for label rendering (playlist-authoritative view); CLPI /// is the per-clip ground truth. Divergent, } /// Full audit report. #[derive(Debug, Clone, Default)] pub struct ClpiVsMplsAudit { pub rows: Vec, } impl ClpiVsMplsAudit { /// Count rows by class, returned in the fixed order /// `(clpi_only, mpls_only, matches, divergent)` matching the /// [`ClpiVsMplsClass`] variants. pub fn class_counts(&self) -> (usize, usize, usize, usize) { let mut clpi_only = 0; let mut mpls_only = 0; let mut matches = 0; let mut divergent = 0; for r in &self.rows { match r.class() { ClpiVsMplsClass::ClpiOnly => clpi_only += 1, ClpiVsMplsClass::MplsOnly => mpls_only += 1, ClpiVsMplsClass::Match => matches += 1, ClpiVsMplsClass::Divergent => divergent += 1, } } (clpi_only, mpls_only, matches, divergent) } } /// Walk `/BDMV/CLIPINF/*.clpi` and `/BDMV/PLAYLIST/*.mpls`, build a /// dedup-by-PID table of (CLPI fields, MPLS fields), return the /// merged view. Missing files (read errors, parse failures) are /// silently skipped — this is diagnostic, not correctness-critical. pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { // Aggregate by PID across all CLPI files. If a PID appears in // multiple clips (typical — main movie clip + trailers reference // the same audio stream PIDs), first encountered wins (they should // all agree per BD spec). let mut clpi_by_pid: BTreeMap = BTreeMap::new(); if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") { let names: Vec = dir .entries .iter() .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi")) .map(|e| e.name.clone()) .collect(); for name in names { let path = format!("/BDMV/CLIPINF/{}", name); let Ok(data) = udf.read_file(reader, &path) else { continue; }; let Ok(clip) = crate::clpi::parse(&data) else { continue; }; for s in clip.streams { if s.pid == 0 { // PID 0 means "no PID in stream entry" — skip rather // than collide, mirroring the MPLS side below. continue; } clpi_by_pid .entry(s.pid) .or_insert((s.coding_type, s.language)); } } } // Same for MPLS streams. let mut mpls_by_pid: BTreeMap = BTreeMap::new(); if let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") { let names: Vec = dir .entries .iter() .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls")) .map(|e| e.name.clone()) .collect(); for name in names { let path = format!("/BDMV/PLAYLIST/{}", name); let Ok(data) = udf.read_file(reader, &path) else { continue; }; let Ok(pl) = crate::mpls::parse(&data) else { continue; }; for s in pl.streams { if s.pid == 0 { // PID 0 means "no PID in stream entry" — skip rather // than collide with other entries. continue; } mpls_by_pid .entry(s.pid) .or_insert((s.coding_type, s.language)); } } } // Merge views: every PID seen anywhere gets a row. let mut all_pids: std::collections::BTreeSet = std::collections::BTreeSet::new(); all_pids.extend(clpi_by_pid.keys().copied()); all_pids.extend(mpls_by_pid.keys().copied()); let mut rows = Vec::with_capacity(all_pids.len()); for pid in all_pids { let clpi = clpi_by_pid.get(&pid); let mpls = mpls_by_pid.get(&pid); rows.push(ClpiVsMplsRow { pid, clpi_coding_type: clpi.map(|(c, _)| *c), clpi_language: clpi.map(|(_, l)| l.clone()), mpls_coding_type: mpls.map(|(c, _)| *c), mpls_language: mpls.map(|(_, l)| l.clone()), }); } ClpiVsMplsAudit { rows } } #[cfg(test)] mod tests { use super::*; #[test] fn class_match_when_identical() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: Some(0x83), mpls_language: Some("eng".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::Match); } #[test] fn class_clpi_only_when_mpls_missing() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: None, mpls_language: None, }; assert_eq!(r.class(), ClpiVsMplsClass::ClpiOnly); } #[test] fn class_mpls_only_when_clpi_missing() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: None, clpi_language: None, mpls_coding_type: Some(0x90), mpls_language: Some("fra".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::MplsOnly); } #[test] fn class_divergent_on_lang_disagreement() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: Some(0x83), mpls_language: Some("und".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::Divergent); } #[test] fn class_both_coding_missing_divergent_on_lang() { // Caller-built row with neither coding_type but disagreeing // languages must classify Divergent, not Match. let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: None, clpi_language: Some("eng".into()), mpls_coding_type: None, mpls_language: Some("fra".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::Divergent); } #[test] fn class_both_coding_missing_match_on_equal_lang() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: None, clpi_language: Some("eng".into()), mpls_coding_type: None, mpls_language: Some("eng".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::Match); } #[test] fn class_counts_sum_rows() { let audit = ClpiVsMplsAudit { rows: vec![ ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: Some(0x83), mpls_language: Some("eng".into()), }, // Match ClpiVsMplsRow { pid: 0x1101, clpi_coding_type: Some(0x83), clpi_language: Some("fra".into()), mpls_coding_type: None, mpls_language: None, }, // ClpiOnly ClpiVsMplsRow { pid: 0x1102, clpi_coding_type: None, clpi_language: None, mpls_coding_type: Some(0x90), mpls_language: Some("eng".into()), }, // MplsOnly ClpiVsMplsRow { pid: 0x1103, clpi_coding_type: Some(0x86), clpi_language: Some("spa".into()), mpls_coding_type: Some(0x86), mpls_language: Some("ita".into()), }, // Divergent ], }; let (co, mo, m, d) = audit.class_counts(); assert_eq!(co, 1); assert_eq!(mo, 1); assert_eq!(m, 1); assert_eq!(d, 1); } // ── Additional hardening tests ───────────────────────────────────────── /// Spec: coding_type mismatch with matching language → Divergent (not Match). /// The spec doc says both coding_type AND language must agree for Match. /// Mutation: only check language for match → coding_type mismatch silently classified as Match. #[test] fn class_divergent_on_coding_type_mismatch_same_lang() { let r = ClpiVsMplsRow { pid: 0x1100, clpi_coding_type: Some(0x83), // TrueHD clpi_language: Some("eng".into()), mpls_coding_type: Some(0x86), // DTS-HD MA mpls_language: Some("eng".into()), }; assert_eq!(r.class(), ClpiVsMplsClass::Divergent); } /// Spec: empty rows → class_counts returns (0,0,0,0). Never panics on empty audit. /// Mutation: access rows[0] unconditionally → panic on empty audit. #[test] fn class_counts_empty_audit() { let audit = ClpiVsMplsAudit { rows: Vec::new() }; let (co, mo, m, d) = audit.class_counts(); assert_eq!((co, mo, m, d), (0, 0, 0, 0)); } /// Spec: class_counts tuple order is (clpi_only, mpls_only, matches, divergent). /// Verifies each counter increments the RIGHT slot. /// Mutation: swap any two counters → wrong slot increments. #[test] fn class_counts_each_counter_in_correct_slot() { // One of each class — verify tuple slots separately. let audit = ClpiVsMplsAudit { rows: vec![ // 2 ClpiOnly ClpiVsMplsRow { pid: 1, clpi_coding_type: Some(0x83), clpi_language: None, mpls_coding_type: None, mpls_language: None, }, ClpiVsMplsRow { pid: 2, clpi_coding_type: Some(0x82), clpi_language: None, mpls_coding_type: None, mpls_language: None, }, // 1 MplsOnly ClpiVsMplsRow { pid: 3, clpi_coding_type: None, clpi_language: None, mpls_coding_type: Some(0x90), mpls_language: None, }, // 3 Match ClpiVsMplsRow { pid: 4, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: Some(0x83), mpls_language: Some("eng".into()), }, ClpiVsMplsRow { pid: 5, clpi_coding_type: Some(0x82), clpi_language: Some("fra".into()), mpls_coding_type: Some(0x82), mpls_language: Some("fra".into()), }, ClpiVsMplsRow { pid: 6, clpi_coding_type: Some(0x86), clpi_language: Some("deu".into()), mpls_coding_type: Some(0x86), mpls_language: Some("deu".into()), }, // 1 Divergent ClpiVsMplsRow { pid: 7, clpi_coding_type: Some(0x83), clpi_language: Some("eng".into()), mpls_coding_type: Some(0x83), mpls_language: Some("spa".into()), }, ], }; let (co, mo, m, d) = audit.class_counts(); assert_eq!(co, 2, "clpi_only slot"); assert_eq!(mo, 1, "mpls_only slot"); assert_eq!(m, 3, "matches slot"); assert_eq!(d, 1, "divergent slot"); assert_eq!(co + mo + m + d, audit.rows.len(), "all rows accounted for"); } }