Skip to main content

murk_obs/
geometry.rs

1//! Grid geometry extraction for interior/boundary dispatch.
2//!
3//! [`GridGeometry`] captures the dimensional structure of grid-based
4//! spaces (Square4, Square8, Hex2D) via `downcast_ref` (Decision M).
5//! This enables O(1) interior detection and branchless gather for
6//! agent-centered foveation.
7
8use murk_space::Space;
9
10/// Grid connectivity type, determines graph-distance metric.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum GridConnectivity {
13    /// 4-connected (Square4): graph distance = Manhattan |dr| + |dc|.
14    FourWay,
15    /// 8-connected (Square8): graph distance = Chebyshev max(|dr|, |dc|).
16    EightWay,
17    /// 6-connected hex (Hex2D, axial coords): graph distance = max(|dq|, |dr|, |dq+dr|).
18    Hex,
19}
20
21/// Extracted grid geometry for fast interior/boundary dispatch.
22///
23/// If the space is a known grid type, we extract its dimensions and
24/// strides per coordinate index. This enables:
25/// - O(1) `is_interior` check (no BFS, no bounds-check per cell)
26/// - Direct stride arithmetic for the fast gather path
27///
28/// `coord_dims\[i\]` is the valid range for `coord\[i\]` (0..coord_dims\[i\]).
29/// `coord_strides\[i\]` is the stride for `coord\[i\]` in canonical rank.
30#[derive(Debug, Clone)]
31pub struct GridGeometry {
32    /// Valid range per coordinate index: `coord[i]` must be in `0..coord_dims[i]`.
33    pub coord_dims: Vec<u32>,
34    /// Stride per coordinate index for canonical rank computation.
35    pub coord_strides: Vec<usize>,
36    /// Number of spatial dimensions.
37    pub ndim: usize,
38    /// Whether all boundaries wrap (torus topology → all positions interior).
39    pub all_wrap: bool,
40    /// Connectivity type for graph-distance computation.
41    pub connectivity: GridConnectivity,
42}
43
44impl GridGeometry {
45    /// Try to extract grid geometry from a `&dyn Space` via downcast.
46    ///
47    /// Returns `Some` for Square4, Square8, Hex2D (all 2D grids).
48    /// Returns `None` for Line1D, Ring1D, ProductSpace (heterogeneous),
49    /// or any unknown Space implementation.
50    pub fn from_space(space: &dyn Space) -> Option<Self> {
51        // Try Square4: coord = [row, col], rank = row * cols + col
52        if let Some(sq4) = space.downcast_ref::<murk_space::Square4>() {
53            let all_wrap = sq4.edge_behavior() == murk_space::EdgeBehavior::Wrap;
54            return Some(GridGeometry {
55                coord_dims: vec![sq4.rows(), sq4.cols()],
56                coord_strides: vec![sq4.cols() as usize, 1],
57                ndim: 2,
58                all_wrap,
59                connectivity: GridConnectivity::FourWay,
60            });
61        }
62
63        // Try Square8: coord = [row, col], rank = row * cols + col
64        if let Some(sq8) = space.downcast_ref::<murk_space::Square8>() {
65            let all_wrap = sq8.edge_behavior() == murk_space::EdgeBehavior::Wrap;
66            return Some(GridGeometry {
67                coord_dims: vec![sq8.rows(), sq8.cols()],
68                coord_strides: vec![sq8.cols() as usize, 1],
69                ndim: 2,
70                all_wrap,
71                connectivity: GridConnectivity::EightWay,
72            });
73        }
74
75        // Try Hex2D: coord = [q, r], rank = r * cols + q
76        // coord[0]=q has range [0, cols) and stride 1
77        // coord[1]=r has range [0, rows) and stride cols
78        if let Some(hex) = space.downcast_ref::<murk_space::Hex2D>() {
79            return Some(GridGeometry {
80                coord_dims: vec![hex.cols(), hex.rows()],
81                coord_strides: vec![1, hex.cols() as usize],
82                ndim: 2,
83                all_wrap: false,
84                connectivity: GridConnectivity::Hex,
85            });
86        }
87
88        None
89    }
90
91    /// Compute the canonical rank of a coordinate using stride arithmetic.
92    ///
93    /// For a 2D grid with dims `[R, C]`, coord `[a, b]`:
94    /// `rank = a * strides[0] + b * strides[1]`.
95    pub fn canonical_rank(&self, coord: &[i32]) -> usize {
96        debug_assert_eq!(coord.len(), self.ndim);
97        let mut rank = 0usize;
98        for (c, &stride) in coord.iter().zip(&self.coord_strides) {
99            rank += *c as usize * stride;
100        }
101        rank
102    }
103
104    /// Check if a coordinate is within the grid bounds.
105    pub fn in_bounds(&self, coord: &[i32]) -> bool {
106        if coord.len() != self.ndim {
107            return false;
108        }
109        for (c, &dim) in coord.iter().zip(&self.coord_dims) {
110            if *c < 0 || *c >= dim as i32 {
111                return false;
112            }
113        }
114        true
115    }
116
117    /// O(1) check: is the agent at `center` fully interior for a given radius?
118    ///
119    /// An agent is interior if all cells within `radius` of `center` are
120    /// in-bounds on every coordinate axis:
121    /// `radius <= center[i]` and `center[i] + radius < coord_dims[i]` for all `i`.
122    ///
123    /// For wrapped spaces (`all_wrap == true`), every position is interior.
124    pub fn is_interior(&self, center: &[i32], radius: u32) -> bool {
125        if self.all_wrap {
126            return true;
127        }
128        let r = radius as i32;
129        for (c, &dim) in center.iter().zip(&self.coord_dims) {
130            if *c < r || *c + r >= dim as i32 {
131                return false;
132            }
133        }
134        true
135    }
136
137    /// Compute the graph distance from origin for a relative coordinate.
138    ///
139    /// This uses the distance metric appropriate for the grid connectivity:
140    /// - `FourWay`: Manhattan distance `|d0| + |d1| + ...`
141    /// - `EightWay`: Chebyshev distance `max(|d0|, |d1|, ...)`
142    /// - `Hex`: Cube distance `max(|dq|, |dr|, |dq + dr|)` (axial coords)
143    pub fn graph_distance(&self, relative: &[i32]) -> u32 {
144        match self.connectivity {
145            GridConnectivity::FourWay => relative.iter().map(|&d| d.unsigned_abs()).sum(),
146            GridConnectivity::EightWay => relative
147                .iter()
148                .map(|&d| d.unsigned_abs())
149                .max()
150                .unwrap_or(0),
151            GridConnectivity::Hex => {
152                // Axial coordinates [dq, dr]. Cube distance = max(|dq|, |dr|, |dq+dr|).
153                debug_assert_eq!(relative.len(), 2);
154                let dq = relative[0];
155                let dr = relative[1];
156                let ds = dq + dr; // implicit third axis s = -(q+r)
157                dq.unsigned_abs()
158                    .max(dr.unsigned_abs())
159                    .max(ds.unsigned_abs())
160            }
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use murk_space::{EdgeBehavior, Hex2D, Square4, Square8};
169
170    #[test]
171    fn extract_square4() {
172        let s = Square4::new(10, 8, EdgeBehavior::Absorb).unwrap();
173        let geo = GridGeometry::from_space(&s).unwrap();
174        assert_eq!(geo.coord_dims, vec![10, 8]); // coord[0]=row, coord[1]=col
175        assert_eq!(geo.coord_strides, vec![8, 1]);
176        assert_eq!(geo.ndim, 2);
177        assert!(!geo.all_wrap);
178    }
179
180    #[test]
181    fn extract_square4_wrap() {
182        let s = Square4::new(5, 5, EdgeBehavior::Wrap).unwrap();
183        let geo = GridGeometry::from_space(&s).unwrap();
184        assert!(geo.all_wrap);
185    }
186
187    #[test]
188    fn extract_square8() {
189        let s = Square8::new(6, 7, EdgeBehavior::Clamp).unwrap();
190        let geo = GridGeometry::from_space(&s).unwrap();
191        assert_eq!(geo.coord_dims, vec![6, 7]);
192        assert!(!geo.all_wrap);
193    }
194
195    #[test]
196    fn extract_hex2d() {
197        let s = Hex2D::new(12, 15).unwrap();
198        let geo = GridGeometry::from_space(&s).unwrap();
199        // coord[0]=q has range [0,15), coord[1]=r has range [0,12)
200        assert_eq!(geo.coord_dims, vec![15, 12]);
201        assert_eq!(geo.coord_strides, vec![1, 15]);
202        assert!(!geo.all_wrap);
203    }
204
205    #[test]
206    fn extract_line1d_returns_none() {
207        let s = murk_space::Line1D::new(10, EdgeBehavior::Absorb).unwrap();
208        assert!(GridGeometry::from_space(&s).is_none());
209    }
210
211    #[test]
212    fn extract_product_returns_none() {
213        let a = murk_space::Line1D::new(5, EdgeBehavior::Absorb).unwrap();
214        let b = murk_space::Line1D::new(3, EdgeBehavior::Absorb).unwrap();
215        let p = murk_space::ProductSpace::new(vec![Box::new(a), Box::new(b)]).unwrap();
216        assert!(GridGeometry::from_space(&p).is_none());
217    }
218
219    #[test]
220    fn canonical_rank_matches_space() {
221        let s = Square4::new(5, 7, EdgeBehavior::Absorb).unwrap();
222        let geo = GridGeometry::from_space(&s).unwrap();
223        // coord [3, 4] → rank = 3*7 + 4 = 25
224        assert_eq!(geo.canonical_rank(&[3, 4]), 25);
225        assert_eq!(s.canonical_rank(&smallvec::smallvec![3, 4]), Some(25));
226    }
227
228    #[test]
229    fn canonical_rank_hex_matches() {
230        let s = Hex2D::new(5, 7).unwrap();
231        let geo = GridGeometry::from_space(&s).unwrap();
232        // Hex coord [q, r] = [3, 2] → rank = r*cols + q = 2*7 + 3 = 17
233        assert_eq!(geo.canonical_rank(&[3, 2]), 17);
234        assert_eq!(s.canonical_rank(&smallvec::smallvec![3, 2]), Some(17));
235    }
236
237    #[test]
238    fn in_bounds_checks() {
239        let s = Square4::new(5, 5, EdgeBehavior::Absorb).unwrap();
240        let geo = GridGeometry::from_space(&s).unwrap();
241        assert!(geo.in_bounds(&[0, 0]));
242        assert!(geo.in_bounds(&[4, 4]));
243        assert!(!geo.in_bounds(&[-1, 0]));
244        assert!(!geo.in_bounds(&[5, 0]));
245        assert!(!geo.in_bounds(&[0, 5]));
246    }
247
248    #[test]
249    fn is_interior_absorb() {
250        let s = Square4::new(20, 20, EdgeBehavior::Absorb).unwrap();
251        let geo = GridGeometry::from_space(&s).unwrap();
252        // Center (10, 10) with radius 3: interior (3..16 on both axes)
253        assert!(geo.is_interior(&[10, 10], 3));
254        // Center (2, 10): row 2, radius 3 → 2 < 3, boundary
255        assert!(!geo.is_interior(&[2, 10], 3));
256        // Center (17, 10): row 17+3=20 >= 20, boundary
257        assert!(!geo.is_interior(&[17, 10], 3));
258    }
259
260    #[test]
261    fn is_interior_wrap_always_true() {
262        let s = Square4::new(5, 5, EdgeBehavior::Wrap).unwrap();
263        let geo = GridGeometry::from_space(&s).unwrap();
264        assert!(geo.is_interior(&[0, 0], 3));
265        assert!(geo.is_interior(&[4, 4], 10));
266    }
267
268    #[test]
269    fn interior_count_20x20_radius3() {
270        let s = Square4::new(20, 20, EdgeBehavior::Absorb).unwrap();
271        let geo = GridGeometry::from_space(&s).unwrap();
272        let mut interior = 0;
273        for r in 0..20 {
274            for c in 0..20 {
275                if geo.is_interior(&[r, c], 3) {
276                    interior += 1;
277                }
278            }
279        }
280        // Interior: rows 3..16, cols 3..16 = 14*14 = 196
281        // Total = 400, ratio = 196/400 = 0.49
282        // Wait, actually radius 3 means center[i] >= 3 and center[i]+3 < 20
283        // so center[i] in [3, 16], that's 14 values per axis
284        assert_eq!(interior, 196);
285        assert!(interior as f64 / 400.0 > 0.45);
286    }
287
288    // ── graph_distance tests ───────────────────────────────
289
290    #[test]
291    fn graph_distance_four_way_manhattan() {
292        let s = Square4::new(10, 10, EdgeBehavior::Absorb).unwrap();
293        let geo = GridGeometry::from_space(&s).unwrap();
294        assert_eq!(geo.graph_distance(&[0, 0]), 0);
295        assert_eq!(geo.graph_distance(&[1, 0]), 1);
296        assert_eq!(geo.graph_distance(&[0, 1]), 1);
297        assert_eq!(geo.graph_distance(&[1, 1]), 2); // Manhattan: |1|+|1|=2
298        assert_eq!(geo.graph_distance(&[-2, 3]), 5);
299    }
300
301    #[test]
302    fn graph_distance_eight_way_chebyshev() {
303        let s = Square8::new(10, 10, EdgeBehavior::Absorb).unwrap();
304        let geo = GridGeometry::from_space(&s).unwrap();
305        assert_eq!(geo.graph_distance(&[0, 0]), 0);
306        assert_eq!(geo.graph_distance(&[1, 1]), 1); // Chebyshev: max(1,1)=1
307        assert_eq!(geo.graph_distance(&[-2, 3]), 3);
308        assert_eq!(geo.graph_distance(&[5, -3]), 5);
309    }
310
311    #[test]
312    fn graph_distance_hex_cube() {
313        let s = Hex2D::new(10, 10).unwrap();
314        let geo = GridGeometry::from_space(&s).unwrap();
315        // Hex distance = max(|dq|, |dr|, |dq+dr|) in axial coords.
316        assert_eq!(geo.graph_distance(&[0, 0]), 0);
317        assert_eq!(geo.graph_distance(&[1, 0]), 1);
318        assert_eq!(geo.graph_distance(&[0, 1]), 1);
319        assert_eq!(geo.graph_distance(&[1, -1]), 1); // Adjacent hex
320        assert_eq!(geo.graph_distance(&[1, 1]), 2); // max(1,1,2)=2
321        assert_eq!(geo.graph_distance(&[-2, -2]), 4); // max(2,2,4)=4
322        assert_eq!(geo.graph_distance(&[2, -1]), 2); // max(2,1,1)=2
323    }
324
325    #[test]
326    fn connectivity_type_correct() {
327        let sq4 = Square4::new(5, 5, EdgeBehavior::Absorb).unwrap();
328        assert_eq!(
329            GridGeometry::from_space(&sq4).unwrap().connectivity,
330            GridConnectivity::FourWay
331        );
332        let sq8 = Square8::new(5, 5, EdgeBehavior::Absorb).unwrap();
333        assert_eq!(
334            GridGeometry::from_space(&sq8).unwrap().connectivity,
335            GridConnectivity::EightWay
336        );
337        let hex = Hex2D::new(5, 5).unwrap();
338        assert_eq!(
339            GridGeometry::from_space(&hex).unwrap().connectivity,
340            GridConnectivity::Hex
341        );
342    }
343}