Skip to main content

murk_space/
square4.rs

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