Skip to main content

u_analytics/msa/
mod.rs

1//! Measurement System Analysis (MSA).
2//!
3//! Implements Gage R&R studies using both the X̄-R (Average & Range) method
4//! and the ANOVA method, following AIAG MSA 4th Edition.
5//!
6//! # Overview
7//!
8//! A Gage R&R study decomposes total measurement variation into:
9//! - **Repeatability (EV)**: Equipment variation — same operator, same part, multiple trials
10//! - **Reproducibility (AV)**: Appraiser variation — different operators, same part
11//! - **Part Variation (PV)**: True part-to-part variation
12//!
13//! # References
14//!
15//! - AIAG (2010). *Measurement Systems Analysis*, 4th ed.
16//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.
17
18use u_numflow::special;
19use u_numflow::stats;
20
21// ---------------------------------------------------------------------------
22// Types
23// ---------------------------------------------------------------------------
24
25/// Input data for a Gage R&R study.
26///
27/// The `measurements` field is a 3D array indexed as `[part][operator][trial]`.
28/// All parts must have the same number of operators, and all operator×part cells
29/// must have the same number of trials.
30pub struct GageRRInput {
31    /// 3D measurement data: `measurements[part][operator][trial]`.
32    pub measurements: Vec<Vec<Vec<f64>>>,
33    /// Process tolerance (USL − LSL), optional for %Tolerance calculation.
34    pub tolerance: Option<f64>,
35}
36
37/// A centre line and control limits for one of the study's two charts.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct GageChartLimits {
40    /// Centre line: R̄ for the range chart, X̿ for the average chart.
41    pub center: f64,
42    /// Upper control limit.
43    pub ucl: f64,
44    /// Lower control limit.
45    pub lcl: f64,
46}
47
48/// Results from a Gage R&R study (X̄-R or ANOVA method).
49#[derive(Debug, Clone)]
50pub struct GageRRResult {
51    /// Equipment Variation (Repeatability).
52    pub ev: f64,
53    /// Appraiser Variation (Reproducibility).
54    pub av: f64,
55    /// Gage R&R = √(EV² + AV²).
56    pub grr: f64,
57    /// Part Variation.
58    pub pv: f64,
59    /// Total Variation = √(GRR² + PV²).
60    pub tv: f64,
61
62    /// %EV = EV / TV × 100.
63    pub percent_ev: f64,
64    /// %AV = AV / TV × 100.
65    pub percent_av: f64,
66    /// %GRR = GRR / TV × 100.
67    pub percent_grr: f64,
68    /// %PV = PV / TV × 100.
69    pub percent_pv: f64,
70    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
71    pub percent_tolerance: Option<f64>,
72
73    /// The range chart the method plots: R̄ and its limits, over the
74    /// operator×part cell ranges the variance components are built from.
75    pub range_chart: GageChartLimits,
76    /// The average chart the method plots: X̿ and its limits, over the
77    /// operator×part cell averages.
78    ///
79    /// Unlike a process chart, points here are *expected* to fall outside the
80    /// limits -- that is how the study reads whether the parts vary enough to
81    /// be told apart.
82    pub average_chart: GageChartLimits,
83
84    /// Number of Distinct Categories = floor(1.41 × PV / GRR), minimum 1.
85    pub ndc: u32,
86    /// Acceptability status based on %GRR.
87    pub status: GrrStatus,
88}
89
90/// ANOVA-based Gage R&R result with full ANOVA table and variance components.
91#[derive(Debug, Clone)]
92pub struct GageRRAnovaResult {
93    /// Two-factor crossed ANOVA table.
94    pub anova_table: AnovaTable,
95    /// Variance components extracted from expected mean squares.
96    pub variance_components: VarianceComponents,
97    /// Equipment Variation (Repeatability) = √σ²_repeatability.
98    pub ev: f64,
99    /// Appraiser Variation (Reproducibility) = √σ²_reproducibility.
100    pub av: f64,
101    /// Gage R&R = √(EV² + AV²).
102    pub grr: f64,
103    /// Part Variation = √σ²_part.
104    pub pv: f64,
105    /// Total Variation = √σ²_total.
106    pub tv: f64,
107    /// %GRR = GRR / TV × 100.
108    pub percent_grr: f64,
109    /// %Tolerance = 6 × GRR / tolerance × 100 (if tolerance provided).
110    pub percent_tolerance: Option<f64>,
111    /// Number of Distinct Categories.
112    pub ndc: u32,
113    /// Acceptability status.
114    pub status: GrrStatus,
115    /// Whether the Part×Operator interaction is significant (p ≤ 0.25).
116    pub interaction_significant: bool,
117    /// Whether the interaction term was pooled into error.
118    pub interaction_pooled: bool,
119}
120
121/// ANOVA table for a two-factor crossed design.
122#[derive(Debug, Clone)]
123pub struct AnovaTable {
124    /// Rows of the ANOVA table.
125    pub rows: Vec<AnovaRow>,
126}
127
128/// A single row in the ANOVA table.
129#[derive(Debug, Clone)]
130pub struct AnovaRow {
131    /// Source of variation: "Part", "Operator", "Part×Operator", "Repeatability", "Total".
132    pub source: String,
133    /// Degrees of freedom.
134    pub df: f64,
135    /// Sum of squares.
136    pub ss: f64,
137    /// Mean square (SS / DF).
138    pub ms: f64,
139    /// F statistic (None for Error/Total rows).
140    pub f_value: Option<f64>,
141    /// p-value from F distribution (None for Error/Total rows).
142    pub p_value: Option<f64>,
143}
144
145/// Variance components from ANOVA expected mean squares.
146#[derive(Debug, Clone)]
147pub struct VarianceComponents {
148    /// σ²_part — true part-to-part variation.
149    pub part: f64,
150    /// σ²_operator — operator main effect.
151    pub operator: f64,
152    /// σ²_interaction — part×operator interaction.
153    pub interaction: f64,
154    /// σ²_repeatability — within-cell (equipment) variation.
155    pub repeatability: f64,
156    /// σ²_reproducibility = σ²_operator + σ²_interaction.
157    pub reproducibility: f64,
158    /// σ²_total = σ²_part + σ²_grr.
159    pub total: f64,
160}
161
162/// Acceptability status based on %GRR (AIAG guidelines).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum GrrStatus {
165    /// %GRR ≤ 10% — measurement system is acceptable.
166    Acceptable,
167    /// 10% < %GRR ≤ 30% — may be acceptable depending on application.
168    Marginal,
169    /// %GRR > 30% — measurement system needs improvement.
170    Unacceptable,
171}
172
173// ---------------------------------------------------------------------------
174// K-factor constants (AIAG MSA 4th Ed.)
175// ---------------------------------------------------------------------------
176
177/// K1 constants: 1/d2* for the number of trials.
178/// Index: K1[trials - 2] (trials = 2..=3).
179///
180/// Values from AIAG MSA 4th Edition, Table III-B-1.
181#[allow(clippy::approx_constant)]
182const K1: [f64; 2] = [0.8862, 0.5908];
183
184/// K2 constants: 1/d2* for the number of operators.
185/// Index: K2[operators - 2] (operators = 2..=3).
186///
187/// Values from AIAG MSA 4th Edition, Table III-B-1.
188#[allow(clippy::approx_constant)]
189const K2: [f64; 2] = [0.7071, 0.5231];
190
191/// K3 constants: 1/d2* for the number of parts.
192/// Index: K3[parts - 2] (parts = 2..=10).
193///
194/// Values from AIAG MSA 4th Edition, Table III-B-1.
195#[allow(clippy::approx_constant)]
196const K3: [f64; 9] = [
197    0.7071, 0.5231, 0.4467, 0.4030, 0.3742, 0.3534, 0.3375, 0.3249, 0.3146,
198];
199
200// ---------------------------------------------------------------------------
201// Helpers
202// ---------------------------------------------------------------------------
203
204/// Determine GRR status from %GRR.
205fn grr_status(percent_grr: f64) -> GrrStatus {
206    if percent_grr <= 10.0 {
207        GrrStatus::Acceptable
208    } else if percent_grr <= 30.0 {
209        GrrStatus::Marginal
210    } else {
211        GrrStatus::Unacceptable
212    }
213}
214
215/// Compute NDC = floor(1.41 × PV / GRR), minimum 1.
216fn compute_ndc(pv: f64, grr: f64) -> u32 {
217    if grr < 1e-300 {
218        return 1;
219    }
220    let ndc = (1.41 * pv / grr).floor() as i64;
221    ndc.max(1) as u32
222}
223
224/// Validate the 3D measurement array. Returns `(n_parts, n_operators, n_trials)`
225/// or an error message.
226fn validate_measurements(
227    measurements: &[Vec<Vec<f64>>],
228) -> Result<(usize, usize, usize), &'static str> {
229    let n_parts = measurements.len();
230    if n_parts < 2 {
231        return Err("at least 2 parts are required");
232    }
233
234    let n_operators = measurements[0].len();
235    if n_operators < 2 {
236        return Err("at least 2 operators are required");
237    }
238
239    let n_trials = measurements[0][0].len();
240    if n_trials < 2 {
241        return Err("at least 2 trials are required");
242    }
243
244    for part in measurements {
245        if part.len() != n_operators {
246            return Err("all parts must have the same number of operators");
247        }
248        for trials in part {
249            if trials.len() != n_trials {
250                return Err("all operator×part cells must have the same number of trials");
251            }
252            for &v in trials {
253                if !v.is_finite() {
254                    return Err("all measurements must be finite");
255                }
256            }
257        }
258    }
259
260    Ok((n_parts, n_operators, n_trials))
261}
262
263// ---------------------------------------------------------------------------
264// X̄-R Method
265// ---------------------------------------------------------------------------
266
267/// Gage R&R using the X̄-R (Average & Range) method (AIAG MSA 4th Edition).
268///
269/// # Algorithm
270///
271/// 1. For each operator×part cell, compute the range across trials.
272/// 2. R̄ = grand mean of all ranges.
273/// 3. For each operator, compute part averages → operator grand averages → X̄_diff.
274/// 4. EV = R̄ × K1(trials), AV = √((X̄_diff × K2)² − EV²/(n×r)), GRR = √(EV² + AV²).
275/// 5. Part means → Rp → PV = Rp × K3(parts), TV = √(GRR² + PV²).
276/// 6. NDC = floor(1.41 × PV / GRR).
277///
278/// # Arguments
279///
280/// * `input` — Measurement data and optional tolerance.
281///
282/// # Returns
283///
284/// `Err` if input dimensions are invalid or out of supported range
285/// (parts 2..=10, operators 2..=3, trials 2..=3).
286///
287/// # References
288///
289/// AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III.
290pub fn gage_rr_xbar_r(input: &GageRRInput) -> Result<GageRRResult, &'static str> {
291    let (n_parts, n_operators, n_trials) = validate_measurements(&input.measurements)?;
292
293    // Validate supported ranges for K-factor tables
294    if !(2..=3).contains(&n_trials) {
295        return Err("X̄-R method supports 2 or 3 trials");
296    }
297    if !(2..=3).contains(&n_operators) {
298        return Err("X̄-R method supports 2 or 3 operators");
299    }
300    if !(2..=10).contains(&n_parts) {
301        return Err("X̄-R method supports 2 to 10 parts");
302    }
303
304    // Step 1: Compute range for each operator×part cell
305    let mut ranges: Vec<f64> = Vec::with_capacity(n_parts * n_operators);
306    for part in &input.measurements {
307        for trials in part {
308            let min = trials.iter().copied().fold(f64::INFINITY, f64::min);
309            let max = trials.iter().copied().fold(f64::NEG_INFINITY, f64::max);
310            ranges.push(max - min);
311        }
312    }
313
314    // Step 2: R̄ = grand mean of all ranges
315    let r_bar = stats::mean(&ranges).expect("ranges is non-empty");
316
317    // Step 3: Operator averages and X̄_diff
318    // Compute the average measurement for each operator across all parts and trials
319    let mut operator_avgs: Vec<f64> = Vec::with_capacity(n_operators);
320    for op in 0..n_operators {
321        let mut sum = 0.0;
322        let mut count = 0usize;
323        for part in &input.measurements {
324            for &v in &part[op] {
325                sum += v;
326                count += 1;
327            }
328        }
329        operator_avgs.push(sum / count as f64);
330    }
331
332    let x_diff = operator_avgs
333        .iter()
334        .copied()
335        .fold(f64::NEG_INFINITY, f64::max)
336        - operator_avgs.iter().copied().fold(f64::INFINITY, f64::min);
337
338    // Step 4: EV and AV
339    let k1 = K1[n_trials - 2];
340    let k2 = K2[n_operators - 2];
341
342    let ev = r_bar * k1;
343
344    let av_squared = (x_diff * k2).powi(2) - ev.powi(2) / (n_parts * n_trials) as f64;
345    let av = if av_squared > 0.0 {
346        av_squared.sqrt()
347    } else {
348        0.0
349    };
350
351    // Step 5: GRR
352    let grr = (ev.powi(2) + av.powi(2)).sqrt();
353
354    // Step 6: Part means across all operators/trials → PV
355    let mut part_means: Vec<f64> = Vec::with_capacity(n_parts);
356    for part in &input.measurements {
357        let mut sum = 0.0;
358        let mut count = 0usize;
359        for trials in part {
360            for &v in trials {
361                sum += v;
362                count += 1;
363            }
364        }
365        part_means.push(sum / count as f64);
366    }
367
368    let rp = part_means.iter().copied().fold(f64::NEG_INFINITY, f64::max)
369        - part_means.iter().copied().fold(f64::INFINITY, f64::min);
370
371    let k3 = K3[n_parts - 2];
372    let pv = rp * k3;
373
374    // Step 7: TV
375    let tv = (grr.powi(2) + pv.powi(2)).sqrt();
376
377    // Percentages
378    let (percent_ev, percent_av, percent_grr, percent_pv) = if tv > 1e-300 {
379        (
380            ev / tv * 100.0,
381            av / tv * 100.0,
382            grr / tv * 100.0,
383            pv / tv * 100.0,
384        )
385    } else {
386        (0.0, 0.0, 0.0, 0.0)
387    };
388
389    let percent_tolerance = input.tolerance.and_then(|tol| {
390        if tol > 1e-300 {
391            Some(grr / tol * 600.0)
392        } else {
393            None
394        }
395    });
396
397    // The two charts the method plots, from the same R̄ the components use --
398    // computing them anywhere else invites a second, slightly different R̄.
399    // The subgroup for both is one operator×part cell, so n is the trial count,
400    // which this function has already narrowed to 2 or 3.
401    let (a2, d3, d4) = crate::spc::range_chart_factors(n_trials)
402        .expect("n_trials is 2 or 3, inside the factor tables");
403    let cell_means: Vec<f64> = input
404        .measurements
405        .iter()
406        .flat_map(|part| part.iter().map(|trials| stats::mean(trials).unwrap_or(0.0)))
407        .collect();
408    let x_double_bar = stats::mean(&cell_means).expect("cell_means is non-empty");
409    let range_chart = GageChartLimits {
410        center: r_bar,
411        ucl: d4 * r_bar,
412        lcl: d3 * r_bar,
413    };
414    let average_chart = GageChartLimits {
415        center: x_double_bar,
416        ucl: x_double_bar + a2 * r_bar,
417        lcl: x_double_bar - a2 * r_bar,
418    };
419
420    let ndc = compute_ndc(pv, grr);
421    let status = grr_status(percent_grr);
422
423    Ok(GageRRResult {
424        range_chart,
425        average_chart,
426        ev,
427        av,
428        grr,
429        pv,
430        tv,
431        percent_ev,
432        percent_av,
433        percent_grr,
434        percent_pv,
435        percent_tolerance,
436        ndc,
437        status,
438    })
439}
440
441// ---------------------------------------------------------------------------
442// ANOVA Method
443// ---------------------------------------------------------------------------
444
445/// Gage R&R using the two-factor crossed ANOVA method (AIAG MSA 4th Edition).
446///
447/// # Algorithm
448///
449/// Performs a Part × Operator crossed ANOVA with replications (trials).
450/// Computes SS for Part, Operator, Interaction, and Error (Repeatability).
451/// Extracts variance components from expected mean squares.
452/// If the interaction p-value > 0.25, pools the interaction into error.
453///
454/// # Arguments
455///
456/// * `input` — Measurement data and optional tolerance.
457///
458/// # Returns
459///
460/// `Err` if input dimensions are invalid (need ≥ 2 parts, ≥ 2 operators, ≥ 2 trials).
461///
462/// # References
463///
464/// - AIAG (2010). *Measurement Systems Analysis*, 4th ed., Chapter III, Section D.
465/// - Montgomery (2019). *Introduction to Statistical Quality Control*, 8th ed., §5.4.
466pub fn gage_rr_anova(input: &GageRRInput) -> Result<GageRRAnovaResult, &'static str> {
467    let (p, o, r) = validate_measurements(&input.measurements)?;
468    let n_total = p * o * r;
469
470    // Compute grand mean
471    let mut grand_sum = 0.0;
472    for part in &input.measurements {
473        for trials in part {
474            for &v in trials {
475                grand_sum += v;
476            }
477        }
478    }
479    let grand_mean = grand_sum / n_total as f64;
480
481    // Part means (across all operators and trials)
482    let mut part_means: Vec<f64> = Vec::with_capacity(p);
483    for part in &input.measurements {
484        let mut sum = 0.0;
485        for trials in part {
486            for &v in trials {
487                sum += v;
488            }
489        }
490        part_means.push(sum / (o * r) as f64);
491    }
492
493    // Operator means (across all parts and trials)
494    let mut operator_means: Vec<f64> = Vec::with_capacity(o);
495    for op in 0..o {
496        let mut sum = 0.0;
497        for part in &input.measurements {
498            for &v in &part[op] {
499                sum += v;
500            }
501        }
502        operator_means.push(sum / (p * r) as f64);
503    }
504
505    // Cell means (part × operator, averaged over trials)
506    let mut cell_means: Vec<Vec<f64>> = Vec::with_capacity(p);
507    for part in &input.measurements {
508        let mut row: Vec<f64> = Vec::with_capacity(o);
509        for trials in part {
510            let cell_sum: f64 = trials.iter().sum();
511            row.push(cell_sum / r as f64);
512        }
513        cell_means.push(row);
514    }
515
516    // SS_Part = o * r * Σ(part_mean - grand_mean)²
517    let ss_part: f64 = part_means
518        .iter()
519        .map(|&pm| (pm - grand_mean).powi(2))
520        .sum::<f64>()
521        * (o * r) as f64;
522
523    // SS_Operator = p * r * Σ(operator_mean - grand_mean)²
524    let ss_operator: f64 = operator_means
525        .iter()
526        .map(|&om| (om - grand_mean).powi(2))
527        .sum::<f64>()
528        * (p * r) as f64;
529
530    // SS_Interaction = r * Σ_ij (cell_mean_ij - part_mean_i - operator_mean_j + grand_mean)²
531    let mut ss_interaction = 0.0;
532    for (i, row) in cell_means.iter().enumerate() {
533        for (j, &cm) in row.iter().enumerate() {
534            let residual = cm - part_means[i] - operator_means[j] + grand_mean;
535            ss_interaction += residual.powi(2);
536        }
537    }
538    ss_interaction *= r as f64;
539
540    // SS_Total = Σ(x_ijk - grand_mean)²
541    let mut ss_total = 0.0;
542    for part in &input.measurements {
543        for trials in part {
544            for &v in trials {
545                ss_total += (v - grand_mean).powi(2);
546            }
547        }
548    }
549
550    // SS_Error = SS_Total - SS_Part - SS_Operator - SS_Interaction
551    let ss_error = ss_total - ss_part - ss_operator - ss_interaction;
552
553    // Degrees of freedom
554    let df_part = (p - 1) as f64;
555    let df_operator = (o - 1) as f64;
556    let df_interaction = ((p - 1) * (o - 1)) as f64;
557    let df_error = (p * o * (r - 1)) as f64;
558    let df_total = (n_total - 1) as f64;
559
560    // Mean squares
561    let ms_part = ss_part / df_part;
562    let ms_operator = ss_operator / df_operator;
563    let ms_interaction = if df_interaction > 0.0 {
564        ss_interaction / df_interaction
565    } else {
566        0.0
567    };
568    let ms_error = if df_error > 0.0 {
569        ss_error / df_error
570    } else {
571        0.0
572    };
573
574    // A mean-square denominator is numerically *degenerate* — indistinguishable
575    // from zero variance — when it collapses to floating-point noise relative to
576    // the total variation (e.g. every trial within each cell identical, so the
577    // within-cell repeatability SS is 0 up to rounding: ms ≈ ±1e-17). A *relative*
578    // floor, with a small absolute backstop for the all-identical case where the
579    // total MS is itself ~0, keeps the boundary sign-independent: a repeatability
580    // MS of +2e-17 and one that dips to −4e-18 under rounding are BOTH reported as
581    // "not computable" (F/p = None), rather than flipping between a spurious ~1e12
582    // finite F ("very significant") and None depending on the noise sign.
583    let ms_total = ss_total / df_total;
584    let degenerate_floor = (1e-9 * ms_total.abs()).max(1e-12);
585    let is_valid_denom = |ms: f64| ms > degenerate_floor;
586
587    // F statistics and p-values
588    let (f_interaction, p_interaction) = if is_valid_denom(ms_error) && df_interaction > 0.0 {
589        let f_val = ms_interaction / ms_error;
590        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_interaction, df_error);
591        (Some(f_val), Some(p_val))
592    } else {
593        (None, None)
594    };
595
596    // Determine if interaction should be pooled (p > 0.25)
597    let interaction_significant = p_interaction.is_some_and(|p| p <= 0.25);
598    let interaction_pooled = !interaction_significant;
599
600    // Pooled error term (AIAG "without interaction" model): interaction SS/df is
601    // folded into the error term, yielding a single pooled MS that serves as BOTH
602    // the common F-test denominator AND the repeatability variance component.
603    // Computed once here so every consumer (F-tests, ANOVA table, variance
604    // components) uses the identical value.
605    let pooled_ss = ss_error + ss_interaction;
606    let pooled_df = df_error + df_interaction;
607    let ms_pooled = if pooled_df > 0.0 {
608        pooled_ss / pooled_df
609    } else {
610        ms_error
611    };
612
613    // Determine the denominator for Part and Operator F-tests
614    let (denom_ms, denom_df) = if interaction_pooled {
615        (ms_pooled, pooled_df)
616    } else {
617        (ms_interaction, df_interaction)
618    };
619
620    let (f_part, p_part) = if is_valid_denom(denom_ms) {
621        let f_val = ms_part / denom_ms;
622        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_part, denom_df);
623        (Some(f_val), Some(p_val))
624    } else {
625        (None, None)
626    };
627
628    let (f_operator, p_operator) = if is_valid_denom(denom_ms) {
629        let f_val = ms_operator / denom_ms;
630        let p_val = 1.0 - special::f_distribution_cdf(f_val, df_operator, denom_df);
631        (Some(f_val), Some(p_val))
632    } else {
633        (None, None)
634    };
635
636    // Build ANOVA table. The error/repeatability row must reflect the SAME model
637    // the F-tests and variance components use. When the interaction is pooled we
638    // report the pooled error term (df = df_error + df_interaction) and drop the
639    // Part×Operator row (it has been folded into error), so that (a) the row df's
640    // sum to df_total and (b) the returned f_value for Part/Operator is
641    // reproducible from the table's own MS values (f_part = ms_part / ms_pooled).
642    // Previously the flag said "pooled" while the table still showed the unpooled
643    // Error row, making the reported F un-reproducible from the displayed numbers.
644    let mut rows = vec![
645        AnovaRow {
646            source: "Part".to_owned(),
647            df: df_part,
648            ss: ss_part,
649            ms: ms_part,
650            f_value: f_part,
651            p_value: p_part,
652        },
653        AnovaRow {
654            source: "Operator".to_owned(),
655            df: df_operator,
656            ss: ss_operator,
657            ms: ms_operator,
658            f_value: f_operator,
659            p_value: p_operator,
660        },
661    ];
662    if !interaction_pooled {
663        rows.push(AnovaRow {
664            source: "Part×Operator".to_owned(),
665            df: df_interaction,
666            ss: ss_interaction,
667            ms: ms_interaction,
668            f_value: f_interaction,
669            p_value: p_interaction,
670        });
671    }
672    rows.push(AnovaRow {
673        source: "Repeatability".to_owned(),
674        df: if interaction_pooled {
675            pooled_df
676        } else {
677            df_error
678        },
679        ss: if interaction_pooled {
680            pooled_ss
681        } else {
682            ss_error
683        },
684        ms: if interaction_pooled {
685            ms_pooled
686        } else {
687            ms_error
688        },
689        f_value: None,
690        p_value: None,
691    });
692    rows.push(AnovaRow {
693        source: "Total".to_owned(),
694        df: df_total,
695        ss: ss_total,
696        ms: ss_total / df_total,
697        f_value: None,
698        p_value: None,
699    });
700    let anova_table = AnovaTable { rows };
701
702    // Variance components from expected mean squares. When the interaction is
703    // pooled, EVERY component — repeatability included — is derived from the
704    // single pooled error MS. Previously repeatability alone leaked the un-pooled
705    // raw MS_error while operator/part used the pooled MS, mixing two models in
706    // one result and inflating GRR / %GRR. `.max(0.0)` guards against a negative
707    // std-dev (NaN) when rounding pushes the (already ~0) MS slightly below zero.
708    let sigma2_repeatability = (if interaction_pooled {
709        ms_pooled
710    } else {
711        ms_error
712    })
713    .max(0.0);
714
715    let sigma2_interaction = if interaction_pooled {
716        0.0
717    } else {
718        let val = (ms_interaction - ms_error) / r as f64;
719        val.max(0.0)
720    };
721
722    let sigma2_operator = if interaction_pooled {
723        let val = (ms_operator - ms_pooled) / (p * r) as f64;
724        val.max(0.0)
725    } else {
726        let val = (ms_operator - ms_interaction) / (p * r) as f64;
727        val.max(0.0)
728    };
729
730    let sigma2_part = if interaction_pooled {
731        let val = (ms_part - ms_pooled) / (o * r) as f64;
732        val.max(0.0)
733    } else {
734        let val = (ms_part - ms_interaction) / (o * r) as f64;
735        val.max(0.0)
736    };
737
738    let sigma2_reproducibility = sigma2_operator + sigma2_interaction;
739    let sigma2_grr = sigma2_repeatability + sigma2_reproducibility;
740    let sigma2_total = sigma2_part + sigma2_grr;
741
742    let variance_components = VarianceComponents {
743        part: sigma2_part,
744        operator: sigma2_operator,
745        interaction: sigma2_interaction,
746        repeatability: sigma2_repeatability,
747        reproducibility: sigma2_reproducibility,
748        total: sigma2_total,
749    };
750
751    // Convert variance components to standard deviations (study variation)
752    let ev = sigma2_repeatability.sqrt();
753    let av = sigma2_reproducibility.sqrt();
754    let grr = sigma2_grr.sqrt();
755    let pv = sigma2_part.sqrt();
756    let tv = sigma2_total.sqrt();
757
758    let percent_grr = if tv > 1e-300 { grr / tv * 100.0 } else { 0.0 };
759
760    let percent_tolerance = input.tolerance.and_then(|tol| {
761        if tol > 1e-300 {
762            Some(grr / tol * 600.0)
763        } else {
764            None
765        }
766    });
767
768    let ndc = compute_ndc(pv, grr);
769    let status = grr_status(percent_grr);
770
771    Ok(GageRRAnovaResult {
772        anova_table,
773        variance_components,
774        ev,
775        av,
776        grr,
777        pv,
778        tv,
779        percent_grr,
780        percent_tolerance,
781        ndc,
782        status,
783        interaction_significant,
784        interaction_pooled,
785    })
786}
787
788// ---------------------------------------------------------------------------
789// Tests
790// ---------------------------------------------------------------------------
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    /// Generate a simple balanced dataset for testing.
797    /// 3 operators, 10 parts, 3 trials.
798    /// Based on AIAG MSA 4th Edition reference data.
799    fn aiag_reference_data() -> Vec<Vec<Vec<f64>>> {
800        // measurements[part][operator][trial]
801        vec![
802            // Part 1
803            vec![
804                vec![0.29, 0.41, 0.64],  // Operator A
805                vec![0.08, 0.25, 0.07],  // Operator B
806                vec![0.04, -0.11, 0.75], // Operator C
807            ],
808            // Part 2
809            vec![
810                vec![-0.56, -0.68, -0.58],
811                vec![-0.47, -1.22, -0.68],
812                vec![-0.49, -0.56, -0.49],
813            ],
814            // Part 3
815            vec![
816                vec![1.34, 1.17, 1.27],
817                vec![1.19, 0.94, 1.34],
818                vec![1.02, 0.82, 0.90],
819            ],
820            // Part 4
821            vec![
822                vec![0.47, 0.50, 0.64],
823                vec![0.01, 0.14, 0.43],
824                vec![0.12, 0.22, 0.31],
825            ],
826            // Part 5
827            vec![
828                vec![-0.80, -0.92, -0.84],
829                vec![-0.56, -1.20, -1.28],
830                vec![-0.44, -0.21, -0.17],
831            ],
832            // Part 6
833            vec![
834                vec![0.02, 0.16, -0.10],
835                vec![0.01, -0.10, 0.07],
836                vec![-0.14, -0.46, 0.18],
837            ],
838            // Part 7
839            vec![
840                vec![0.59, 0.75, 0.66],
841                vec![0.55, 0.36, 0.51],
842                vec![0.47, 0.63, 0.34],
843            ],
844            // Part 8
845            vec![
846                vec![-0.31, -0.20, 0.17],
847                vec![0.02, -0.09, 0.12],
848                vec![-0.24, 0.04, -0.19],
849            ],
850            // Part 9
851            vec![
852                vec![2.26, 1.99, 2.01],
853                vec![1.80, 2.12, 2.19],
854                vec![1.80, 1.71, 2.29],
855            ],
856            // Part 10
857            vec![
858                vec![-1.36, -1.14, -1.30],
859                vec![-1.34, -1.11, -1.42],
860                vec![-1.13, -1.13, -0.96],
861            ],
862        ]
863    }
864
865    // -----------------------------------------------------------------------
866    // X̄-R method tests
867    // -----------------------------------------------------------------------
868
869    /// The study's two charts come back with it, from the same R̄ the variance
870    /// components are built from.
871    ///
872    /// A consumer that draws them otherwise re-derives R̄ from the raw
873    /// measurements and copies the D3/D4/A2 tables out of a manual -- two
874    /// chances for its numbers to disagree with the ones beside them.
875    #[test]
876    fn the_result_carries_the_charts_the_method_plots() {
877        // 3 parts x 2 operators x 3 trials.
878        let measurements = vec![
879            vec![vec![10.0, 10.2, 10.1], vec![10.1, 10.0, 10.2]],
880            vec![vec![12.0, 12.1, 12.3], vec![12.2, 12.0, 12.1]],
881            vec![vec![14.0, 14.1, 14.2], vec![14.1, 14.3, 14.0]],
882        ];
883        let input = GageRRInput {
884            measurements: measurements.clone(),
885            tolerance: None,
886        };
887        let r = gage_rr_xbar_r(&input).expect("3 parts, 2 operators, 3 trials");
888
889        // R̄ computed independently from the cell ranges.
890        let cell_ranges: Vec<f64> = measurements
891            .iter()
892            .flat_map(|part| {
893                part.iter().map(|trials| {
894                    trials.iter().copied().fold(f64::NEG_INFINITY, f64::max)
895                        - trials.iter().copied().fold(f64::INFINITY, f64::min)
896                })
897            })
898            .collect();
899        let r_bar = cell_ranges.iter().sum::<f64>() / cell_ranges.len() as f64;
900        assert!(
901            (r.range_chart.center - r_bar).abs() < 1e-12,
902            "R̄ = {} vs {r_bar}",
903            r.range_chart.center
904        );
905
906        // n = 3 trials: AIAG D3 = 0, D4 = 2.575, A2 = 1.023.
907        assert!((r.range_chart.ucl - 2.575 * r_bar).abs() < 1e-12);
908        assert_eq!(r.range_chart.lcl, 0.0, "D3 is 0 at n = 3");
909
910        // X̿ over the cell averages, and the average chart around it.
911        let cell_means: Vec<f64> = measurements
912            .iter()
913            .flat_map(|part| {
914                part.iter()
915                    .map(|trials| trials.iter().sum::<f64>() / trials.len() as f64)
916            })
917            .collect();
918        let x_double_bar = cell_means.iter().sum::<f64>() / cell_means.len() as f64;
919        assert!((r.average_chart.center - x_double_bar).abs() < 1e-12);
920        assert!((r.average_chart.ucl - (x_double_bar + 1.023 * r_bar)).abs() < 1e-12);
921        assert!((r.average_chart.lcl - (x_double_bar - 1.023 * r_bar)).abs() < 1e-12);
922    }
923
924    /// The factor tables are the crate's, and the subgroup sizes this method
925    /// can reach are only 2 and 3 -- so a caller never meets a size the tables
926    /// do not cover, and never needs a fallback for one.
927    #[test]
928    fn the_method_only_ever_asks_for_factors_it_has() {
929        let two = vec![
930            vec![vec![10.0, 10.2], vec![10.1, 10.0]],
931            vec![vec![12.0, 12.1], vec![12.2, 12.0]],
932        ];
933        let r = gage_rr_xbar_r(&GageRRInput {
934            measurements: two,
935            tolerance: None,
936        })
937        .expect("2 trials");
938        // n = 2: D4 = 3.267, A2 = 1.880.
939        assert!((r.range_chart.ucl / r.range_chart.center - 3.267).abs() < 1e-12);
940
941        // Four trials is refused by the method itself, before any factor is
942        // looked up: there is no path to an uncovered subgroup size.
943        let four = vec![
944            vec![vec![10.0, 10.2, 10.1, 10.3], vec![10.1, 10.0, 10.2, 10.1]],
945            vec![vec![12.0, 12.1, 12.3, 12.2], vec![12.2, 12.0, 12.1, 12.3]],
946        ];
947        assert!(gage_rr_xbar_r(&GageRRInput {
948            measurements: four,
949            tolerance: None,
950        })
951        .is_err());
952    }
953
954    #[test]
955    fn xbar_r_basic_computation() {
956        let data = aiag_reference_data();
957        let input = GageRRInput {
958            measurements: data,
959            tolerance: Some(4.0),
960        };
961        let result = gage_rr_xbar_r(&input).expect("should compute");
962
963        // EV, AV, GRR should be positive
964        assert!(result.ev > 0.0, "EV should be positive: {}", result.ev);
965        assert!(result.grr > 0.0, "GRR should be positive: {}", result.grr);
966        assert!(result.pv > 0.0, "PV should be positive: {}", result.pv);
967        assert!(result.tv > 0.0, "TV should be positive: {}", result.tv);
968
969        // GRR = sqrt(EV² + AV²)
970        let expected_grr = (result.ev.powi(2) + result.av.powi(2)).sqrt();
971        assert!(
972            (result.grr - expected_grr).abs() < 1e-10,
973            "GRR identity failed: {} vs {}",
974            result.grr,
975            expected_grr
976        );
977
978        // TV = sqrt(GRR² + PV²)
979        let expected_tv = (result.grr.powi(2) + result.pv.powi(2)).sqrt();
980        assert!(
981            (result.tv - expected_tv).abs() < 1e-10,
982            "TV identity failed: {} vs {}",
983            result.tv,
984            expected_tv
985        );
986
987        // Percentages should sum close to 100% (via Pythagorean: %EV² + %AV² + %PV² ≈ 10000)
988        // Actually: %GRR² + %PV² = 10000 since TV is the hypotenuse
989        let pct_check = result.percent_grr.powi(2) + result.percent_pv.powi(2);
990        assert!(
991            (pct_check - 10000.0).abs() < 1.0,
992            "percentage identity: {} should be ~10000",
993            pct_check
994        );
995
996        // %Tolerance should be present when tolerance is provided
997        assert!(result.percent_tolerance.is_some());
998
999        // NDC should be at least 1
1000        assert!(result.ndc >= 1);
1001    }
1002
1003    #[test]
1004    fn xbar_r_ndc_minimum_one() {
1005        // Create data where GRR >> PV (bad measurement system)
1006        let data = vec![
1007            vec![vec![1.0, 5.0], vec![0.0, 6.0]],
1008            vec![vec![1.5, 4.5], vec![0.5, 5.5]],
1009        ];
1010        let input = GageRRInput {
1011            measurements: data,
1012            tolerance: None,
1013        };
1014        let result = gage_rr_xbar_r(&input).expect("should compute");
1015        assert!(result.ndc >= 1, "NDC should be at least 1");
1016    }
1017
1018    #[test]
1019    fn xbar_r_status_classification() {
1020        let data = aiag_reference_data();
1021        let input = GageRRInput {
1022            measurements: data,
1023            tolerance: None,
1024        };
1025        let result = gage_rr_xbar_r(&input).expect("should compute");
1026
1027        // The status should match the percent_grr
1028        match result.status {
1029            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1030            GrrStatus::Marginal => {
1031                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1032            }
1033            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1034        }
1035    }
1036
1037    #[test]
1038    fn xbar_r_rejects_invalid_dimensions() {
1039        // Only 1 part
1040        let data = vec![vec![vec![1.0, 2.0], vec![1.0, 2.0]]];
1041        let input = GageRRInput {
1042            measurements: data,
1043            tolerance: None,
1044        };
1045        assert!(gage_rr_xbar_r(&input).is_err());
1046
1047        // Only 1 operator
1048        let data = vec![vec![vec![1.0, 2.0]], vec![vec![3.0, 4.0]]];
1049        let input = GageRRInput {
1050            measurements: data,
1051            tolerance: None,
1052        };
1053        assert!(gage_rr_xbar_r(&input).is_err());
1054
1055        // Only 1 trial
1056        let data = vec![vec![vec![1.0], vec![2.0]], vec![vec![3.0], vec![4.0]]];
1057        let input = GageRRInput {
1058            measurements: data,
1059            tolerance: None,
1060        };
1061        assert!(gage_rr_xbar_r(&input).is_err());
1062    }
1063
1064    #[test]
1065    fn xbar_r_rejects_non_finite() {
1066        let data = vec![
1067            vec![vec![1.0, f64::NAN], vec![1.0, 2.0]],
1068            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
1069        ];
1070        let input = GageRRInput {
1071            measurements: data,
1072            tolerance: None,
1073        };
1074        assert!(gage_rr_xbar_r(&input).is_err());
1075    }
1076
1077    #[test]
1078    fn xbar_r_two_operators_two_trials() {
1079        // Minimal case: 2 parts, 2 operators, 2 trials
1080        let data = vec![
1081            vec![vec![10.0, 10.2], vec![10.1, 10.3]],
1082            vec![vec![20.0, 20.1], vec![19.9, 20.2]],
1083        ];
1084        let input = GageRRInput {
1085            measurements: data,
1086            tolerance: Some(2.0),
1087        };
1088        let result = gage_rr_xbar_r(&input).expect("should compute");
1089        assert!(result.ev > 0.0);
1090        assert!(result.pv > 0.0);
1091        assert!(result.percent_tolerance.is_some());
1092    }
1093
1094    // -----------------------------------------------------------------------
1095    // ANOVA method tests
1096    // -----------------------------------------------------------------------
1097
1098    #[test]
1099    fn anova_basic_computation() {
1100        let data = aiag_reference_data();
1101        let input = GageRRInput {
1102            measurements: data,
1103            tolerance: Some(4.0),
1104        };
1105        let result = gage_rr_anova(&input).expect("should compute");
1106
1107        // Variance components should be non-negative
1108        assert!(
1109            result.variance_components.repeatability >= 0.0,
1110            "σ²_repeatability should be non-negative"
1111        );
1112        assert!(
1113            result.variance_components.part >= 0.0,
1114            "σ²_part should be non-negative"
1115        );
1116        assert!(
1117            result.variance_components.operator >= 0.0,
1118            "σ²_operator should be non-negative"
1119        );
1120        assert!(
1121            result.variance_components.interaction >= 0.0,
1122            "σ²_interaction should be non-negative"
1123        );
1124
1125        // σ²_reproducibility = σ²_operator + σ²_interaction
1126        let expected_repro =
1127            result.variance_components.operator + result.variance_components.interaction;
1128        assert!(
1129            (result.variance_components.reproducibility - expected_repro).abs() < 1e-10,
1130            "σ²_reproducibility identity failed"
1131        );
1132
1133        // σ²_total = σ²_part + σ²_repeatability + σ²_reproducibility
1134        let expected_total = result.variance_components.part
1135            + result.variance_components.repeatability
1136            + result.variance_components.reproducibility;
1137        assert!(
1138            (result.variance_components.total - expected_total).abs() < 1e-10,
1139            "σ²_total identity failed"
1140        );
1141
1142        // ANOVA table should have 5 rows
1143        assert_eq!(result.anova_table.rows.len(), 5);
1144
1145        // Check source names
1146        assert_eq!(result.anova_table.rows[0].source, "Part");
1147        assert_eq!(result.anova_table.rows[1].source, "Operator");
1148        assert_eq!(result.anova_table.rows[2].source, "Part×Operator");
1149        assert_eq!(result.anova_table.rows[3].source, "Repeatability");
1150        assert_eq!(result.anova_table.rows[4].source, "Total");
1151
1152        // EV, GRR, PV, TV should be consistent with variance components
1153        assert!(
1154            (result.ev - result.variance_components.repeatability.sqrt()).abs() < 1e-10,
1155            "EV should be sqrt(σ²_repeatability)"
1156        );
1157        assert!(
1158            (result.pv - result.variance_components.part.sqrt()).abs() < 1e-10,
1159            "PV should be sqrt(σ²_part)"
1160        );
1161    }
1162
1163    #[test]
1164    fn anova_ss_decomposition() {
1165        let data = aiag_reference_data();
1166        let input = GageRRInput {
1167            measurements: data,
1168            tolerance: None,
1169        };
1170        let result = gage_rr_anova(&input).expect("should compute");
1171
1172        // SS_Part + SS_Operator + SS_Interaction + SS_Error = SS_Total
1173        let rows = &result.anova_table.rows;
1174        let ss_sum = rows[0].ss + rows[1].ss + rows[2].ss + rows[3].ss;
1175        let ss_total = rows[4].ss;
1176        assert!(
1177            (ss_sum - ss_total).abs() < 1e-8,
1178            "SS decomposition: {} + {} + {} + {} = {} vs total {}",
1179            rows[0].ss,
1180            rows[1].ss,
1181            rows[2].ss,
1182            rows[3].ss,
1183            ss_sum,
1184            ss_total
1185        );
1186
1187        // DF decomposition
1188        let df_sum = rows[0].df + rows[1].df + rows[2].df + rows[3].df;
1189        let df_total = rows[4].df;
1190        assert!(
1191            (df_sum - df_total).abs() < 1e-10,
1192            "DF decomposition failed: {} vs {}",
1193            df_sum,
1194            df_total
1195        );
1196    }
1197
1198    #[test]
1199    fn anova_interaction_pooling() {
1200        // Create data with negligible interaction (operators measure similarly)
1201        let data = vec![
1202            vec![
1203                vec![10.0, 10.1, 10.0],
1204                vec![10.0, 10.0, 10.1],
1205                vec![10.1, 10.0, 10.0],
1206            ],
1207            vec![
1208                vec![20.0, 20.1, 20.0],
1209                vec![20.0, 20.0, 20.1],
1210                vec![20.1, 20.0, 20.0],
1211            ],
1212            vec![
1213                vec![15.0, 15.1, 15.0],
1214                vec![15.0, 15.0, 15.1],
1215                vec![15.1, 15.0, 15.0],
1216            ],
1217        ];
1218        let input = GageRRInput {
1219            measurements: data,
1220            tolerance: None,
1221        };
1222        let result = gage_rr_anova(&input).expect("should compute");
1223
1224        // With no real interaction, it should likely be pooled
1225        if result.interaction_pooled {
1226            assert_eq!(result.variance_components.interaction, 0.0);
1227        }
1228    }
1229
1230    /// Regression (upstream-013): when the interaction is pooled, EVERY variance
1231    /// component — repeatability included — must be derived from the single pooled
1232    /// error MS, and the returned ANOVA table's Repeatability row must report that
1233    /// same pooled term so the Part/Operator F is reproducible from the table.
1234    ///
1235    /// The discriminating invariant: the denominator actually used for the Part
1236    /// F-test is `ms_part / f_part`. Before the fix σ²_repeatability leaked the raw
1237    /// (un-pooled) MS_error while f_part used the pooled MS, so they disagreed.
1238    #[test]
1239    fn anova_pooled_repeatability_uses_pooled_ms() {
1240        // 3 parts × 3 operators × 3 trials with a dominant part effect and
1241        // negligible operator/interaction — this pools (interaction p > 0.25).
1242        let data = vec![
1243            vec![
1244                vec![10.0, 10.1, 10.0],
1245                vec![10.0, 10.0, 10.1],
1246                vec![10.1, 10.0, 10.0],
1247            ],
1248            vec![
1249                vec![20.0, 20.1, 20.0],
1250                vec![20.0, 20.0, 20.1],
1251                vec![20.1, 20.0, 20.0],
1252            ],
1253            vec![
1254                vec![15.0, 15.1, 15.0],
1255                vec![15.0, 15.0, 15.1],
1256                vec![15.1, 15.0, 15.0],
1257            ],
1258        ];
1259        let input = GageRRInput {
1260            measurements: data,
1261            tolerance: None,
1262        };
1263        let result = gage_rr_anova(&input).expect("should compute");
1264        assert!(result.interaction_pooled, "this dataset should pool");
1265
1266        let rep_row = result
1267            .anova_table
1268            .rows
1269            .iter()
1270            .find(|r| r.source == "Repeatability")
1271            .expect("Repeatability row present");
1272        let part = result
1273            .anova_table
1274            .rows
1275            .iter()
1276            .find(|r| r.source == "Part")
1277            .expect("Part row");
1278
1279        // The pooled denominator actually used for f_part.
1280        let pooled_denom = part.ms / part.f_value.expect("Part F present");
1281
1282        // (a) σ²_repeatability equals that pooled denominator — NOT the raw MS.
1283        assert!(
1284            (result.variance_components.repeatability - pooled_denom).abs() < 1e-10,
1285            "σ²_repeatability ({}) must equal the pooled denominator ({pooled_denom})",
1286            result.variance_components.repeatability
1287        );
1288        // (b) The Repeatability row MS equals it too — F reproducible from table.
1289        assert!(
1290            (rep_row.ms - pooled_denom).abs() < 1e-10,
1291            "Repeatability row MS ({}) must equal the pooled denominator ({pooled_denom})",
1292            rep_row.ms
1293        );
1294        // (c) When pooled, no standalone interaction row; df's sum to df_total.
1295        assert!(
1296            !result
1297                .anova_table
1298                .rows
1299                .iter()
1300                .any(|r| r.source == "Part×Operator"),
1301            "pooled table must not carry a separate interaction row"
1302        );
1303        let df_sum: f64 = result
1304            .anova_table
1305            .rows
1306            .iter()
1307            .filter(|r| r.source != "Total")
1308            .map(|r| r.df)
1309            .sum();
1310        let df_total = result
1311            .anova_table
1312            .rows
1313            .iter()
1314            .find(|r| r.source == "Total")
1315            .expect("Total row")
1316            .df;
1317        assert!(
1318            (df_sum - df_total).abs() < 1e-9,
1319            "component df ({df_sum}) must sum to total df ({df_total})"
1320        );
1321    }
1322
1323    /// Regression (upstream-011): a degenerate denominator (every trial within
1324    /// each cell identical → repeatability MS ≈ floating-point noise) must yield
1325    /// F/p = None, not a spurious ~1e12 finite F, and must do so regardless of the
1326    /// sign of the rounding noise.
1327    #[test]
1328    fn anova_degenerate_denominator_returns_none() {
1329        // Take the AIAG reference layout (10×3×3) and collapse every trial within
1330        // a cell to that cell's first value, so the within-cell repeatability SS is
1331        // 0. At this scale/count the SS *decomposition* residual does not cancel to
1332        // exact 0 — it lands on surviving floating-point noise (ms_error ≈ 1.85e-16,
1333        // reproducing the report's ~1e-17 case). The OLD absolute `> 1e-300` guard
1334        // divides the (real, ~0.08) interaction MS by that noise, yielding a
1335        // spurious F ≈ 4.4e14 that renders as "very significant"; the relative
1336        // degeneracy floor must instead report F/p as None.
1337        let mut data = aiag_reference_data();
1338        for part in data.iter_mut() {
1339            for op in part.iter_mut() {
1340                let first = op[0];
1341                for trial in op.iter_mut() {
1342                    *trial = first;
1343                }
1344            }
1345        }
1346        let input = GageRRInput {
1347            measurements: data,
1348            tolerance: None,
1349        };
1350        let result = gage_rr_anova(&input).expect("should compute");
1351
1352        for row in &result.anova_table.rows {
1353            if let Some(f) = row.f_value {
1354                assert!(
1355                    f.is_finite() && f < 1e6,
1356                    "degenerate denominator must not produce a divergent F for {}: {}",
1357                    row.source,
1358                    f
1359                );
1360            }
1361        }
1362        // Repeatability is ~0, so GRR-derived std devs must stay finite (no NaN).
1363        assert!(
1364            result.ev.is_finite(),
1365            "EV must be finite, got {}",
1366            result.ev
1367        );
1368        assert!(
1369            result.percent_grr.is_finite(),
1370            "%GRR must be finite, got {}",
1371            result.percent_grr
1372        );
1373    }
1374
1375    #[test]
1376    fn anova_rejects_invalid_input() {
1377        let data = vec![vec![vec![1.0, 2.0]]];
1378        let input = GageRRInput {
1379            measurements: data,
1380            tolerance: None,
1381        };
1382        assert!(gage_rr_anova(&input).is_err());
1383    }
1384
1385    #[test]
1386    fn anova_status_matches_percent_grr() {
1387        let data = aiag_reference_data();
1388        let input = GageRRInput {
1389            measurements: data,
1390            tolerance: None,
1391        };
1392        let result = gage_rr_anova(&input).expect("should compute");
1393
1394        match result.status {
1395            GrrStatus::Acceptable => assert!(result.percent_grr <= 10.0),
1396            GrrStatus::Marginal => {
1397                assert!(result.percent_grr > 10.0 && result.percent_grr <= 30.0)
1398            }
1399            GrrStatus::Unacceptable => assert!(result.percent_grr > 30.0),
1400        }
1401    }
1402
1403    #[test]
1404    fn anova_p_values_bounded() {
1405        let data = aiag_reference_data();
1406        let input = GageRRInput {
1407            measurements: data,
1408            tolerance: None,
1409        };
1410        let result = gage_rr_anova(&input).expect("should compute");
1411
1412        for row in &result.anova_table.rows {
1413            if let Some(p) = row.p_value {
1414                assert!(
1415                    (0.0..=1.0).contains(&p),
1416                    "p-value for {} out of range: {}",
1417                    row.source,
1418                    p
1419                );
1420            }
1421            if let Some(f) = row.f_value {
1422                assert!(
1423                    f >= 0.0,
1424                    "F-value for {} should be non-negative: {}",
1425                    row.source,
1426                    f
1427                );
1428            }
1429        }
1430    }
1431
1432    // -----------------------------------------------------------------------
1433    // Consistency between methods
1434    // -----------------------------------------------------------------------
1435
1436    #[test]
1437    fn both_methods_detect_same_dominant_variation() {
1438        let data = aiag_reference_data();
1439        let input_xr = GageRRInput {
1440            measurements: data.clone(),
1441            tolerance: None,
1442        };
1443        let input_anova = GageRRInput {
1444            measurements: data,
1445            tolerance: None,
1446        };
1447
1448        let xr = gage_rr_xbar_r(&input_xr).expect("X̄-R should compute");
1449        let anova = gage_rr_anova(&input_anova).expect("ANOVA should compute");
1450
1451        // Both methods should agree on whether PV dominates over GRR
1452        let xr_pv_dominant = xr.pv > xr.grr;
1453        let anova_pv_dominant = anova.pv > anova.grr;
1454        assert_eq!(
1455            xr_pv_dominant, anova_pv_dominant,
1456            "X̄-R and ANOVA should agree on PV vs GRR dominance"
1457        );
1458    }
1459}