Skip to main content

gam_inference/
effects.rs

1//! Matrix-level effect and contrast inference.
2//!
3//! This module owns the statistical kernel shared by difference-smooth,
4//! partial-dependence, and other linear-contrast reports.  Callers supply a
5//! coefficient vector, its covariance, and a contrast design.  Presentation
6//! layers only marshal the resulting typed report.
7
8use faer::Side;
9use gam_linalg::faer_ndarray::FaerEigh;
10use gam_linalg::matrix::symmetrize_in_place;
11use gam_math::probability::standard_normal_quantile;
12use gam_math::quantile::quantile_from_sorted;
13use gam_solve::estimate::UnifiedFitResult;
14use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
15use rand::{RngExt, SeedableRng, rngs::StdRng};
16use std::error::Error;
17use std::fmt;
18
19/// Default confidence level for effect bands.
20pub const DEFAULT_BAND_LEVEL: f64 = 0.95;
21/// Default Monte Carlo draw count for simultaneous bands.
22pub const DEFAULT_SIMULATIONS: usize = 10_000;
23/// Default deterministic random seed for simultaneous bands.
24pub const DEFAULT_SIMULATION_SEED: u64 = 12_345;
25
26/// The coefficient covariance definition used by an effect report.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum CovarianceSource {
29    /// Conditional Bayesian covariance with smoothing parameters fixed (`Vb`).
30    Conditional,
31    /// Bayesian covariance including smoothing-parameter uncertainty (`Vp`).
32    SmoothingCorrected,
33    /// Frequentist sandwich covariance (`Ve`).
34    Frequentist,
35}
36
37impl fmt::Display for CovarianceSource {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        formatter.write_str(match self {
40            Self::Conditional => "conditional",
41            Self::SmoothingCorrected => "smoothing-corrected",
42            Self::Frequentist => "frequentist",
43        })
44    }
45}
46
47/// A covariance borrowed from a fit together with its exact provenance.
48#[derive(Clone, Copy, Debug)]
49pub struct SelectedCovariance<'a> {
50    pub source: CovarianceSource,
51    pub matrix: ArrayView2<'a, f64>,
52}
53
54/// Select a coefficient covariance from a unified fit under an explicit policy.
55///
56/// The requested source is exact: absence is an error and no other covariance
57/// definition is substituted. [`SelectedCovariance::source`] records the
58/// resolved provenance.
59pub fn select_covariance<'a>(
60    fit: &'a UnifiedFitResult,
61    source: CovarianceSource,
62) -> Result<SelectedCovariance<'a>, EffectError> {
63    let matrix =
64        covariance_by_source(fit, source).ok_or(EffectError::MissingCovariance { source })?;
65
66    Ok(SelectedCovariance {
67        source,
68        matrix: matrix.view(),
69    })
70}
71
72fn covariance_by_source(fit: &UnifiedFitResult, source: CovarianceSource) -> Option<&Array2<f64>> {
73    match source {
74        CovarianceSource::Conditional => fit.beta_covariance(),
75        CovarianceSource::SmoothingCorrected => fit.beta_covariance_corrected(),
76        CovarianceSource::Frequentist => fit.beta_covariance_ve(),
77    }
78}
79
80/// Configuration for a pointwise normal-theory confidence band.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct PointwiseBandOptions {
83    pub level: f64,
84}
85
86impl Default for PointwiseBandOptions {
87    fn default() -> Self {
88        Self {
89            level: DEFAULT_BAND_LEVEL,
90        }
91    }
92}
93
94/// Configuration for a simulated simultaneous confidence band.
95#[derive(Clone, Copy, Debug, PartialEq)]
96pub struct SimultaneousBandOptions {
97    pub level: f64,
98    pub simulations: usize,
99    pub seed: u64,
100}
101
102impl Default for SimultaneousBandOptions {
103    fn default() -> Self {
104        Self {
105            level: DEFAULT_BAND_LEVEL,
106            simulations: DEFAULT_SIMULATIONS,
107            seed: DEFAULT_SIMULATION_SEED,
108        }
109    }
110}
111
112/// Confidence-band procedure for a linear effect curve.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub enum BandOptions {
115    /// Independent marginal normal intervals at each contrast row.
116    Pointwise(PointwiseBandOptions),
117    /// A common critical value calibrated from the supremum of the standardized
118    /// Gaussian effect curve.
119    Simultaneous(SimultaneousBandOptions),
120}
121
122impl Default for BandOptions {
123    fn default() -> Self {
124        Self::Pointwise(PointwiseBandOptions::default())
125    }
126}
127
128/// A matrix-level effect report, with one entry per contrast-design row.
129#[derive(Clone, Debug, PartialEq)]
130pub struct EffectReport {
131    pub center: Array1<f64>,
132    pub se: Array1<f64>,
133    pub lower: Array1<f64>,
134    pub upper: Array1<f64>,
135    pub critical: f64,
136}
137
138/// Typed failures from covariance selection or effect-band construction.
139#[derive(Clone, Debug, PartialEq)]
140pub enum EffectError {
141    MissingCovariance {
142        source: CovarianceSource,
143    },
144    EmptyCoefficients,
145    EmptyContrastDesign,
146    InvalidLevel {
147        level: f64,
148    },
149    InvalidSimulationCount,
150    CovarianceShape {
151        rows: usize,
152        columns: usize,
153        expected: usize,
154    },
155    ContrastShape {
156        columns: usize,
157        expected: usize,
158    },
159    NonFiniteInput {
160        input: &'static str,
161    },
162    NonSymmetricCovariance {
163        row: usize,
164        column: usize,
165        difference: f64,
166        tolerance: f64,
167    },
168    IndefiniteCovariance {
169        matrix: &'static str,
170        minimum_eigenvalue: f64,
171        tolerance: f64,
172    },
173    Eigendecomposition {
174        matrix: &'static str,
175        detail: String,
176    },
177    NormalQuantile {
178        detail: String,
179    },
180}
181
182impl fmt::Display for EffectError {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::MissingCovariance { source } => {
186                write!(formatter, "fit has no {source} coefficient covariance")
187            }
188            Self::EmptyCoefficients => {
189                formatter.write_str("beta must contain at least one coefficient")
190            }
191            Self::EmptyContrastDesign => {
192                formatter.write_str("contrast design must contain at least one row")
193            }
194            Self::InvalidLevel { level } => {
195                write!(
196                    formatter,
197                    "confidence level must be finite and in (0, 1), got {level}"
198                )
199            }
200            Self::InvalidSimulationCount => {
201                formatter.write_str("simultaneous-band simulation count must be positive")
202            }
203            Self::CovarianceShape {
204                rows,
205                columns,
206                expected,
207            } => write!(
208                formatter,
209                "covariance must have shape {expected}x{expected}, got {rows}x{columns}"
210            ),
211            Self::ContrastShape { columns, expected } => write!(
212                formatter,
213                "contrast design must have {expected} columns, got {columns}"
214            ),
215            Self::NonFiniteInput { input } => {
216                write!(formatter, "{input} contains a non-finite value")
217            }
218            Self::NonSymmetricCovariance {
219                row,
220                column,
221                difference,
222                tolerance,
223            } => write!(
224                formatter,
225                "covariance is not symmetric at ({row}, {column}): absolute difference {difference:e} exceeds tolerance {tolerance:e}"
226            ),
227            Self::IndefiniteCovariance {
228                matrix,
229                minimum_eigenvalue,
230                tolerance,
231            } => write!(
232                formatter,
233                "{matrix} is materially indefinite: minimum eigenvalue {minimum_eigenvalue:e} is below -{tolerance:e}"
234            ),
235            Self::Eigendecomposition { matrix, detail } => {
236                write!(formatter, "{matrix} eigendecomposition failed: {detail}")
237            }
238            Self::NormalQuantile { detail } => {
239                write!(
240                    formatter,
241                    "normal critical-value calculation failed: {detail}"
242                )
243            }
244        }
245    }
246}
247
248impl Error for EffectError {}
249
250
251/// Compute centers, standard errors, bounds, and the common critical value for
252/// a linear effect curve.
253///
254/// For `m x p` contrast design `C`, coefficient vector `beta`, and covariance
255/// `V`, the report center is `C beta` and its covariance is `C V C'`.
256/// Pointwise bands compute only the diagonal of that covariance, with O(p)
257/// working memory. Simultaneous bands calibrate `max_i |Z_i|` for the
258/// standardized Gaussian curve and factor whichever covariance space is
259/// smaller: coefficient space when `p <= m`, projected curve space otherwise.
260/// Positive-semidefinite singular matrices are supported without a ridge.
261pub fn effect_report(
262    beta: ArrayView1<'_, f64>,
263    covariance: ArrayView2<'_, f64>,
264    contrast_design: ArrayView2<'_, f64>,
265    options: BandOptions,
266) -> Result<EffectReport, EffectError> {
267    validate_inputs(beta, covariance, contrast_design, options)?;
268
269    let covariance = validated_symmetric_matrix(covariance)?;
270    let center = contrast_design.dot(&beta);
271    let (se, critical) = match options {
272        BandOptions::Pointwise(pointwise) => {
273            let se = pointwise_standard_errors(contrast_design, covariance.view())?;
274            let critical = standard_normal_quantile(0.5 * (1.0 + pointwise.level))
275                .map_err(|detail| EffectError::NormalQuantile { detail })?;
276            (se, critical)
277        }
278        BandOptions::Simultaneous(simultaneous) => {
279            let curve_factor = simultaneous_curve_factor(contrast_design, covariance.view())?;
280            let se = factor_standard_errors(&curve_factor);
281            let critical = simultaneous_critical(
282                &curve_factor,
283                se.view(),
284                simultaneous.level,
285                simultaneous.simulations,
286                simultaneous.seed,
287            );
288            (se, critical)
289        }
290    };
291
292    let half_width = se.mapv(|value| critical * value);
293    let lower = &center - &half_width;
294    let upper = &center + &half_width;
295    Ok(EffectReport {
296        center,
297        se,
298        lower,
299        upper,
300        critical,
301    })
302}
303
304fn pointwise_standard_errors(
305    contrast_design: ArrayView2<'_, f64>,
306    covariance: ArrayView2<'_, f64>,
307) -> Result<Array1<f64>, EffectError> {
308    let p = covariance.nrows();
309    let mut se = Array1::<f64>::zeros(contrast_design.nrows());
310    let mut product = vec![0.0_f64; p];
311    for (row_index, row) in contrast_design.rows().into_iter().enumerate() {
312        product.fill(0.0);
313        for covariance_row in 0..p {
314            for column in 0..p {
315                product[covariance_row] += covariance[[covariance_row, column]] * row[column];
316            }
317        }
318        let variance = row
319            .iter()
320            .zip(&product)
321            .map(|(&loading, &projected)| loading * projected)
322            .sum::<f64>();
323        let scale = row
324            .iter()
325            .zip(&product)
326            .map(|(&loading, &projected)| (loading * projected).abs())
327            .sum::<f64>();
328        let tolerance = roundoff_tolerance(scale, p);
329        if variance < -tolerance {
330            return Err(EffectError::IndefiniteCovariance {
331                matrix: "projected curve covariance",
332                minimum_eigenvalue: variance,
333                tolerance,
334            });
335        }
336        se[row_index] = variance.max(0.0).sqrt();
337    }
338    Ok(se)
339}
340
341fn simultaneous_curve_factor(
342    contrast_design: ArrayView2<'_, f64>,
343    covariance: ArrayView2<'_, f64>,
344) -> Result<Array2<f64>, EffectError> {
345    if covariance.nrows() <= contrast_design.nrows() {
346        let coefficient_eigen = psd_eigendecomposition(covariance, "coefficient covariance")?;
347        let coefficient_factor = covariance_factor(&coefficient_eigen);
348        let factor = contrast_design.dot(&coefficient_factor);
349        if factor.iter().any(|value| !value.is_finite()) {
350            return Err(EffectError::NonFiniteInput {
351                input: "projected curve factor",
352            });
353        }
354        return Ok(factor);
355    }
356
357    let mut curve_covariance = contrast_design.dot(&covariance).dot(&contrast_design.t());
358    if curve_covariance.iter().any(|value| !value.is_finite()) {
359        return Err(EffectError::NonFiniteInput {
360            input: "projected curve covariance",
361        });
362    }
363    symmetrize_in_place(&mut curve_covariance);
364    let curve_eigen =
365        psd_eigendecomposition(curve_covariance.view(), "projected curve covariance")?;
366    Ok(covariance_factor(&curve_eigen))
367}
368
369fn validate_inputs(
370    beta: ArrayView1<'_, f64>,
371    covariance: ArrayView2<'_, f64>,
372    contrast_design: ArrayView2<'_, f64>,
373    options: BandOptions,
374) -> Result<(), EffectError> {
375    if beta.is_empty() {
376        return Err(EffectError::EmptyCoefficients);
377    }
378    if contrast_design.nrows() == 0 {
379        return Err(EffectError::EmptyContrastDesign);
380    }
381    let p = beta.len();
382    if covariance.dim() != (p, p) {
383        return Err(EffectError::CovarianceShape {
384            rows: covariance.nrows(),
385            columns: covariance.ncols(),
386            expected: p,
387        });
388    }
389    if contrast_design.ncols() != p {
390        return Err(EffectError::ContrastShape {
391            columns: contrast_design.ncols(),
392            expected: p,
393        });
394    }
395    if beta.iter().any(|value| !value.is_finite()) {
396        return Err(EffectError::NonFiniteInput { input: "beta" });
397    }
398    if covariance.iter().any(|value| !value.is_finite()) {
399        return Err(EffectError::NonFiniteInput {
400            input: "covariance",
401        });
402    }
403    if contrast_design.iter().any(|value| !value.is_finite()) {
404        return Err(EffectError::NonFiniteInput {
405            input: "contrast design",
406        });
407    }
408
409    let (level, simulations) = match options {
410        BandOptions::Pointwise(pointwise) => (pointwise.level, None),
411        BandOptions::Simultaneous(simultaneous) => {
412            (simultaneous.level, Some(simultaneous.simulations))
413        }
414    };
415    if !level.is_finite() || !(0.0..1.0).contains(&level) || level == 0.0 {
416        return Err(EffectError::InvalidLevel { level });
417    }
418    if simulations == Some(0) {
419        return Err(EffectError::InvalidSimulationCount);
420    }
421    Ok(())
422}
423
424struct PsdEigen {
425    vectors: Array2<f64>,
426    active: Vec<(usize, f64)>,
427}
428
429fn validated_symmetric_matrix(matrix: ArrayView2<'_, f64>) -> Result<Array2<f64>, EffectError> {
430    let n = matrix.nrows();
431    let scale = matrix
432        .iter()
433        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
434    let symmetry_tolerance = roundoff_tolerance(scale, n);
435    let mut symmetric = matrix.to_owned();
436    for row in 0..n {
437        for column in 0..row {
438            let difference = (matrix[[row, column]] - matrix[[column, row]]).abs();
439            if difference > symmetry_tolerance {
440                return Err(EffectError::NonSymmetricCovariance {
441                    row,
442                    column,
443                    difference,
444                    tolerance: symmetry_tolerance,
445                });
446            }
447            let average = 0.5 * (matrix[[row, column]] + matrix[[column, row]]);
448            symmetric[[row, column]] = average;
449            symmetric[[column, row]] = average;
450        }
451    }
452    Ok(symmetric)
453}
454
455fn psd_eigendecomposition(
456    matrix: ArrayView2<'_, f64>,
457    label: &'static str,
458) -> Result<PsdEigen, EffectError> {
459    let n = matrix.nrows();
460    let symmetric = validated_symmetric_matrix(matrix)?;
461
462    let (values, vectors) =
463        symmetric
464            .eigh(Side::Lower)
465            .map_err(|error| EffectError::Eigendecomposition {
466                matrix: label,
467                detail: error.to_string(),
468            })?;
469    let spectral_scale = values
470        .iter()
471        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
472    let tolerance = roundoff_tolerance(spectral_scale, n);
473    let minimum_eigenvalue = values.iter().copied().fold(f64::INFINITY, f64::min);
474    if minimum_eigenvalue < -tolerance {
475        return Err(EffectError::IndefiniteCovariance {
476            matrix: label,
477            minimum_eigenvalue,
478            tolerance,
479        });
480    }
481    let active = values
482        .iter()
483        .copied()
484        .enumerate()
485        .filter_map(|(column, value)| (value > tolerance).then(|| (column, value.sqrt())))
486        .collect();
487    Ok(PsdEigen { vectors, active })
488}
489
490fn roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
491    scale * f64::EPSILON * dimension.max(1) as f64
492}
493
494fn covariance_factor(eigen: &PsdEigen) -> Array2<f64> {
495    let mut factor = Array2::zeros((eigen.vectors.nrows(), eigen.active.len()));
496    for (active_column, &(eigen_column, eigenvalue_sqrt)) in eigen.active.iter().enumerate() {
497        for row in 0..eigen.vectors.nrows() {
498            factor[[row, active_column]] = eigen.vectors[[row, eigen_column]] * eigenvalue_sqrt;
499        }
500    }
501    factor
502}
503
504fn factor_standard_errors(curve_factor: &Array2<f64>) -> Array1<f64> {
505    let variances = Array1::from_iter(
506        curve_factor
507            .rows()
508            .into_iter()
509            .map(|row| row.iter().map(|value| value * value).sum::<f64>()),
510    );
511    let variance_scale = variances.iter().copied().fold(0.0_f64, f64::max);
512    let variance_tolerance = roundoff_tolerance(variance_scale, curve_factor.ncols());
513    variances.mapv(|variance| {
514        if variance > variance_tolerance {
515            variance.sqrt()
516        } else {
517            0.0
518        }
519    })
520}
521
522fn simultaneous_critical(
523    curve_factor: &Array2<f64>,
524    se: ArrayView1<'_, f64>,
525    level: f64,
526    simulations: usize,
527    seed: u64,
528) -> f64 {
529    if curve_factor.ncols() == 0 {
530        return 0.0;
531    }
532
533    let mut standardized_factor = curve_factor.clone();
534    for row in 0..standardized_factor.nrows() {
535        if se[row] == 0.0 {
536            standardized_factor.row_mut(row).fill(0.0);
537        } else {
538            standardized_factor
539                .row_mut(row)
540                .mapv_inplace(|value| value / se[row]);
541        }
542    }
543
544    let mut rng = StdRng::seed_from_u64(seed);
545    let mut normal_coordinates = vec![0.0; curve_factor.ncols()];
546    let mut maxima = Vec::with_capacity(simulations);
547    for _ in 0..simulations {
548        fill_standard_normals(&mut rng, &mut normal_coordinates);
549        let maximum = standardized_factor
550            .rows()
551            .into_iter()
552            .map(|row| {
553                row.iter()
554                    .zip(&normal_coordinates)
555                    .map(|(&loading, &coordinate)| loading * coordinate)
556                    .sum::<f64>()
557                    .abs()
558            })
559            .fold(0.0_f64, f64::max);
560        maxima.push(maximum);
561    }
562    maxima.sort_by(f64::total_cmp);
563    quantile_from_sorted(&maxima, level)
564}
565
566fn fill_standard_normals(rng: &mut StdRng, output: &mut [f64]) {
567    for pair in output.chunks_mut(2) {
568        let uniform_radius = rng.random::<f64>().max(f64::MIN_POSITIVE);
569        let uniform_angle = rng.random::<f64>();
570        let radius = (-2.0 * uniform_radius.ln()).sqrt();
571        let angle = std::f64::consts::TAU * uniform_angle;
572        pair[0] = radius * angle.cos();
573        if pair.len() == 2 {
574            pair[1] = radius * angle.sin();
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use approx::assert_abs_diff_eq;
583    use ndarray::array;
584
585    #[test]
586    fn closed_form_centers_and_standard_errors() {
587        let beta = array![2.0, -1.0];
588        let covariance = array![[4.0, 1.0], [1.0, 9.0]];
589        let contrast = array![[1.0, 0.0], [1.0, 2.0]];
590
591        let report = effect_report(
592            beta.view(),
593            covariance.view(),
594            contrast.view(),
595            BandOptions::default(),
596        )
597        .unwrap();
598
599        assert_abs_diff_eq!(report.center[0], 2.0, epsilon = 1e-14);
600        assert_abs_diff_eq!(report.center[1], 0.0, epsilon = 1e-14);
601        assert_abs_diff_eq!(report.se[0], 2.0, epsilon = 1e-14);
602        assert_abs_diff_eq!(report.se[1], 44.0_f64.sqrt(), epsilon = 1e-13);
603    }
604
605    #[test]
606    fn singular_psd_simulation_is_reproducible() {
607        let beta = array![0.5, -0.5];
608        let covariance = array![[1.0, 1.0], [1.0, 1.0]];
609        let contrast = array![[1.0, 0.0], [0.0, 1.0], [1.0, -1.0]];
610        let options = BandOptions::Simultaneous(SimultaneousBandOptions {
611            simulations: 2_000,
612            ..SimultaneousBandOptions::default()
613        });
614
615        let first =
616            effect_report(beta.view(), covariance.view(), contrast.view(), options).unwrap();
617        let second =
618            effect_report(beta.view(), covariance.view(), contrast.view(), options).unwrap();
619
620        assert_eq!(first, second);
621        assert_abs_diff_eq!(first.se[0], 1.0, epsilon = 1e-14);
622        assert_abs_diff_eq!(first.se[1], 1.0, epsilon = 1e-14);
623        assert_eq!(first.se[2], 0.0);
624        assert!(first.critical.is_finite());
625    }
626
627    #[test]
628    fn materially_indefinite_covariance_is_rejected() {
629        let error = effect_report(
630            array![0.0, 0.0].view(),
631            array![[1.0, 0.0], [0.0, -0.1]].view(),
632            array![[0.0, 1.0]].view(),
633            BandOptions::default(),
634        )
635        .unwrap_err();
636
637        assert!(matches!(
638            error,
639            EffectError::IndefiniteCovariance {
640                matrix: "projected curve covariance",
641                ..
642            }
643        ));
644    }
645
646    #[test]
647    fn pointwise_band_uses_two_sided_normal_critical_value() {
648        let report = effect_report(
649            array![0.0].view(),
650            array![[1.0]].view(),
651            array![[1.0]].view(),
652            BandOptions::default(),
653        )
654        .unwrap();
655
656        // True two-sided 95% normal critical value z_{0.975} = 1.9599639845400545.
657        // The former golden 1.959963986120195 was the RAW Acklam-approximation
658        // output (absolute error ~1.6e-9); the quantile now carries a two-round
659        // Halley refinement against erfc and returns the true value, so the
660        // golden pins the mathematically correct quantile at a tightened
661        // tolerance.
662        assert_abs_diff_eq!(report.critical, 1.959_963_984_540_054, epsilon = 1e-9);
663    }
664
665    #[test]
666    fn simultaneous_critical_is_beta_and_contrast_sign_invariant() {
667        let covariance = array![[2.0, 0.25], [0.25, 1.0]];
668        let contrast = array![[1.0, 0.5], [-0.25, 1.0]];
669        let options = BandOptions::Simultaneous(SimultaneousBandOptions {
670            simulations: 1_000,
671            ..SimultaneousBandOptions::default()
672        });
673        let first = effect_report(
674            array![1.0, -2.0].view(),
675            covariance.view(),
676            contrast.view(),
677            options,
678        )
679        .unwrap();
680        let shifted = effect_report(
681            array![8.0, 3.0].view(),
682            covariance.view(),
683            contrast.view(),
684            options,
685        )
686        .unwrap();
687        let signed = effect_report(
688            array![1.0, -2.0].view(),
689            covariance.view(),
690            (-&contrast).view(),
691            options,
692        )
693        .unwrap();
694
695        assert_eq!(first.critical, shifted.critical);
696        assert_eq!(first.critical, signed.critical);
697        assert_eq!(first.se, shifted.se);
698        assert_eq!(first.se, signed.se);
699        for row in 0..contrast.nrows() {
700            assert_abs_diff_eq!(signed.center[row], -first.center[row], epsilon = 1e-14);
701            assert_abs_diff_eq!(signed.lower[row], -first.upper[row], epsilon = 1e-14);
702            assert_abs_diff_eq!(signed.upper[row], -first.lower[row], epsilon = 1e-14);
703        }
704    }
705}