Skip to main content

exact_poly/
validation.rs

1//! Part validation matching polygon.move on-chain rules.
2//! All validation functions must produce identical results to the Move contract.
3//!
4//! ## Compactness is a boundary-level property, not a per-part property.
5//!
6//! polygon.move calls `validate_compactness` in exactly two places
7//! (polygon.move::validate_multipart_topology, count==1 and count>=2 branches):
8//! - count == 1: on the single part's (twice_area, L1 perimeter), which is the
9//!   polygon's outer boundary because the single part IS the boundary.
10//! - count >= 2: on the union's outer boundary, computed by
11//!   `validate_boundary_graph` (edges appearing exactly once) and the SUM of
12//!   all per-part twice_areas.
13//!
14//! Per-part checks in Move are strictly: convexity, `validate_part_edges` (min
15//! edge length), and max vertex count. There is NO per-part compactness check.
16//!
17//! This module therefore provides:
18//! - `validate_compactness`: the isoperimetric check, to be applied to a
19//!   polygon boundary (whole single part, or union boundary of a multipart
20//!   polygon). Do NOT call this on an individual part of a multipart polygon.
21//! - `validate_part`: mirrors Move's per-part rules — convex + edges +
22//!   vertex count bounds. NO compactness here.
23//!
24//! Rules (in order):
25//! 1. `is_convex`: weakly convex (collinear runs OK, reflex turns rejected)
26//! 2. `validate_edge_lengths`: all edges squared-length >= `MIN_EDGE_LENGTH_SQUARED`
27//! 3. `validate_compactness` (boundary-level only): `L1 perimeter^2 * MIN_COMPACTNESS_PPM <= 8_000_000 * twice_area`
28//! 4. `validate_part`: structural per-part checks (1 + 2 + vertex-count bounds)
29
30use crate::primitives::cross2d;
31use crate::types::ProtocolConfig;
32
33/// True if the polygon vertices form a weakly convex polygon.
34///
35/// "Weakly convex" = collinear runs are ALLOWED, but any non-zero cross product
36/// must be the same sign throughout. Matches polygon.move::is_convex_vertices().
37///
38/// A polygon is convex if all cross products are:
39/// - The same sign (all positive for CCW, all negative for CW), OR
40/// - Zero (collinear)
41/// Any mix of positive and negative cross products → not convex.
42///
43/// Coordinates are i64; cross products use i128.
44pub fn is_convex(ring: &[[i64; 2]]) -> bool {
45    let n = ring.len();
46    if n < 3 {
47        return false;
48    }
49
50    let mut direction: i32 = 0; // 0 = undecided, 1 = CCW (positive), -1 = CW (negative)
51
52    for i in 0..n {
53        let prev = (i + n - 1) % n;
54        let next = (i + 1) % n;
55
56        let cross = cross2d(
57            ring[prev][0],
58            ring[prev][1],
59            ring[i][0],
60            ring[i][1],
61            ring[next][0],
62            ring[next][1],
63        );
64
65        if cross == 0 {
66            // Collinear — skip this vertex (weakly convex allows this)
67            continue;
68        }
69
70        let cross_dir = if cross > 0 { 1i32 } else { -1i32 };
71
72        if direction == 0 {
73            direction = cross_dir;
74        } else if direction != cross_dir {
75            return false; // Mixed signs — not convex
76        }
77    }
78
79    true // All non-zero cross products are same sign
80}
81
82/// Validate all edges have squared length >= MIN_EDGE_LENGTH_SQUARED.
83/// Returns None if valid, Some(error message) if any edge is too short.
84/// Matches polygon.move::validate_part_edges().
85pub fn validate_edge_lengths(ring: &[[i64; 2]], config: &ProtocolConfig) -> Option<String> {
86    let n = ring.len();
87    for i in 0..n {
88        let j = (i + 1) % n;
89        let dx = (ring[j][0] as i128) - (ring[i][0] as i128);
90        let dy = (ring[j][1] as i128) - (ring[i][1] as i128);
91        let sq_len = (dx * dx + dy * dy) as u128;
92        if sq_len < config.min_edge_length_squared {
93            return Some(format!(
94                "edge {i}→{j} too short: {sq_len} < {}",
95                config.min_edge_length_squared
96            ));
97        }
98    }
99    None
100}
101
102/// Compute the L1 (Manhattan) perimeter of a polygon.
103/// Uses |dx| + |dy| per edge — matches polygon.move's perimeter formula.
104/// MUST use L1, NOT Euclidean (matching on-chain compactness check).
105pub fn perimeter_l1(ring: &[[i64; 2]]) -> u128 {
106    let n = ring.len();
107    let mut perimeter: u128 = 0;
108    for i in 0..n {
109        let j = (i + 1) % n;
110        let dx = ring[j][0].abs_diff(ring[i][0]) as u128;
111        let dy = ring[j][1].abs_diff(ring[i][1]) as u128;
112        perimeter += dx + dy;
113    }
114    perimeter
115}
116
117/// Outcome of the isoperimetric compactness check.
118///
119/// `ratio_ppm` is the computed compactness score in parts-per-million, directly
120/// comparable to `min_ppm` (the configured threshold). It equals
121/// `8_000_000 * twice_area / perimeter²`; for a 1:1 square this is ~1_000_000.
122/// Saturated to `u128::MAX` on lhs overflow (a massive-area polygon is
123/// unambiguously compact) and to 0 on perimeter² / rhs overflow.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct CompactnessOutcome {
126    pub passes: bool,
127    pub ratio_ppm: u128,
128    pub min_ppm: u128,
129}
130
131/// Core compactness check matching polygon.move::validate_compactness.
132///
133/// Formula: `8_000_000 * twice_area >= min_compactness_ppm * perimeter_l1²`.
134///
135/// Overflow semantics (matches Move):
136/// - `perimeter²` overflow → not compact (pathologically long boundary).
137/// - `rhs` (`min_ppm * perimeter²`) overflow → not compact.
138/// - `lhs` (`8_000_000 * twice_area`) overflow → compact (massive area wins).
139///
140/// This is the canonical implementation. Both the string-based
141/// `validate_compactness` and the `TopologyError`-based wrapper in `topology.rs`
142/// delegate to this function so all callers agree byte-for-byte with on-chain.
143pub fn check_compactness(
144    twice_area: u128,
145    perimeter: u128,
146    config: &ProtocolConfig,
147) -> CompactnessOutcome {
148    let min_ppm = config.min_compactness_ppm;
149
150    let perimeter_sq = perimeter.checked_mul(perimeter);
151    let rhs = perimeter_sq.and_then(|p| min_ppm.checked_mul(p));
152    let lhs = 8_000_000u128.checked_mul(twice_area);
153
154    let passes = match (lhs, rhs) {
155        (None, _) => true,        // massive area — compact by definition
156        (Some(_), None) => false, // pathological perimeter — not compact
157        (Some(l), Some(r)) => l >= r,
158    };
159
160    // ratio_ppm = 8M * twice_area / perimeter², directly comparable to min_ppm.
161    let ratio_ppm = match (lhs, perimeter_sq) {
162        (None, _) => u128::MAX,
163        (_, None) | (_, Some(0)) => 0,
164        (Some(l), Some(p_sq)) => l / p_sq,
165    };
166
167    CompactnessOutcome {
168        passes,
169        ratio_ppm,
170        min_ppm,
171    }
172}
173
174/// Validate compactness using the isoperimetric ratio, returning a string error
175/// on failure. Thin wrapper around `check_compactness` for callers that want a
176/// human-readable message instead of a structured `TopologyError`.
177///
178/// Apply this to a polygon boundary (whole single-part polygon, or outer
179/// boundary of a multipart polygon). Do NOT call on an isolated part.
180pub fn validate_compactness(
181    twice_area: u128,
182    perimeter: u128,
183    config: &ProtocolConfig,
184) -> Option<String> {
185    let outcome = check_compactness(twice_area, perimeter, config);
186    if outcome.passes {
187        None
188    } else {
189        Some(format!(
190            "not compact: {} ppm < min {} ppm",
191            outcome.ratio_ppm, outcome.min_ppm
192        ))
193    }
194}
195
196/// Validate a polygon part against the per-part on-chain rules.
197///
198/// Mirrors polygon.move's per-part checks — the ones enforced by `part()` and
199/// `validate_part_edges`/`is_convex_vertices`. Compactness is deliberately NOT
200/// checked here because on-chain applies it only at the polygon boundary
201/// (`validate_multipart_topology`). Callers that need the full polygon
202/// validation must also invoke `topology::validate_multipart_topology`.
203///
204/// Checks (in order):
205/// 1. At least 3 vertices
206/// 2. At most `max_vertices_per_part` vertices
207/// 3. Weakly convex
208/// 4. All edges `>= min_edge_length_squared`
209pub fn validate_part(ring: &[[i64; 2]], config: &ProtocolConfig) -> Option<String> {
210    let n = ring.len();
211
212    if n < 3 {
213        return Some(format!("part has {n} vertices, need >= 3"));
214    }
215
216    if n > config.max_vertices_per_part {
217        return Some(format!(
218            "part has {n} vertices, max is {}",
219            config.max_vertices_per_part
220        ));
221    }
222
223    if !is_convex(ring) {
224        return Some("part is not convex".into());
225    }
226
227    if let Some(err) = validate_edge_lengths(ring, config) {
228        return Some(err);
229    }
230
231    None
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::area::twice_area_fp2;
238    use crate::types::ProtocolConfig;
239
240    const M: i64 = 1_000_000;
241
242    fn merca_config() -> ProtocolConfig {
243        ProtocolConfig::merca()
244    }
245
246    #[test]
247    fn is_convex_accepts_square() {
248        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
249        assert!(is_convex(&ring));
250    }
251
252    #[test]
253    fn is_convex_rejects_l_shape() {
254        // L-shape has a reflex vertex
255        let ring = vec![
256            [0, 0],
257            [20 * M, 0],
258            [20 * M, 10 * M],
259            [10 * M, 10 * M],
260            [10 * M, 20 * M],
261            [0, 20 * M],
262        ];
263        assert!(!is_convex(&ring));
264    }
265
266    #[test]
267    fn is_convex_accepts_weakly_convex_with_collinear() {
268        // Rectangle with extra vertex at midpoint of top edge (collinear)
269        let ring = vec![
270            [0, 0],
271            [10 * M, 0],
272            [10 * M, 10 * M],
273            [5 * M, 10 * M],
274            [0, 10 * M],
275        ];
276        assert!(is_convex(&ring));
277    }
278
279    #[test]
280    fn is_convex_accepts_triangle() {
281        let ring = vec![[0, 0], [10 * M, 0], [5 * M, 10 * M]];
282        assert!(is_convex(&ring));
283    }
284
285    #[test]
286    fn is_convex_rejects_two_points() {
287        let ring = vec![[0, 0], [1, 0]];
288        assert!(!is_convex(&ring));
289    }
290
291    #[test]
292    fn is_convex_accepts_convex_pentagon() {
293        let ring = vec![[0, 0], [2 * M, 0], [3 * M, M], [2 * M, 2 * M], [0, 2 * M]];
294        assert!(is_convex(&ring));
295    }
296
297    #[test]
298    fn validate_edge_lengths_valid() {
299        // All edges 10M each (= (10M)^2 = 1e14 >> 1e12 threshold)
300        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
301        assert!(validate_edge_lengths(&ring, &merca_config()).is_none());
302    }
303
304    #[test]
305    fn validate_edge_lengths_rejects_short_edge() {
306        // Edge of 0.5M length: (0.5M)^2 = 2.5e11 < MIN_EDGE_LENGTH_SQUARED=1e12
307        let ring = vec![[0, 0], [M / 2, 0], [M / 2, M], [0, M]]; // 0.5M = 500_000
308                                                                 // Edge 0→1: dx=500_000, dy=0, sq=250_000_000_000 < 1_000_000_000_000
309        assert!(validate_edge_lengths(&ring, &merca_config()).is_some());
310    }
311
312    #[test]
313    fn validate_edge_lengths_exact_threshold() {
314        // Edge exactly at MIN_EDGE_LENGTH = 1M: sq = (1M)^2 = 1e12 = MIN_EDGE_LENGTH_SQUARED
315        let ring = vec![[0, 0], [M, 0], [M, M], [0, M]];
316        assert!(validate_edge_lengths(&ring, &merca_config()).is_none());
317    }
318
319    #[test]
320    fn validate_edge_lengths_accepts_large_negative_coordinates() {
321        let ring = vec![[-1_000_000, 0], [1_000_000, 0]];
322        assert!(validate_edge_lengths(&ring, &merca_config()).is_none());
323    }
324
325    #[test]
326    fn validate_edge_lengths_rejects_unit_edge() {
327        let ring = vec![[0, 0], [1, 1], [1, 0]];
328        assert!(validate_edge_lengths(&ring, &merca_config()).is_some());
329    }
330
331    #[test]
332    fn perimeter_l1_uses_manhattan() {
333        // Square 10M x 10M: L1 perimeter = 4 * (|10M| + |0|) = 4 * 10M = 40M
334        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
335        let p = perimeter_l1(&ring);
336        assert_eq!(p, 4 * 10 * M as u128);
337    }
338
339    #[test]
340    fn perimeter_l1_not_euclidean() {
341        // Diagonal edge: (0,0)→(3M,4M)
342        // Euclidean = 5M, L1 = 7M
343        let ring = vec![[0, 0], [3 * M, 0], [0, 4 * M]]; // right triangle
344                                                         // Edge 0→1: |3M|+|0|=3M, Edge 1→2: |3M|+|4M|=7M, Edge 2→0: |0|+|4M|=4M
345        let p = perimeter_l1(&ring);
346        assert_eq!(p, (3 + 7 + 4) * M as u128);
347    }
348
349    #[test]
350    fn perimeter_l1_handles_negative_coordinates() {
351        let ring = vec![[-1, -1], [-1, 1], [1, 1], [1, -1]];
352        assert_eq!(perimeter_l1(&ring), 8);
353    }
354
355    #[test]
356    fn perimeter_l1_large_square_does_not_overflow() {
357        let ring = vec![
358            [10_000_000 * M, 10_000_000 * M],
359            [11_000_000 * M, 10_000_000 * M],
360            [11_000_000 * M, 11_000_000 * M],
361            [10_000_000 * M, 11_000_000 * M],
362        ];
363        assert_eq!(perimeter_l1(&ring), 4 * 1_000_000_000_000u128);
364    }
365
366    #[test]
367    fn validate_compactness_compact_square_passes() {
368        // 10M x 10M square
369        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
370        let twice_area = twice_area_fp2(&ring);
371        let perimeter = perimeter_l1(&ring);
372        assert!(validate_compactness(twice_area, perimeter, &merca_config()).is_none());
373    }
374
375    #[test]
376    fn validate_compactness_uses_checked_mul() {
377        // Large but valid polygon — should not panic
378        let ring = vec![
379            [0, 0],
380            [10_000_000 * M, 0],
381            [10_000_000 * M, 10_000_000 * M],
382            [0, 10_000_000 * M],
383        ];
384        let twice_area = twice_area_fp2(&ring);
385        let perimeter = perimeter_l1(&ring);
386        // Should not panic, result doesn't matter (extreme case)
387        let _ = validate_compactness(twice_area, perimeter, &merca_config());
388    }
389
390    #[test]
391    fn validate_part_valid_square() {
392        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
393        assert!(validate_part(&ring, &merca_config()).is_none());
394    }
395
396    #[test]
397    fn validate_part_square_passes_both_configs() {
398        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
399        let permissive = ProtocolConfig::permissive();
400        assert!(validate_part(&ring, &merca_config()).is_none());
401        assert!(validate_part(&ring, &permissive).is_none());
402    }
403
404    #[test]
405    fn validate_part_short_square_fails_merca_but_passes_permissive() {
406        let ring = vec![[0, 0], [M / 2, 0], [M / 2, M / 2], [0, M / 2]];
407        let permissive = ProtocolConfig::permissive();
408        assert!(validate_part(&ring, &merca_config()).is_some());
409        assert!(validate_part(&ring, &permissive).is_none());
410    }
411
412    #[test]
413    fn validate_part_rejects_l_shape() {
414        let ring = vec![
415            [0, 0],
416            [20 * M, 0],
417            [20 * M, 10 * M],
418            [10 * M, 10 * M],
419            [10 * M, 20 * M],
420            [0, 20 * M],
421        ];
422        assert!(validate_part(&ring, &merca_config()).is_some());
423    }
424
425    #[test]
426    fn validate_part_rejects_too_few_vertices() {
427        assert!(validate_part(&[[0, 0], [M, 0]], &merca_config()).is_some());
428    }
429}