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}