Skip to main content

murk_space/
square8.rs

1//! 2D square grid with 8-connected neighbourhood (cardinal + diagonal).
2
3use crate::edge::EdgeBehavior;
4use crate::error::SpaceError;
5use crate::grid2d;
6use crate::region::{RegionPlan, RegionSpec};
7use crate::space::Space;
8use murk_core::{Coord, SpaceInstanceId};
9use smallvec::{smallvec, SmallVec};
10
11/// All 8 offsets: N, S, W, E, NW, NE, SW, SE.
12const OFFSETS_8: [(i32, i32); 8] = [
13    (-1, 0),
14    (1, 0),
15    (0, -1),
16    (0, 1),
17    (-1, -1),
18    (-1, 1),
19    (1, -1),
20    (1, 1),
21];
22
23/// A two-dimensional square grid with 8-connected neighbourhood.
24///
25/// Each cell has coordinate `[row, col]`. Neighbours include the four
26/// cardinal directions plus four diagonals. Distance is Chebyshev (L-inf),
27/// consistent with 8-connected semantics where diagonal moves cost 1.
28///
29/// Boundary handling is controlled by [`EdgeBehavior`]:
30/// - **Absorb**: edge cells have fewer neighbors (corners have 3, edges have 5)
31/// - **Clamp**: edge cells self-loop on the boundary axis
32/// - **Wrap**: periodic boundary (torus topology)
33#[derive(Debug, Clone)]
34pub struct Square8 {
35    rows: u32,
36    cols: u32,
37    edge: EdgeBehavior,
38    instance_id: SpaceInstanceId,
39}
40
41impl Square8 {
42    /// Create a new 2D grid with `rows * cols` cells and the given edge behavior.
43    ///
44    /// Maximum dimension size: coordinates use `i32`, so each axis must fit.
45    pub const MAX_DIM: u32 = i32::MAX as u32;
46
47    /// Returns `Err(SpaceError::EmptySpace)` if either dimension is 0, or
48    /// `Err(SpaceError::DimensionTooLarge)` if either exceeds `i32::MAX`.
49    pub fn new(rows: u32, cols: u32, edge: EdgeBehavior) -> Result<Self, SpaceError> {
50        if rows == 0 || cols == 0 {
51            return Err(SpaceError::EmptySpace);
52        }
53        if rows > Self::MAX_DIM {
54            return Err(SpaceError::DimensionTooLarge {
55                name: "rows",
56                value: rows,
57                max: Self::MAX_DIM,
58            });
59        }
60        if cols > Self::MAX_DIM {
61            return Err(SpaceError::DimensionTooLarge {
62                name: "cols",
63                value: cols,
64                max: Self::MAX_DIM,
65            });
66        }
67        Ok(Self {
68            rows,
69            cols,
70            edge,
71            instance_id: SpaceInstanceId::next(),
72        })
73    }
74
75    /// Number of rows.
76    pub fn rows(&self) -> u32 {
77        self.rows
78    }
79
80    /// Number of columns.
81    pub fn cols(&self) -> u32 {
82        self.cols
83    }
84
85    /// Always returns `false` — construction rejects empty grids.
86    pub fn is_empty(&self) -> bool {
87        false
88    }
89
90    /// Edge behavior.
91    pub fn edge_behavior(&self) -> EdgeBehavior {
92        self.edge
93    }
94
95    /// Compute the 8-connected neighbours of `(r, c)` as `(row, col)` pairs.
96    fn neighbours_rc(&self, r: i32, c: i32) -> Vec<(i32, i32)> {
97        let mut result = Vec::with_capacity(8);
98        for (dr, dc) in OFFSETS_8 {
99            let nr = grid2d::resolve_axis(r + dr, self.rows, self.edge);
100            let nc = grid2d::resolve_axis(c + dc, self.cols, self.edge);
101            if let (Some(nr), Some(nc)) = (nr, nc) {
102                result.push((nr, nc));
103            }
104        }
105        result
106    }
107}
108
109impl Space for Square8 {
110    fn ndim(&self) -> usize {
111        2
112    }
113
114    fn cell_count(&self) -> usize {
115        (self.rows as usize) * (self.cols as usize)
116    }
117
118    fn neighbours(&self, coord: &Coord) -> SmallVec<[Coord; 8]> {
119        let r = coord[0];
120        let c = coord[1];
121        self.neighbours_rc(r, c)
122            .into_iter()
123            .map(|(nr, nc)| smallvec![nr, nc])
124            .collect()
125    }
126
127    fn max_neighbour_degree(&self) -> usize {
128        match self.edge {
129            EdgeBehavior::Clamp | EdgeBehavior::Wrap => 8,
130            EdgeBehavior::Absorb => {
131                let axis_max = |len: u32| -> usize {
132                    match len {
133                        0 | 1 => 0,
134                        2 => 1,
135                        _ => 2,
136                    }
137                };
138                let v = axis_max(self.rows);
139                let h = axis_max(self.cols);
140                // Degree = cardinals + diagonals.
141                (v + h) + (v * h)
142            }
143        }
144    }
145
146    fn distance(&self, a: &Coord, b: &Coord) -> f64 {
147        // Chebyshev (L-inf) distance — matches graph geodesic for 8-connected.
148        let dr = grid2d::axis_distance(a[0], b[0], self.rows, self.edge);
149        let dc = grid2d::axis_distance(a[1], b[1], self.cols, self.edge);
150        dr.max(dc)
151    }
152
153    fn compile_region(&self, spec: &RegionSpec) -> Result<RegionPlan, SpaceError> {
154        let edge = self.edge;
155        let rows = self.rows;
156        let cols = self.cols;
157        grid2d::compile_region_2d(spec, rows, cols, self, |r, c| {
158            let mut result = Vec::with_capacity(8);
159            for (dr, dc) in OFFSETS_8 {
160                let nr = grid2d::resolve_axis(r + dr, rows, edge);
161                let nc = grid2d::resolve_axis(c + dc, cols, edge);
162                if let (Some(nr), Some(nc)) = (nr, nc) {
163                    result.push((nr, nc));
164                }
165            }
166            result
167        })
168    }
169
170    fn canonical_ordering(&self) -> Vec<Coord> {
171        grid2d::canonical_ordering_2d(self.rows, self.cols)
172    }
173
174    fn canonical_rank(&self, coord: &Coord) -> Option<usize> {
175        grid2d::canonical_rank_2d(coord, self.rows, self.cols)
176    }
177
178    fn canonical_rank_slice(&self, coord: &[i32]) -> Option<usize> {
179        if coord.len() != 2 {
180            return None;
181        }
182        let r = coord[0];
183        let c = coord[1];
184        if r >= 0 && r < self.rows as i32 && c >= 0 && c < self.cols as i32 {
185            Some(r as usize * self.cols as usize + c as usize)
186        } else {
187            None
188        }
189    }
190
191    fn instance_id(&self) -> SpaceInstanceId {
192        self.instance_id
193    }
194
195    fn topology_eq(&self, other: &dyn Space) -> bool {
196        (other as &dyn std::any::Any)
197            .downcast_ref::<Self>()
198            .is_some_and(|o| self.rows == o.rows && self.cols == o.cols && self.edge == o.edge)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::compliance;
206    use murk_core::Coord;
207    use proptest::prelude::*;
208
209    fn c(r: i32, col: i32) -> Coord {
210        smallvec![r, col]
211    }
212
213    // ── Neighbour tests ─────────────────────────────────────────
214
215    #[test]
216    fn neighbours_absorb_interior() {
217        let s = Square8::new(5, 5, EdgeBehavior::Absorb).unwrap();
218        let n = s.neighbours(&c(2, 2));
219        assert_eq!(n.len(), 8);
220    }
221
222    #[test]
223    fn neighbours_absorb_corner() {
224        let s = Square8::new(5, 5, EdgeBehavior::Absorb).unwrap();
225        let n = s.neighbours(&c(0, 0));
226        assert_eq!(n.len(), 3);
227        assert!(n.contains(&c(1, 0)));
228        assert!(n.contains(&c(0, 1)));
229        assert!(n.contains(&c(1, 1)));
230    }
231
232    #[test]
233    fn neighbours_absorb_edge() {
234        let s = Square8::new(5, 5, EdgeBehavior::Absorb).unwrap();
235        let n = s.neighbours(&c(0, 2));
236        assert_eq!(n.len(), 5);
237    }
238
239    #[test]
240    fn neighbours_wrap_corner() {
241        let s = Square8::new(5, 5, EdgeBehavior::Wrap).unwrap();
242        let n = s.neighbours(&c(0, 0));
243        assert_eq!(n.len(), 8);
244        assert!(n.contains(&c(4, 4))); // NW wraps on both axes
245        assert!(n.contains(&c(4, 0))); // N wraps
246        assert!(n.contains(&c(0, 4))); // W wraps
247    }
248
249    // ── Distance tests ──────────────────────────────────────────
250
251    #[test]
252    fn distance_chebyshev_absorb() {
253        let s = Square8::new(10, 10, EdgeBehavior::Absorb).unwrap();
254        // Diagonal: max(1, 1) = 1
255        assert_eq!(s.distance(&c(0, 0), &c(1, 1)), 1.0);
256        // max(3, 4) = 4
257        assert_eq!(s.distance(&c(0, 0), &c(3, 4)), 4.0);
258        // max(3, 4) = 4
259        assert_eq!(s.distance(&c(2, 3), &c(5, 7)), 4.0);
260    }
261
262    #[test]
263    fn distance_chebyshev_wrap() {
264        let s = Square8::new(10, 10, EdgeBehavior::Wrap).unwrap();
265        // Wrap: per-axis min(9,1) = 1 each → max(1,1) = 1
266        assert_eq!(s.distance(&c(0, 0), &c(9, 9)), 1.0);
267    }
268
269    // ── Region tests ────────────────────────────────────────────
270
271    #[test]
272    fn compile_region_all() {
273        let s = Square8::new(5, 5, EdgeBehavior::Absorb).unwrap();
274        let plan = s.compile_region(&RegionSpec::All).unwrap();
275        assert_eq!(plan.cell_count(), 25);
276        assert_eq!(plan.valid_ratio(), 1.0);
277    }
278
279    #[test]
280    fn compile_region_disk_square_shape() {
281        // Disk on Square8 should produce a square shape (Chebyshev ball).
282        let s = Square8::new(10, 10, EdgeBehavior::Absorb).unwrap();
283        let plan = s
284            .compile_region(&RegionSpec::Disk {
285                center: c(5, 5),
286                radius: 2,
287            })
288            .unwrap();
289        // Chebyshev ball of radius 2: 5×5 = 25 cells
290        assert_eq!(plan.cell_count(), 25);
291    }
292
293    #[test]
294    fn compile_region_rect() {
295        let s = Square8::new(10, 10, EdgeBehavior::Absorb).unwrap();
296        let plan = s
297            .compile_region(&RegionSpec::Rect {
298                min: c(2, 3),
299                max: c(4, 6),
300            })
301            .unwrap();
302        assert_eq!(plan.cell_count(), 12); // 3 rows * 4 cols
303    }
304
305    // ── Constructor tests ───────────────────────────────────────
306
307    #[test]
308    fn new_zero_rows_returns_error() {
309        assert!(matches!(
310            Square8::new(0, 5, EdgeBehavior::Absorb),
311            Err(SpaceError::EmptySpace)
312        ));
313    }
314
315    #[test]
316    fn new_rejects_dims_exceeding_i32_max() {
317        let big = i32::MAX as u32 + 1;
318        assert!(matches!(
319            Square8::new(big, 5, EdgeBehavior::Absorb),
320            Err(SpaceError::DimensionTooLarge { name: "rows", .. })
321        ));
322        assert!(matches!(
323            Square8::new(5, big, EdgeBehavior::Absorb),
324            Err(SpaceError::DimensionTooLarge { name: "cols", .. })
325        ));
326    }
327
328    // ── 1×1 edge case ──────────────────────────────────────────
329
330    #[test]
331    fn single_cell_absorb() {
332        let s = Square8::new(1, 1, EdgeBehavior::Absorb).unwrap();
333        assert!(s.neighbours(&c(0, 0)).is_empty());
334    }
335
336    #[test]
337    fn single_cell_wrap() {
338        let s = Square8::new(1, 1, EdgeBehavior::Wrap).unwrap();
339        let n = s.neighbours(&c(0, 0));
340        // All 8 directions wrap to self
341        assert_eq!(n.len(), 8);
342        assert!(n.iter().all(|nb| nb == &c(0, 0)));
343    }
344
345    // ── Compliance suites ───────────────────────────────────────
346
347    #[test]
348    fn compliance_absorb() {
349        let s = Square8::new(8, 8, EdgeBehavior::Absorb).unwrap();
350        compliance::run_full_compliance(&s);
351    }
352
353    #[test]
354    fn compliance_clamp() {
355        let s = Square8::new(8, 8, EdgeBehavior::Clamp).unwrap();
356        compliance::run_full_compliance(&s);
357    }
358
359    #[test]
360    fn compliance_wrap() {
361        let s = Square8::new(8, 8, EdgeBehavior::Wrap).unwrap();
362        compliance::run_full_compliance(&s);
363    }
364
365    // ── Downcast test ───────────────────────────────────────────
366
367    #[test]
368    fn downcast_ref_square8() {
369        let s: Box<dyn Space> = Box::new(Square8::new(3, 3, EdgeBehavior::Absorb).unwrap());
370        assert!(s.downcast_ref::<Square8>().is_some());
371        assert!(s.downcast_ref::<crate::Square4>().is_none());
372    }
373
374    // ── Property tests ──────────────────────────────────────────
375
376    fn arb_edge() -> impl Strategy<Value = EdgeBehavior> {
377        prop_oneof![
378            Just(EdgeBehavior::Absorb),
379            Just(EdgeBehavior::Clamp),
380            Just(EdgeBehavior::Wrap),
381        ]
382    }
383
384    proptest! {
385        #[test]
386        fn distance_is_metric(
387            rows in 2u32..10,
388            cols in 2u32..10,
389            edge in arb_edge(),
390            ar in 0i32..10, ac in 0i32..10,
391            br in 0i32..10, bc in 0i32..10,
392            cr in 0i32..10, cc_val in 0i32..10,
393        ) {
394            let ar = ar % rows as i32;
395            let ac = ac % cols as i32;
396            let br = br % rows as i32;
397            let bc = bc % cols as i32;
398            let cr = cr % rows as i32;
399            let cc_val = cc_val % cols as i32;
400            let s = Square8::new(rows, cols, edge).unwrap();
401            let a: Coord = smallvec![ar, ac];
402            let b: Coord = smallvec![br, bc];
403            let cv: Coord = smallvec![cr, cc_val];
404
405            prop_assert!((s.distance(&a, &a) - 0.0).abs() < f64::EPSILON);
406            prop_assert!((s.distance(&a, &b) - s.distance(&b, &a)).abs() < f64::EPSILON);
407            prop_assert!(s.distance(&a, &cv) <= s.distance(&a, &b) + s.distance(&b, &cv) + f64::EPSILON);
408        }
409
410        #[test]
411        fn neighbours_symmetric(
412            rows in 2u32..10,
413            cols in 2u32..10,
414            edge in arb_edge(),
415            r in 0i32..10, col in 0i32..10,
416        ) {
417            let r = r % rows as i32;
418            let col = col % cols as i32;
419            let s = Square8::new(rows, cols, edge).unwrap();
420            let coord: Coord = smallvec![r, col];
421            for nb in s.neighbours(&coord) {
422                let nb_neighbours = s.neighbours(&nb);
423                prop_assert!(
424                    nb_neighbours.contains(&coord),
425                    "neighbour symmetry violated: {:?} in N({:?}) but {:?} not in N({:?})",
426                    nb, coord, coord, nb,
427                );
428            }
429        }
430    }
431}