Skip to main content

geop_core_math/
interval_newton.rs

1//! Verified interval Gauss-Newton (a.k.a. Gauss-Newton-Krawczyk) contraction
2//! for over-determined, zero-residual-at-the-root systems `F: R² → Rᴹ`.
3//!
4//! This is the linear-algebra core of the technique described in the
5//! `geop-core-geometry` curve-curve-intersection algorithm that uses it
6//! ([`geop_core_geometry::intersection::curve_curve_intersect_gnk`], not
7//! visible from here — this crate has no dependency on it): two unknowns
8//! `(s, t)`, `M` residual equations (`M = 3` for a plain curve-curve
9//! coincidence `C1(s) - C2(t) = 0` in R³, `M = 6` once a tangential root is
10//! deflated with the cross-product condition `C1'(s) × C2'(t) = 0`). The
11//! shape of the math is identical either way — only `M` changes — so it
12//! lives here once, generic over `M`, rather than being duplicated per
13//! deflation stage.
14//!
15//! # Why Gauss-Newton, not a square Krawczyk operator
16//!
17//! A genuine curve intersection is exactly-determined in a geometric sense
18//! (one point on each curve) but over-determined algebraically (2 unknowns,
19//! `M > 2` equations) — there is no square Jacobian to invert. The standard
20//! fix is the normal equations: approximate the Moore-Penrose pseudoinverse
21//! `Y ≈ (JᵀJ)⁻¹Jᵀ` (a `2×M` matrix) and run Krawczyk with `Y` in place of
22//! `J⁻¹`:
23//!
24//! ```text
25//! K(X) = x̂ − Y·F(x̂) + (I − Y·J(X))·(X − x̂)
26//! ```
27//!
28//! Because a real intersection has `F(x*) = 0` exactly (not merely
29//! minimized, as in a least-squares fit), Neumaier's convergence theory for
30//! verified interval Gauss-Newton on zero-residual over-determined systems
31//! applies directly: the contraction stays quadratic, exactly as for a
32//! square Krawczyk step.
33//!
34//! # What `X` and `x̂` are here
35//!
36//! Every enclosure in this codebase is carried by the scalar type itself
37//! (`ScalInF64`/`ScalInFPA64` *are* `[lo, hi]` intervals — see
38//! `scalars::Scalar`), so a "box" in `(s, t)` is just a `Vector<S, 2>`, one
39//! interval component per unknown. `x̂` is the (sharp) midpoint of that box
40//! — a single evaluation point standing in for "the current best guess",
41//! exactly as `Scalar::sharpen`/`Scalar::midpoint` are used elsewhere for
42//! the same purpose (see `Scalar::sharpen`'s own doc comment). `J`, `J(X)`
43//! are `Matrix<S, M, 2>` — `M` rows (one per residual component), 2 columns
44//! (`∂/∂s`, `∂/∂t`).
45
46use crate::{
47    geop_error::{GeopResult, WithContext},
48    matrix::{Matrix, solve_linear_system},
49    scalars::Scalar,
50    vector::Vector,
51};
52
53/// Outcome of one [`gauss_newton_krawczyk_step`] contraction.
54pub struct KrawczykStep<S: Scalar> {
55    /// `K(X) ∩ X` — still an honest enclosure of every root of `F` in the
56    /// incoming `X`, tightened whenever `K(X)` and `X` actually overlap.
57    /// Meaningless (equal to the incoming `X`, unchanged) when `empty` is
58    /// `true` — the whole point of that flag is to tell the two cases apart.
59    pub contracted: Vector<S, 2>,
60    /// `K(X) ⊆ X` held: the rigorous existence-*and*-uniqueness certificate
61    /// (Krawczyk's classical guarantee) — `X` contains exactly one root of
62    /// `F`, and it lies in `contracted`.
63    pub verified: bool,
64    /// `K(X)` and `X` did not even overlap on some component: a rigorous
65    /// *non*-existence proof — `X` contains no root of `F` at all. Distinct
66    /// from `!verified`, which merely means "inconclusive" (neither proved
67    /// nor disproved) — the caller must check this before trusting
68    /// `contracted`.
69    pub empty: bool,
70}
71
72/// One Gauss-Newton-Krawczyk contraction of the box `x_box = (s_box, t_box)`
73/// against `F(s, t) ∈ Rᴹ`.
74///
75/// - `x_hat`: the evaluation point (sharp midpoint of `x_box`).
76/// - `f_hat`: `F(x̂)`.
77/// - `jac_hat`: `J(x̂)`, `M×2` — evaluated at the sharp point `x̂` (this is
78///   what makes `JᵀJ` an ordinary — not interval — `2×2` system, solvable
79///   by plain Gaussian elimination).
80/// - `jac_box`: `J(X)`, `M×2` — the same Jacobian, but evaluated as an
81///   *enclosure* over the whole incoming box (interval arithmetic over
82///   `x_box`, not just the midpoint) — this is what lets the contraction
83///   step be rigorous rather than merely a numerical guess.
84///
85/// Errs only when `JᵀJ` (built from the *sharp* `jac_hat`) is singular —
86/// tangential/rank-deficient curves at `x̂`, Cauchy-Schwarz equality in
87/// `|C1'|²|C2'|² = (C1'·C2')²` — a fixed, structural signal to the caller
88/// that this system needs deflating (see the module doc comment) rather
89/// than a transient numerical hiccup to retry.
90pub fn gauss_newton_krawczyk_step<S: Scalar, const M: usize>(
91    x_hat: Vector<S, 2>,
92    f_hat: Vector<S, M>,
93    jac_hat: Matrix<S, M, 2>,
94    x_box: Vector<S, 2>,
95    jac_box: Matrix<S, M, 2>,
96) -> GeopResult<KrawczykStep<S>> {
97    let jac_hat_t = jac_hat.transpose();
98    let jtj = jac_hat_t.mul_mat(&jac_hat);
99
100    // Gauss-Newton step at x̂: solve (JᵀJ) δ = Jᵀ F(x̂), so x̂ − δ is the usual
101    // normal-equations correction (`Y·F(x̂)` with `Y = (JᵀJ)⁻¹Jᵀ`).
102    let jtf = jac_hat_t.mul_vec(&f_hat);
103    let delta = solve_linear_system(&jtj, &jtf).with_context(
104        "gauss_newton_krawczyk_step: JᵀJ singular at x̂ (rank-deficient Jacobian — likely tangential)",
105    )?;
106    let x_center = x_hat.sub(&delta);
107
108    // Y·J(X) = (JᵀJ)⁻¹ (Jᵀ J(X)), a 2×2 matrix, computed column-by-column
109    // (each column is itself a (JᵀJ)⁻¹·(interval vector) solve).
110    let jt_jbox = jac_hat_t.mul_mat(&jac_box);
111    let col0 = solve_linear_system(&jtj, &jt_jbox.col(0))?;
112    let col1 = solve_linear_system(&jtj, &jt_jbox.col(1))?;
113    let yjx = Matrix::from_columns([col0, col1]);
114
115    let imyjx = Matrix::<S, 2, 2>::identity().sub(&yjx);
116    let term2 = imyjx.mul_vec(&x_box.sub(&x_hat));
117
118    let k = x_center.add(&term2);
119
120    let overlaps = [k[0].could_be_equal(x_box[0]), k[1].could_be_equal(x_box[1])];
121    let empty = !overlaps[0] || !overlaps[1];
122    let verified = !empty && k[0].is_subset_of(x_box[0]) && k[1].is_subset_of(x_box[1]);
123    let contracted = if empty {
124        x_box
125    } else {
126        Vector::from_array([k[0].intersect(x_box[0]), k[1].intersect(x_box[1])])
127    };
128
129    Ok(KrawczykStep {
130        contracted,
131        verified,
132        empty,
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::for_all_scalars;
140
141    /// `F(s, t) = [s − 1, t − 2, 0]`: a trivial, exactly-determined-in-
142    /// disguise linear system (the third residual component is identically
143    /// zero everywhere, so it contributes to `JᵀJ` conditioning without
144    /// ever perturbing the root). Root at `(1, 2)`.
145    fn f_and_jac<S: Scalar>(s: S, t: S) -> (Vector<S, 3>, Matrix<S, 3, 2>) {
146        let f = Vector::from_array([s.sub(S::ONE), t.sub(S::TWO), S::ZERO]);
147        let jac = Matrix::from_rows([[S::ONE, S::ZERO], [S::ZERO, S::ONE], [S::ZERO, S::ZERO]]);
148        (f, jac)
149    }
150
151    fn check_contracts_and_verifies_box_containing_root<S: Scalar>() {
152        let x_box = Vector::from_array([
153            S::from_f64(0.0).union(S::from_f64(3.0)),
154            S::from_f64(1.0).union(S::from_f64(4.0)),
155        ]);
156        let x_hat = Vector::from_array([x_box[0].midpoint(), x_box[1].midpoint()]);
157        let (f_hat, jac_hat) = f_and_jac(x_hat[0], x_hat[1]);
158        // Linear system: J(X) is the same constant Jacobian everywhere.
159        let jac_box = jac_hat;
160
161        let step = gauss_newton_krawczyk_step(x_hat, f_hat, jac_hat, x_box, jac_box).unwrap();
162        assert!(!step.empty);
163        assert!(step.verified, "linear system should verify in one step");
164        assert!(step.contracted[0].could_be_equal(S::ONE));
165        assert!(step.contracted[1].could_be_equal(S::TWO));
166    }
167    #[test]
168    fn contracts_and_verifies_box_containing_root() {
169        for_all_scalars!(check_contracts_and_verifies_box_containing_root);
170    }
171
172    fn check_proves_empty_for_box_missing_root<S: Scalar>() {
173        // Root is at (1, 2); this box doesn't come near it.
174        let x_box = Vector::from_array([
175            S::from_f64(10.0).union(S::from_f64(11.0)),
176            S::from_f64(10.0).union(S::from_f64(11.0)),
177        ]);
178        let x_hat = Vector::from_array([x_box[0].midpoint(), x_box[1].midpoint()]);
179        let (f_hat, jac_hat) = f_and_jac(x_hat[0], x_hat[1]);
180        let jac_box = jac_hat;
181
182        let step = gauss_newton_krawczyk_step(x_hat, f_hat, jac_hat, x_box, jac_box).unwrap();
183        assert!(step.empty, "box far from the root should be proven empty");
184    }
185    #[test]
186    fn proves_empty_for_box_missing_root() {
187        for_all_scalars!(check_proves_empty_for_box_missing_root);
188    }
189
190    fn check_singular_jacobian_errs<S: Scalar>() {
191        // Both columns identical -> JᵀJ singular (rank-1), the tangential
192        // signal `gauss_newton_krawczyk_step` is documented to error on.
193        let x_hat = Vector::from_array([S::ZERO, S::ZERO]);
194        let x_box = Vector::from_array([S::ZERO.union(S::ONE), S::ZERO.union(S::ONE)]);
195        let f_hat = Vector::from_array([S::ONE, S::ONE, S::ONE]);
196        let jac_hat = Matrix::from_rows([[S::ONE, S::ONE], [S::ONE, S::ONE], [S::ONE, S::ONE]]);
197        let jac_box = jac_hat;
198
199        assert!(gauss_newton_krawczyk_step(x_hat, f_hat, jac_hat, x_box, jac_box).is_err());
200    }
201    #[test]
202    fn singular_jacobian_errs() {
203        for_all_scalars!(check_singular_jacobian_errs);
204    }
205}