Skip to main content

open_eeg_codec_standard/
levels.rs

1//! OpenECS tier spec and the grading gate.
2//!
3//! This module is the authoritative source for the OpenECS thresholds and
4//! the `grade` logic. The tier thresholds and per-band requirement
5//! tables are ported verbatim from the Python reference
6//! (`lamquant_codec/lqs.py`), with the L-tier PRD gate redefined as an
7//! EXACT-ZERO short-circuit (see [`grade`]).
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13/// Per-frequency-band quality requirement.
14///
15/// `freq_range` is kept for documentation / reporting parity with the
16/// Python spec but the gate only consults `max_prd` / `min_r`.
17#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
18pub struct BandRequirement {
19    /// Inclusive-low, exclusive-high band edges in Hz (informational).
20    pub freq_range: (f64, f64),
21    /// Maximum allowed per-band PRD (percent). Lower is better.
22    pub max_prd: f64,
23    /// Minimum allowed per-band Pearson R. Higher is better.
24    pub min_r: f64,
25}
26
27impl BandRequirement {
28    /// Convenience constructor matching the Python `BandRequirement(...)`.
29    pub fn new(lo: f64, hi: f64, max_prd: f64, min_r: f64) -> Self {
30        Self {
31            freq_range: (lo, hi),
32            max_prd,
33            min_r,
34        }
35    }
36}
37
38/// One tier of the OpenECS standard.
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub struct EcsLevel {
41    /// Human-readable tier name, e.g. "Clinical".
42    pub name: String,
43    /// Single-character tier code: 'L', 'C', 'M', or 'A'.
44    pub level: char,
45    /// Maximum allowed global PRD (percent). For the L tier this field
46    /// is documentary only — the L gate is an exact-zero short-circuit.
47    pub max_prd: f64,
48    /// Minimum allowed global Pearson R.
49    pub min_r: f64,
50    /// Maximum allowed SNR loss (dB). Reported, not gated, in this port.
51    pub max_snr_loss: f64,
52    /// Minimum required compression ratio (raw / compressed).
53    pub min_cr: f64,
54    /// Per-band fidelity requirements keyed by band name.
55    pub band_fidelity: BTreeMap<String, BandRequirement>,
56}
57
58/// The result of grading a set of metrics against the OpenECS standard.
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60pub struct ComplianceResult {
61    /// Highest passing tier code, or '\0' if below the alerting floor.
62    pub grade: char,
63    /// Why the next-higher tier failed — the to-do list to climb a tier.
64    /// Empty when the codec already passes the strictest tier (L or C).
65    pub violations: Vec<String>,
66}
67
68impl ComplianceResult {
69    /// True iff the codec reached any compliant tier (grade != '\0').
70    pub fn passed(&self) -> bool {
71        self.grade != '\0'
72    }
73
74    /// The grade as a string, or "" for the below-floor sentinel.
75    pub fn grade_str(&self) -> String {
76        if self.grade == '\0' {
77            String::new()
78        } else {
79            self.grade.to_string()
80        }
81    }
82}
83
84/// Build the canonical OpenECS tier table.
85///
86/// Returned in strictness order L, C, M, A. Thresholds and band tables
87/// are ported verbatim from `lamquant_codec/lqs.py`, except the L tier
88/// whose PRD gate is an exact-zero short-circuit handled in [`grade`]
89/// and whose `min_cr` is locked to 0.8 by the vendor-neutral spec.
90pub fn levels() -> Vec<EcsLevel> {
91    let mut out = Vec::with_capacity(4);
92
93    // ── L : Lossless ────────────────────────────────────────────────
94    // No band requirements; PRD gate is special-cased EXACT-ZERO.
95    out.push(EcsLevel {
96        name: "Lossless".to_string(),
97        level: 'L',
98        max_prd: 0.0,
99        min_r: 1.0,
100        max_snr_loss: 0.0,
101        min_cr: 0.8,
102        band_fidelity: BTreeMap::new(),
103    });
104
105    // ── N : Near-Lossless ───────────────────────────────────────────
106    // Not bit-exact, but distortion is small and the shape is essentially
107    // preserved: global PRD ≤ 5 %, R ≥ 0.99, and no expansion. No per-band
108    // requirements (a ≤5 % global PRD is already inside every clinical band
109    // floor). The strongest non-lossless tier. (OpenECS v2.0.)
110    out.push(EcsLevel {
111        name: "Near-Lossless".to_string(),
112        level: 'N',
113        max_prd: 5.0,
114        min_r: 0.99,
115        max_snr_loss: 2.0,
116        min_cr: 1.0,
117        band_fidelity: BTreeMap::new(),
118    });
119
120    // ── C : Clinical ────────────────────────────────────────────────
121    {
122        let mut bands = BTreeMap::new();
123        bands.insert("delta".to_string(), BandRequirement::new(0.5, 4.0, 5.0, 0.98));
124        bands.insert("theta".to_string(), BandRequirement::new(4.0, 8.0, 7.0, 0.97));
125        bands.insert("alpha".to_string(), BandRequirement::new(8.0, 13.0, 8.0, 0.96));
126        bands.insert("beta".to_string(), BandRequirement::new(13.0, 30.0, 12.0, 0.93));
127        bands.insert("gamma".to_string(), BandRequirement::new(30.0, 50.0, 20.0, 0.85));
128        out.push(EcsLevel {
129            name: "Clinical".to_string(),
130            level: 'C',
131            max_prd: 9.0,
132            min_r: 0.95,
133            max_snr_loss: 3.0,
134            min_cr: 20.0,
135            band_fidelity: bands,
136        });
137    }
138
139    // ── M : Monitoring ──────────────────────────────────────────────
140    {
141        let mut bands = BTreeMap::new();
142        bands.insert("delta".to_string(), BandRequirement::new(0.5, 4.0, 10.0, 0.95));
143        bands.insert("theta".to_string(), BandRequirement::new(4.0, 8.0, 12.0, 0.93));
144        bands.insert("alpha".to_string(), BandRequirement::new(8.0, 13.0, 15.0, 0.90));
145        bands.insert("beta".to_string(), BandRequirement::new(13.0, 30.0, 25.0, 0.80));
146        bands.insert("gamma".to_string(), BandRequirement::new(30.0, 50.0, 40.0, 0.60));
147        out.push(EcsLevel {
148            name: "Monitoring".to_string(),
149            level: 'M',
150            max_prd: 20.0,
151            min_r: 0.85,
152            max_snr_loss: 6.0,
153            min_cr: 100.0,
154            band_fidelity: bands,
155        });
156    }
157
158    // ── A : Alerting ────────────────────────────────────────────────
159    {
160        let mut bands = BTreeMap::new();
161        bands.insert("delta".to_string(), BandRequirement::new(0.5, 4.0, 20.0, 0.85));
162        bands.insert("theta".to_string(), BandRequirement::new(4.0, 8.0, 25.0, 0.80));
163        bands.insert("alpha".to_string(), BandRequirement::new(8.0, 13.0, 30.0, 0.75));
164        bands.insert("beta".to_string(), BandRequirement::new(13.0, 30.0, 40.0, 0.65));
165        bands.insert("gamma".to_string(), BandRequirement::new(30.0, 50.0, 60.0, 0.40));
166        out.push(EcsLevel {
167            name: "Alerting".to_string(),
168            level: 'A',
169            max_prd: 40.0,
170            min_r: 0.70,
171            max_snr_loss: 10.0,
172            min_cr: 200.0,
173            band_fidelity: bands,
174        });
175    }
176
177    out
178}
179
180/// Look up a single tier by its character code.
181pub fn level_by_char(c: char) -> Option<EcsLevel> {
182    levels().into_iter().find(|l| l.level == c)
183}
184
185/// Check one lossy tier (C / M / A) against the supplied metrics.
186///
187/// Returns the list of violations; an empty list means the tier passes.
188/// `per_band` is a slice of `(band_name, band_r, band_prd)` triples.
189/// A band requirement only constrains the metrics if a matching band
190/// name is present in `per_band`; unmeasured bands are not penalized
191/// (mirrors the Python `_check_level`, which skips `None` band values).
192fn check_lossy(
193    level: &EcsLevel,
194    r: f64,
195    prd: f64,
196    cr: f64,
197    per_band: &[(String, f64, f64)],
198) -> Vec<String> {
199    let mut v = Vec::new();
200
201    if r < level.min_r {
202        v.push(format!("global R {r:.4} < {:.4}", level.min_r));
203    }
204    if prd > level.max_prd {
205        v.push(format!("global PRD {prd:.2}% > {:.2}%", level.max_prd));
206    }
207    if cr < level.min_cr {
208        v.push(format!("CR {cr:.1} < {:.1}", level.min_cr));
209    }
210
211    for (band_name, req) in &level.band_fidelity {
212        if let Some((_, br, bp)) = per_band.iter().find(|(n, _, _)| n == band_name) {
213            if *br < req.min_r {
214                v.push(format!("{band_name} R {br:.4} < {:.4}", req.min_r));
215            }
216            if *bp > req.max_prd {
217                v.push(format!("{band_name} PRD {bp:.2}% > {:.2}%", req.max_prd));
218            }
219        }
220    }
221
222    v
223}
224
225/// Grade a set of metrics against the OpenECS standard.
226///
227/// Returns the highest passing tier and, when relevant, the violations
228/// that blocked the next-higher tier (the climb-a-tier to-do list).
229///
230/// Gate order:
231///
232/// 1. **ECS-L short-circuit.** If `prd == 0.0` exactly and `cr >= 0.8`,
233///    grade `'L'` and stop. PRD of exactly zero on the integer sample
234///    domain means bit-exact reconstruction, which is the lossless
235///    contract. (Callers should derive this `prd` via
236///    [`crate::metrics::prd_is_exact_zero`] on integer samples, not via
237///    the float PRD, to avoid ~1e-12 roundoff spuriously failing the
238///    exact-zero test.)
239/// 2. **N → C → M → A descent.** A lossy tier passes iff the global R, PRD,
240///    and CR thresholds are met AND every measured band meets its
241///    per-band R and PRD floors. The highest fully-passing tier wins.
242///    `N` (Near-Lossless) is the strictest lossy tier — negligible
243///    distortion (R ≥ 0.999, PRD ≤ 0.5 %) without bit-exactness — and
244///    carries no per-band requirements.
245/// 3. **Below floor.** If no tier passes, grade is the `'\0'` sentinel.
246///
247/// `per_band` is a slice of `(band_name, band_r, band_prd)` triples.
248/// Pass an empty slice to gate on the global metrics only.
249pub fn grade(
250    r: f64,
251    prd: f64,
252    cr: f64,
253    _snr_loss: f64,
254    per_band: &[(String, f64, f64)],
255) -> ComplianceResult {
256    let tiers = levels();
257
258    // 1. ECS-L exact-zero short-circuit.
259    if prd == 0.0 && cr >= 0.8 {
260        return ComplianceResult {
261            grade: 'L',
262            violations: Vec::new(),
263        };
264    }
265
266    // 2. Descend N -> C -> M -> A. The reported violations are those of the
267    //    tier IMMEDIATELY above the one that passes — a precise "climb ONE
268    //    tier" to-do list (e.g. a codec on M is told what blocks C, not what
269    //    blocks the topmost tier).
270    let lossy_order = ['N', 'C', 'M', 'A'];
271    let mut prev_violations: Vec<String> = Vec::new();
272
273    for code in lossy_order {
274        let level = tiers
275            .iter()
276            .find(|l| l.level == code)
277            .expect("lossy tier present in table");
278        let violations = check_lossy(level, r, prd, cr, per_band);
279        if violations.is_empty() {
280            return ComplianceResult {
281                grade: code,
282                violations: prev_violations,
283            };
284        }
285        prev_violations = violations;
286    }
287
288    // 3. Below the alerting floor — report what blocked the floor (A).
289    ComplianceResult {
290        grade: '\0',
291        violations: prev_violations,
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn good_clinical_bands() -> Vec<(String, f64, f64)> {
300        // (name, R, PRD) comfortably inside the C-tier floors.
301        vec![
302            ("delta".to_string(), 0.99, 3.0),
303            ("theta".to_string(), 0.98, 4.0),
304            ("alpha".to_string(), 0.97, 5.0),
305            ("beta".to_string(), 0.95, 8.0),
306            ("gamma".to_string(), 0.90, 12.0),
307        ]
308    }
309
310    #[test]
311    fn table_has_five_tiers_in_order() {
312        let t = levels();
313        assert_eq!(t.len(), 5);
314        assert_eq!(t[0].level, 'L');
315        assert_eq!(t[1].level, 'N');
316        assert_eq!(t[2].level, 'C');
317        assert_eq!(t[3].level, 'M');
318        assert_eq!(t[4].level, 'A');
319        // L tier carries the vendor-neutral CR floor; N has no per-band reqs.
320        assert_eq!(t[0].min_cr, 0.8);
321        assert!(t[0].band_fidelity.is_empty());
322        assert_eq!(t[1].max_prd, 5.0);
323        assert_eq!(t[1].min_r, 0.99);
324        assert_eq!(t[1].min_cr, 1.0);
325        assert!(t[1].band_fidelity.is_empty());
326    }
327
328    #[test]
329    fn near_lossless_tier() {
330        // Small distortion (PRD <= 5, R >= 0.99), compresses -> N (not L: not
331        // exact; the strongest non-lossless tier).
332        let res = grade(0.995, 3.0, 2.0, 0.0, &[]);
333        assert_eq!(res.grade, 'N');
334        assert!(res.violations.is_empty());
335
336        // PRD over the N ceiling (5%) but inside C -> falls to C; the climb-a-
337        // tier to-do points at the N PRD gate.
338        let res = grade(0.96, 7.0, 25.0, 0.0, &good_clinical_bands());
339        assert_eq!(res.grade, 'C');
340        assert!(res.violations.iter().any(|s| s.contains("PRD")));
341
342        // Near-lossless distortion but EXPANDS (cr < 1.0) -> cannot be N (and
343        // fails C/M/A on their CR floors too) -> below floor.
344        let res = grade(0.9999, 0.1, 0.9, 0.0, &[]);
345        assert_ne!(res.grade, 'N');
346    }
347
348    #[test]
349    fn lossless_short_circuit() {
350        // prd == 0 exactly and cr >= 0.8 => 'L'.
351        let res = grade(1.0, 0.0, 0.8, 0.0, &[]);
352        assert_eq!(res.grade, 'L');
353        assert!(res.violations.is_empty());
354        assert!(res.passed());
355
356        // Even with garbage R it is still L: exact-zero is bit-exact.
357        let res2 = grade(0.0, 0.0, 5.0, 0.0, &[]);
358        assert_eq!(res2.grade, 'L');
359    }
360
361    #[test]
362    fn lossless_blocked_by_cr_floor() {
363        // prd == 0 but cr below 0.8 cannot be L; falls through. With
364        // bad R it lands below the floor.
365        let res = grade(0.0, 0.0, 0.5, 0.0, &[]);
366        assert_ne!(res.grade, 'L');
367    }
368
369    #[test]
370    fn clinical_pass() {
371        // prd=5, r=0.96, cr=25 + good bands => 'C' (fails N on R<0.99).
372        let res = grade(0.96, 5.0, 25.0, 0.0, &good_clinical_bands());
373        assert_eq!(res.grade, 'C');
374        // C is no longer the top lossy tier (N is above it), so the climb-a-
375        // tier to-do points at the N gate the codec missed (R 0.96 < 0.99).
376        assert!(res.violations.iter().any(|s| s.contains('R')));
377    }
378
379    #[test]
380    fn clinical_pass_global_only() {
381        // No band info supplied: gate on globals only, still 'C'.
382        let res = grade(0.96, 5.0, 25.0, 0.0, &[]);
383        assert_eq!(res.grade, 'C');
384    }
385
386    #[test]
387    fn below_floor() {
388        // prd=50, r=0.5 => '' (below alerting floor).
389        let res = grade(0.5, 50.0, 1.0, 0.0, &[]);
390        assert_eq!(res.grade, '\0');
391        assert!(!res.passed());
392        assert_eq!(res.grade_str(), "");
393        // The violations explain why the alerting floor (A) was missed.
394        assert!(!res.violations.is_empty());
395    }
396
397    #[test]
398    fn descends_to_monitoring_with_clinical_todo() {
399        // Passes M globals (r>=0.85, prd<=20, cr>=100) but fails C
400        // (cr<20? no — set cr=120; fail C on prd>9 and r<0.95).
401        let res = grade(0.90, 15.0, 120.0, 0.0, &[]);
402        assert_eq!(res.grade, 'M');
403        // Reported violations are the C-tier to-do list.
404        assert!(res.violations.iter().any(|s| s.contains("PRD")));
405        assert!(res.violations.iter().any(|s| s.contains('R')));
406    }
407
408    #[test]
409    fn band_failure_drops_tier() {
410        // Globals pass C, but a band R is below the C floor => C fails,
411        // and M floors (looser) are met, so we land on M.
412        let mut bands = good_clinical_bands();
413        // Tank gamma R below C's 0.85 but keep it above M's 0.60.
414        bands[4] = ("gamma".to_string(), 0.70, 12.0);
415        let res = grade(0.96, 5.0, 120.0, 0.0, &bands);
416        assert_eq!(res.grade, 'M');
417        assert!(res.violations.iter().any(|s| s.contains("gamma")));
418    }
419}