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}