Skip to main content

gam_terms/
geometry.rs

1use ndarray::parallel::prelude::*;
2use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut2, Axis};
3use serde::{Deserialize, Serialize};
4
5/// Slack tolerance for the half-space membership test `aᵀx ≤ b`. Points within
6/// this distance of a facet (normals are unit-length, so slack is Euclidean
7/// distance) are treated as on-boundary / inside, absorbing round-off in the
8/// dot product.
9const MEMBERSHIP_SLACK_TOL: f64 = 1e-12;
10
11/// Maximum number of Dykstra cycles over all facet constraints before the
12/// projection returns its last (near-feasible) iterate.
13const DYKSTRA_MAX_CYCLES: usize = 200;
14
15/// Convergence tolerance on the per-cycle L¹ iterate change for Dykstra
16/// projection.
17const DYKSTRA_TOL: f64 = 1e-8;
18
19/// Floor on the squared facet-normal magnitude used when normalising the
20/// projection step, guarding against a degenerate (near-zero) normal.
21const FACET_NORM2_FLOOR: f64 = 1e-16;
22
23/// A peeled convex hull represented as an intersection of half-spaces a^T x <= b.
24/// Facet normals `a` are unit-length direction vectors used to generate supporting halfspaces
25/// after iterative peeling. This is a robust, outlier-insensitive boundary representation.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PeeledHull {
28    /// Each facet as (normal, offset). For any in-domain x: a^T x <= b for all facets.
29    pub facets: Vec<(Array1<f64>, f64)>,
30    /// Dimensionality of the space (number of predictors)
31    pub dim: usize,
32}
33
34impl PeeledHull {
35    /// Projects points in place onto the hull if needed. Returns the count of projected points.
36    pub(crate) fn project_in_place(&self, mut points: ArrayViewMut2<'_, f64>) -> usize {
37        if points.nrows() == 0 {
38            return 0;
39        }
40
41        points
42            .axis_iter_mut(Axis(0))
43            .into_par_iter()
44            .map(|mut row| {
45                let view = row.view();
46                if self.is_inside(view) {
47                    0usize
48                } else {
49                    let proj = self.project_point(view);
50                    row.assign(&proj);
51                    1usize
52                }
53            })
54            .sum()
55    }
56
57    /// Projects points onto the hull if needed. Returns corrected points and count projected.
58    pub fn project_if_needed(&self, points: ArrayView2<f64>) -> (Array2<f64>, usize) {
59        let d = points.ncols();
60        assert_eq!(
61            d, self.dim,
62            "Dimension mismatch in PeeledHull::project_if_needed"
63        );
64
65        let mut out = points.to_owned();
66        let projected = self.project_in_place(out.view_mut());
67        (out, projected)
68    }
69
70    /// Fast in-domain test: a_i^T x <= b_i for all facets.
71    pub fn is_inside(&self, x: ArrayView1<f64>) -> bool {
72        for (a, b) in &self.facets {
73            let s = a.dot(&x);
74            if s > *b + MEMBERSHIP_SLACK_TOL {
75                return false;
76            }
77        }
78        true
79    }
80
81    /// Compute the signed distance from a point to the peeled hull boundary.
82    ///
83    /// Convention:
84    /// - Negative inside: exact distance to the nearest boundary facet.
85    /// - Positive outside: exact Euclidean distance to the polytope (via projection).
86    /// - Zero on the boundary.
87    pub fn signed_distance(&self, x: ArrayView1<f64>) -> f64 {
88        if self.is_inside(x) {
89            // Inside: distance to boundary is the minimum slack over facets.
90            // Facet normals are constructed unit-length, so slack equals Euclidean distance.
91            let mut min_slack = f64::INFINITY;
92            for (a, b) in &self.facets {
93                let slack = *b - a.dot(&x);
94                if slack < min_slack {
95                    min_slack = slack;
96                }
97            }
98            // Numerical safety: never return a negative slack for inside points
99            -min_slack.max(0.0)
100        } else {
101            // Outside: use Dykstra projection onto the feasible polytope
102            let z = self.project_point(x);
103            let diff = &z - &x;
104
105            diff.mapv(|v| v * v).sum().sqrt()
106        }
107    }
108
109    /// Project a single point onto the polytope using Dykstra's algorithm for halfspaces.
110    fn project_point(&self, y: ArrayView1<f64>) -> Array1<f64> {
111        let d = self.dim;
112        let m = self.facets.len();
113        let max_cycles = DYKSTRA_MAX_CYCLES;
114        let tol = DYKSTRA_TOL;
115
116        // Dykstra variables
117        let mut x = y.to_owned();
118        let mut p_corr: Vec<Array1<f64>> = (0..m).map(|_| Array1::zeros(d)).collect();
119
120        for _ in 0..max_cycles {
121            let x_prev = x.clone();
122            for (i, (a, b)) in self.facets.iter().enumerate() {
123                // y_i = x + p_i
124                let mut y_i = x.clone();
125                y_i += &p_corr[i];
126
127                // Project y_i onto halfspace H_i: a^T z <= b
128                let a_tb = a.dot(&y_i) - *b;
129                let a_norm2 = a.dot(a).max(FACET_NORM2_FLOOR);
130                if a_tb > 0.0 {
131                    // Outside; move along normal inward
132                    let alpha = a_tb / a_norm2;
133                    let z = &y_i - &(a * alpha);
134                    // Update correction and current x
135                    p_corr[i] = &y_i - &z;
136                    x = z;
137                } else {
138                    // Inside; projection is itself
139                    p_corr[i].fill(0.0);
140                    x = y_i;
141                }
142            }
143
144            // Convergence check
145            let diff = (&x - &x_prev).mapv(|v| v.abs()).sum();
146            if diff < tol {
147                return x;
148            }
149        }
150
151        // If not converged, return last iterate (still feasible or near-feasible)
152        x
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use ndarray::array;
160
161    fn unit_square_hull() -> PeeledHull {
162        // 0 <= x <= 1, 0 <= y <= 1
163        PeeledHull {
164            facets: vec![
165                (array![1.0, 0.0], 1.0),  // x <= 1
166                (array![-1.0, 0.0], 0.0), // -x <= 0 -> x >= 0
167                (array![0.0, 1.0], 1.0),  // y <= 1
168                (array![0.0, -1.0], 0.0), // -y <= 0 -> y >= 0
169            ],
170            dim: 2,
171        }
172    }
173
174    #[test]
175    fn test_is_inside_unit_square() {
176        let h = unit_square_hull();
177        assert!(h.is_inside(array![0.5, 0.5].view())); // inside
178        assert!(h.is_inside(array![1.0, 0.5].view())); // on edge
179        assert!(h.is_inside(array![0.0, 0.0].view())); // corner
180        assert!(!h.is_inside(array![1.1, 0.5].view())); // outside +x
181        assert!(!h.is_inside(array![-0.1, 0.5].view())); // outside -x
182        assert!(!h.is_inside(array![0.5, 1.1].view())); // outside +y
183        assert!(!h.is_inside(array![0.5, -0.1].view())); // outside -y
184    }
185
186    #[test]
187    fn test_project_point_unit_square() {
188        let h = unit_square_hull();
189        // Inside point stays unchanged
190        let p_in = array![0.5, 0.5];
191        let proj_in = h.project_point(p_in.view());
192        assert!((&proj_in - &p_in).mapv(|v| v.abs()).sum() < 1e-12);
193
194        // Project onto a face
195        let p_face = array![1.5, 0.5];
196        let proj_face = h.project_point(p_face.view());
197        assert!((&proj_face - &array![1.0, 0.5]).mapv(|v| v.abs()).sum() < 1e-8);
198
199        // Project onto a corner
200        let p_corner = array![1.5, -0.5];
201        let proj_corner = h.project_point(p_corner.view());
202        assert!((&proj_corner - &array![1.0, 0.0]).mapv(|v| v.abs()).sum() < 1e-6);
203    }
204
205    #[test]
206    fn test_signed_distance_unit_square() {
207        let h = unit_square_hull();
208        // Inside center: nearest boundary at distance 0.5 (negative inside)
209        let d_center = h.signed_distance(array![0.5, 0.5].view());
210        assert!((d_center + 0.5).abs() < 1e-12);
211
212        // Inside near left edge: distance ~0.2 (negative)
213        let d_inside = h.signed_distance(array![0.2, 0.8].view());
214        assert!((d_inside + 0.2).abs() < 1e-12);
215
216        // On edge: exactly zero (treat as on/inside)
217        let d_edge = h.signed_distance(array![1.0, 0.3].view());
218        assert!(d_edge.abs() < 1e-12);
219
220        // Outside along +x: distance 0.5
221        let d_out_x = h.signed_distance(array![1.5, 0.5].view());
222        assert!((d_out_x - 0.5).abs() < 1e-8);
223
224        // Outside towards corner: distance sqrt(0.5^2 + 0.5^2)
225        let d_out_corner = h.signed_distance(array![1.5, -0.5].view());
226        assert!((d_out_corner - (0.5f64.hypot(0.5))).abs() < 1e-6);
227    }
228}