Skip to main content

voronoi_go/alive_zone/
validate.rs

1//! What the alive zone guarantees on top of the clipping structure, and the
2//! check that says so.
3//!
4//! [`AliveZone::validate`] delegates to the clipping layer's own validator and
5//! then adds the two things the alive zone itself owns: the map from a stone to
6//! the dead zone carved for it, and the forced-eye set. It runs after **every**
7//! mutating operation under `cfg(debug_assertions)`, so a bug is reported where
8//! the damage was done rather than where a later walk falls over it.
9
10use std::collections::BTreeSet;
11
12use thiserror::Error;
13
14use crate::clipping::{ShapeId, ShapeKind, StructureError};
15use crate::{Point, StoneId};
16
17use super::AliveZone;
18
19/// Something the alive zone guarantees, found not to hold.
20///
21/// Every variant is a bug in whatever last mutated the zone. None of them is
22/// reachable from user input: an unplayable move is rejected long before it
23/// reaches this layer.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
25pub enum ZoneError {
26    /// The clipping structure underneath is corrupt.
27    #[error("the clipping structure is corrupt: {0}")]
28    Structure(#[from] StructureError),
29
30    /// A stone's dead zone names a shape that is no longer there.
31    #[error("stone {stone}'s dead zone names shape {shape:?}, which is not in the graph")]
32    MissingDeadZone {
33        /// The stone.
34        stone: StoneId,
35        /// The shape it names.
36        shape: ShapeId,
37    },
38
39    /// A stone's dead zone names a shape that is not a dead zone.
40    #[error("stone {stone}'s dead zone names shape {shape:?}, which is a board edge")]
41    NotADeadZone {
42        /// The stone.
43        stone: StoneId,
44        /// The shape it names.
45        shape: ShapeId,
46    },
47
48    /// Two stones name the same dead zone, so reclaiming either would take the
49    /// other's playable area with it.
50    #[error("shape {shape:?} is named as the dead zone of more than one stone")]
51    SharedDeadZone {
52        /// The shape.
53        shape: ShapeId,
54    },
55
56    /// A dead zone is clipping the playable area that no stone can name, so
57    /// nothing can ever reclaim it.
58    ///
59    /// A temporary circle is exempt while the
60    /// [`with_temp_circle`](AliveZone::with_temp_circle) call that carved it is
61    /// still running — the zone names it itself for exactly that long, which is
62    /// what lets the check stay this strict everywhere else.
63    #[error("shape {shape:?} is a dead zone that no stone names")]
64    UnnamedDeadZone {
65        /// The shape.
66        shape: ShapeId,
67    },
68
69    /// A temporary circle names a shape that is not a dead zone in the graph,
70    /// so the guard that restores it would have nothing to give back.
71    #[error("the temporary circle names shape {shape:?}, which is not a dead zone in the graph")]
72    StrandedTempCircle {
73        /// The shape it names.
74        shape: ShapeId,
75    },
76
77    /// A forced eye is filed under a key that is not its own point's.
78    #[error("the forced eye at {point:?} is filed under another point's key")]
79    MisfiledForcedEye {
80        /// The eye.
81        point: Point,
82    },
83}
84
85impl AliveZone {
86    /// Checks every invariant the alive zone is supposed to hold.
87    ///
88    /// The clipping structure first — list circularity, `prev`/`next` symmetry,
89    /// offset ordering, and the shared-start index in both directions — then
90    /// that stones and dead zones name each other one-for-one, and that every
91    /// forced eye is filed under its own point.
92    ///
93    /// A dead zone carved by a [`AliveZone::with_temp_circle`] call that has not
94    /// returned yet counts as named. It has to: the check runs inside that call
95    /// as well, and a temporary circle *is* accounted for — by the guard holding
96    /// it — for as long as it exists.
97    ///
98    /// # Errors
99    ///
100    /// Returns the first invariant found not to hold. Any of them means an
101    /// earlier mutation left the zone corrupt.
102    pub fn validate(&self) -> Result<(), ZoneError> {
103        self.graph.validate()?;
104
105        let mut named: BTreeSet<ShapeId> = BTreeSet::new();
106        for shape in &self.temp_circles {
107            let shape = *shape;
108            let is_dead_zone = self
109                .graph
110                .shape(shape)
111                .is_some_and(|entry| matches!(entry.kind(), ShapeKind::DeadZone(_)));
112            if !is_dead_zone {
113                return Err(ZoneError::StrandedTempCircle { shape });
114            }
115            named.insert(shape);
116        }
117
118        for (stone, shape) in &self.dead_zones {
119            let (stone, shape) = (*stone, *shape);
120            let Some(entry) = self.graph.shape(shape) else {
121                return Err(ZoneError::MissingDeadZone { stone, shape });
122            };
123            if !matches!(entry.kind(), ShapeKind::DeadZone(_)) {
124                return Err(ZoneError::NotADeadZone { stone, shape });
125            }
126            if !named.insert(shape) {
127                return Err(ZoneError::SharedDeadZone { shape });
128            }
129        }
130
131        // The other direction. A dead zone nothing names can never be reclaimed,
132        // which is exactly the shape a leaked temporary circle would take.
133        for (id, shape) in self.graph.shapes() {
134            if matches!(shape.kind(), ShapeKind::DeadZone(_)) && !named.contains(&id) {
135                return Err(ZoneError::UnnamedDeadZone { shape: id });
136            }
137        }
138
139        for (key, point) in self.forced_eyes.entries() {
140            if point.key() != key {
141                return Err(ZoneError::MisfiledForcedEye { point });
142            }
143        }
144
145        Ok(())
146    }
147
148    /// Panics if the zone is corrupt, in a debug build.
149    ///
150    /// Called at the end of every mutating operation. In a release build the
151    /// check compiles away.
152    pub(super) fn debug_validate(&self) {
153        if cfg!(debug_assertions) {
154            if let Err(error) = self.validate() {
155                panic!("the alive zone is corrupt: {error}");
156            }
157        }
158    }
159
160    /// Every shape, named by its own geometry, with the exact bits of every
161    /// segment offset it carries and what is visible — the comparison a
162    /// round trip has to survive.
163    ///
164    /// Two things are canonicalized away, and both are bookkeeping rather than
165    /// structure:
166    ///
167    /// - **Shape ids.** A reclaimed dead zone that is carved again gets a fresh
168    ///   one, so shapes are named by a point on their own outline instead and
169    ///   the list is sorted.
170    /// - **Where a closed list starts.** The head of a circular list is
171    ///   whichever segment happened to be inserted first; deleting it moves the
172    ///   head to its successor, so a round trip can leave the list rotated. The
173    ///   cyclic order itself is checked by [`AliveZone::validate`], and offsets
174    ///   are sorted here.
175    ///
176    /// Everything else is compared bit for bit. Forced eyes are not part of it:
177    /// they are their own set, compared directly.
178    #[cfg(test)]
179    pub(crate) fn fingerprint(&self) -> Vec<(crate::PointKey, Vec<(u64, bool)>)> {
180        let mut shapes: Vec<(crate::PointKey, Vec<(u64, bool)>)> = self
181            .graph
182            .shape_ids()
183            .filter_map(|shape| {
184                let kind = self.graph.shape(shape)?.kind();
185                let mut segments: Vec<(u64, bool)> = self
186                    .graph
187                    .node_ids(shape)
188                    .into_iter()
189                    .filter_map(|id| {
190                        let start = self.graph.segment(id)?.start().to_bits();
191                        Some((start, self.graph.is_active(id)))
192                    })
193                    .collect();
194                segments.sort_unstable();
195                Some((kind.offset_to_point(0.0).key(), segments))
196            })
197            .collect();
198        shapes.sort_unstable();
199        shapes
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    #![allow(clippy::unwrap_used, clippy::expect_used)]
206
207    use super::ZoneError;
208    use crate::alive_zone::AliveZone;
209    use crate::clipping::{ShapeId, StructureError};
210    use crate::{Point, PointKey, StoneId};
211
212    const BOARD: f64 = 20.0;
213
214    fn p(x: f64, y: f64) -> Point {
215        Point::new(x, y)
216    }
217
218    /// A shape id that names nothing, made the only way there is: allocate a
219    /// shape and take it straight back out again. Shape ids are never reused, so
220    /// the id stays dangling.
221    fn dangling_shape(zone: &mut AliveZone) -> ShapeId {
222        let ghost = zone.graph.add_dead_zone(p(15.0, 15.0));
223        let _ = zone.graph.remove_shape(ghost);
224        ghost
225    }
226
227    fn populated() -> AliveZone {
228        let mut zone = AliveZone::new(BOARD);
229        zone.remove_circle(StoneId::new(0), p(2.0, 10.0)).unwrap();
230        zone.remove_circle(StoneId::new(1), p(3.0, 11.5)).unwrap();
231        zone.add_forced_eye(p(9.0, 9.0));
232        assert_eq!(zone.validate(), Ok(()));
233        zone
234    }
235
236    #[test]
237    fn a_healthy_zone_validates() {
238        assert_eq!(populated().validate(), Ok(()));
239    }
240
241    #[test]
242    fn corruption_underneath_surfaces_through_the_zone() {
243        // The clipping layer tests every way its own structure can break; what
244        // matters here is that the zone reports rather than swallows it.
245        let error = ZoneError::from(StructureError::EmptyIndexEntry);
246        assert!(matches!(error, ZoneError::Structure(_)));
247        assert!(error.to_string().starts_with("the clipping structure is"));
248    }
249
250    #[test]
251    fn a_dead_zone_that_has_gone_is_caught() {
252        let mut zone = populated();
253        let ghost = dangling_shape(&mut zone);
254        zone.dead_zones.insert(StoneId::new(9), ghost);
255
256        assert!(matches!(
257            zone.validate(),
258            Err(ZoneError::MissingDeadZone { .. })
259        ));
260    }
261
262    #[test]
263    fn a_stone_naming_a_board_edge_is_caught() {
264        let mut zone = populated();
265        let edge = zone.graph.shape_ids().next().unwrap();
266        zone.dead_zones.insert(StoneId::new(9), edge);
267
268        assert!(matches!(
269            zone.validate(),
270            Err(ZoneError::NotADeadZone { .. })
271        ));
272    }
273
274    #[test]
275    fn two_stones_naming_one_dead_zone_is_caught() {
276        let mut zone = populated();
277        let shared = *zone.dead_zones.get(&StoneId::new(0)).unwrap();
278        zone.dead_zones.insert(StoneId::new(9), shared);
279
280        assert!(matches!(
281            zone.validate(),
282            Err(ZoneError::SharedDeadZone { .. })
283        ));
284    }
285
286    #[test]
287    fn a_dead_zone_no_stone_names_is_caught() {
288        let mut zone = populated();
289        // Exactly the shape a leaked temporary circle would take.
290        zone.dead_zones.remove(&StoneId::new(0));
291
292        assert!(matches!(
293            zone.validate(),
294            Err(ZoneError::UnnamedDeadZone { .. })
295        ));
296    }
297
298    #[test]
299    fn a_live_temporary_circle_is_named_by_the_zone_itself() {
300        // Carved and named by nothing a stone can reach, and still valid: this
301        // is what lets the check stay strict everywhere else.
302        let mut zone = populated();
303        zone.with_temp_circle(p(9.0, 4.0), |zone| {
304            assert_eq!(zone.validate(), Ok(()));
305            assert_eq!(zone.temp_circles.len(), 1);
306        });
307        assert!(zone.temp_circles.is_empty());
308        assert_eq!(zone.validate(), Ok(()));
309    }
310
311    #[test]
312    fn a_temporary_circle_naming_nothing_is_caught() {
313        let mut zone = populated();
314        let ghost = dangling_shape(&mut zone);
315        zone.temp_circles.push(ghost);
316
317        assert!(matches!(
318            zone.validate(),
319            Err(ZoneError::StrandedTempCircle { .. })
320        ));
321    }
322
323    #[test]
324    fn a_forced_eye_under_the_wrong_key_is_caught() {
325        let mut zone = populated();
326        zone.forced_eyes
327            .by_point
328            .insert(PointKey::new(1.0, 1.0), p(4.0, 4.0));
329
330        assert!(matches!(
331            zone.validate(),
332            Err(ZoneError::MisfiledForcedEye { .. })
333        ));
334    }
335
336    #[test]
337    fn a_panic_out_of_a_compound_operation_puts_the_structure_check_back() {
338        // What the guard is for. A carve defers the clipping structure's own
339        // per-mutation check for its duration, and a deferral that outlived the
340        // operation would silence that check for the rest of the run.
341        let mut zone = populated();
342        let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
343            zone.compound(|_| panic!("something went wrong mid-carve"));
344        }));
345
346        assert!(unwound.is_err());
347        assert!(!zone.graph.validation_is_deferred());
348    }
349
350    #[test]
351    #[cfg(debug_assertions)]
352    #[should_panic(expected = "the alive zone is corrupt")]
353    fn a_mutation_on_a_corrupt_zone_panics() {
354        let mut zone = populated();
355        let ghost = dangling_shape(&mut zone);
356        zone.dead_zones.insert(StoneId::new(9), ghost);
357
358        // The next mutating operation validates, and finds the damage.
359        zone.add_forced_eye(p(1.5, 1.5));
360    }
361}