Skip to main content

exact_poly/
validate_onchain.rs

1//! Full on-chain validation pipeline for polygon decompositions.
2//!
3//! Replicates the exact sequence of checks that `polygon.move::prepare_geometry`
4//! and `index.move::register` perform. If `validate_decomposition` returns Ok,
5//! the decomposition WILL pass on-chain registration (modulo spatial overlap
6//! with existing parcels, which requires on-chain state).
7//!
8//! Check order matches on-chain:
9//! 1. Part count within limits
10//! 2. Per-part validation (vertex count, convexity, edge lengths)
11//! 3. Area > 0
12//! 4. Area conservation (sum of parts == original)
13//! 5. Internal overlap check (no parts overlap each other)
14//! 6. Multipart topology (connectivity, boundary graph, boundary compactness)
15//!
16//! Compactness is a boundary-level invariant (step 6), not a per-part one —
17//! see the `validation` module docs for the rationale that mirrors polygon.move.
18
19use crate::area::{areas_conserved, twice_area_fp2};
20use crate::overlap::convex_parts_overlap;
21use crate::topology::validate_multipart_topology;
22use crate::types::{ProtocolConfig, TopologyError};
23use crate::validation::validate_part;
24use serde::{Deserialize, Serialize};
25
26/// A single validation check result.
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct ValidationCheck {
29    /// Name of the check (e.g. "part_0_convex", "area_conservation").
30    pub name: String,
31    /// true = passed, false = failed.
32    pub passed: bool,
33    /// Human-readable detail (error message or "OK").
34    pub detail: String,
35    /// Severity: "error" = would fail on-chain, "warn" = suspicious but allowed.
36    pub severity: String,
37}
38
39/// Full validation report for a polygon decomposition.
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct ValidationReport {
42    /// All individual checks, in order.
43    pub checks: Vec<ValidationCheck>,
44    /// true if ALL checks passed (decomposition is valid on-chain).
45    pub valid: bool,
46    /// Number of errors.
47    pub error_count: usize,
48    /// Number of warnings.
49    pub warn_count: usize,
50    /// Original polygon twice-area (string for u128).
51    pub original_twice_area: String,
52    /// Sum of part twice-areas (string for u128).
53    pub parts_twice_area_sum: String,
54    /// Per-part twice-areas (strings for u128).
55    pub part_areas: Vec<String>,
56}
57
58fn check_ok(name: impl Into<String>, detail: impl Into<String>) -> ValidationCheck {
59    ValidationCheck {
60        name: name.into(),
61        passed: true,
62        detail: detail.into(),
63        severity: "ok".into(),
64    }
65}
66
67fn check_err(name: impl Into<String>, detail: impl Into<String>) -> ValidationCheck {
68    ValidationCheck {
69        name: name.into(),
70        passed: false,
71        detail: detail.into(),
72        severity: "error".into(),
73    }
74}
75
76fn check_warn(name: impl Into<String>, detail: impl Into<String>) -> ValidationCheck {
77    ValidationCheck {
78        name: name.into(),
79        passed: true,
80        detail: detail.into(),
81        severity: "warn".into(),
82    }
83}
84
85/// Run the full on-chain validation pipeline on a polygon and its decomposition.
86///
87/// `ring` — the original polygon vertices (before decomposition).
88/// `parts` — the convex parts from decomposition.
89/// `config` — protocol config (use `ProtocolConfig::merca()` for on-chain defaults).
90///
91/// Returns a detailed report with every check and its result.
92pub fn validate_decomposition(
93    ring: &[[i64; 2]],
94    parts: &[Vec<[i64; 2]>],
95    config: &ProtocolConfig,
96) -> ValidationReport {
97    let mut checks = Vec::new();
98    let mut error_count = 0usize;
99    let mut warn_count = 0usize;
100
101    let mut push = |c: ValidationCheck| {
102        if c.severity == "error" && !c.passed {
103            error_count += 1;
104        } else if c.severity == "warn" {
105            warn_count += 1;
106        }
107        checks.push(c);
108    };
109
110    // 1. Part count
111    let n = parts.len();
112    if n == 0 {
113        push(check_err("part_count", "no parts"));
114    } else if n > config.max_parts {
115        push(check_err(
116            "part_count",
117            format!("{n} parts exceeds max {}", config.max_parts),
118        ));
119    } else {
120        push(check_ok(
121            "part_count",
122            format!("{n} (max {})", config.max_parts),
123        ));
124    }
125
126    // 2. Per-part validation
127    let mut part_areas: Vec<u128> = Vec::with_capacity(n);
128    let mut total_vertices = 0usize;
129
130    for (i, part) in parts.iter().enumerate() {
131        total_vertices += part.len();
132
133        // Vertex count
134        if part.len() < 3 {
135            push(check_err(
136                format!("part_{i}_vertices"),
137                format!("{} vertices (min 3)", part.len()),
138            ));
139        } else if part.len() > config.max_vertices_per_part {
140            push(check_err(
141                format!("part_{i}_vertices"),
142                format!(
143                    "{} vertices (max {})",
144                    part.len(),
145                    config.max_vertices_per_part
146                ),
147            ));
148        } else {
149            push(check_ok(
150                format!("part_{i}_vertices"),
151                format!("{} (max {})", part.len(), config.max_vertices_per_part),
152            ));
153        }
154
155        // Per-part structural validation (convexity, edges).
156        // Boundary compactness is checked once at step 6 below.
157        match validate_part(part, config) {
158            None => {
159                push(check_ok(format!("part_{i}_valid"), "OK"));
160            }
161            Some(err) => {
162                push(check_err(format!("part_{i}_valid"), err));
163            }
164        }
165
166        // Area
167        let area = twice_area_fp2(part);
168        part_areas.push(area);
169        if area == 0 {
170            push(check_err(format!("part_{i}_area"), "zero area"));
171        } else {
172            push(check_ok(format!("part_{i}_area"), format!("2A = {area}")));
173        }
174
175        // Coordinate range (MAX_WORLD = 40_075_017_000_000)
176        let max_world = crate::constants::MAX_WORLD as i64;
177        let out_of_range = part
178            .iter()
179            .flat_map(|vertex| vertex.iter())
180            .any(|&c| c < 0 || c > max_world);
181        if out_of_range {
182            // On-chain uses u64, so negatives are impossible there.
183            // In WASM we use i64, so negative coords are valid for demos but won't pass on-chain.
184            push(check_warn(
185                format!("part_{i}_coords"),
186                "coordinates outside [0, MAX_WORLD] — will fail on-chain",
187            ));
188        }
189    }
190
191    // Total vertex count
192    let max_total = config.max_vertices_per_part.saturating_mul(n.max(1));
193    if total_vertices > max_total {
194        push(check_err(
195            "total_vertices",
196            format!("{total_vertices} > max {max_total}"),
197        ));
198    } else {
199        push(check_ok(
200            "total_vertices",
201            format!("{total_vertices} (max {max_total})"),
202        ));
203    }
204
205    // 3. Total area > 0
206    let parts_area_sum: u128 = part_areas.iter().sum();
207    if parts_area_sum == 0 {
208        push(check_err("total_area", "zero total area"));
209    } else {
210        push(check_ok("total_area", format!("2A = {parts_area_sum}")));
211    }
212
213    // 4. Area conservation
214    let original_area = twice_area_fp2(ring);
215    if areas_conserved(original_area, &part_areas) {
216        push(check_ok(
217            "area_conservation",
218            format!("original={original_area}, sum={parts_area_sum}"),
219        ));
220    } else {
221        let diff = if original_area > parts_area_sum {
222            original_area - parts_area_sum
223        } else {
224            parts_area_sum - original_area
225        };
226        push(check_err(
227            "area_conservation",
228            format!("MISMATCH: original={original_area}, sum={parts_area_sum}, diff={diff}"),
229        ));
230    }
231
232    // 5. Internal overlap check (pairwise)
233    if n > 1 {
234        let mut has_overlap = false;
235        for i in 0..n {
236            for j in (i + 1)..n {
237                if convex_parts_overlap(&parts[i], &parts[j]) {
238                    push(check_err(
239                        format!("parts_overlap_{i}_{j}"),
240                        format!("parts {i} and {j} overlap"),
241                    ));
242                    has_overlap = true;
243                }
244            }
245        }
246        if !has_overlap {
247            push(check_ok("parts_no_overlap", "no pairwise overlap"));
248        }
249    }
250
251    // 6. Multipart topology
252    if n > 0 {
253        match validate_multipart_topology(parts, false, config) {
254            Ok(()) => {
255                push(check_ok("topology", "valid"));
256            }
257            Err(topo_err) => {
258                push(check_err("topology", format_topology_error(&topo_err)));
259            }
260        }
261    }
262
263    let valid = error_count == 0;
264    ValidationReport {
265        checks,
266        valid,
267        error_count,
268        warn_count,
269        original_twice_area: original_area.to_string(),
270        parts_twice_area_sum: parts_area_sum.to_string(),
271        part_areas: part_areas.iter().map(|a| a.to_string()).collect(),
272    }
273}
274
275fn format_topology_error(err: &TopologyError) -> String {
276    match err {
277        TopologyError::NotConnected { disconnected_parts } => {
278            format!("not connected (disconnected: {disconnected_parts:?})")
279        }
280        TopologyError::HasHoles {
281            boundary_components,
282        } => {
283            format!("has holes ({boundary_components} boundary components)")
284        }
285        TopologyError::VertexOnlyContact { part_a, part_b } => {
286            format!("vertex-only contact between parts {part_a} and {part_b}")
287        }
288        TopologyError::UnsupportedContact {
289            part_a,
290            part_b,
291            reason,
292        } => {
293            format!("unsupported contact {part_a}↔{part_b}: {reason}")
294        }
295        TopologyError::TooManyParts { count, max } => {
296            format!("too many parts ({count} > {max})")
297        }
298        TopologyError::NotCompact {
299            compactness_ppm,
300            min_ppm,
301        } => {
302            format!("not compact ({compactness_ppm} ppm < {min_ppm} ppm)")
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::types::ProtocolConfig;
311
312    const M: i64 = 1_000_000;
313
314    fn merca() -> ProtocolConfig {
315        ProtocolConfig::merca()
316    }
317
318    fn permissive() -> ProtocolConfig {
319        ProtocolConfig::permissive()
320    }
321
322    #[test]
323    fn valid_single_convex_part() {
324        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
325        let parts = vec![ring.clone()];
326        let report = validate_decomposition(&ring, &parts, &merca());
327        assert!(
328            report.valid,
329            "errors: {:?}",
330            report
331                .checks
332                .iter()
333                .filter(|c| !c.passed)
334                .collect::<Vec<_>>()
335        );
336    }
337
338    #[test]
339    fn valid_two_part_l_shape() {
340        let ring = vec![
341            [0, 0],
342            [20 * M, 0],
343            [20 * M, 10 * M],
344            [10 * M, 10 * M],
345            [10 * M, 20 * M],
346            [0, 20 * M],
347        ];
348        let parts = vec![
349            vec![
350                [0, 0],
351                [20 * M, 0],
352                [20 * M, 10 * M],
353                [10 * M, 10 * M],
354                [0, 10 * M],
355            ],
356            vec![[0, 10 * M], [10 * M, 10 * M], [10 * M, 20 * M], [0, 20 * M]],
357        ];
358        let report = validate_decomposition(&ring, &parts, &merca());
359        assert!(
360            report.valid,
361            "errors: {:?}",
362            report
363                .checks
364                .iter()
365                .filter(|c| !c.passed)
366                .collect::<Vec<_>>()
367        );
368    }
369
370    #[test]
371    fn valid_two_part_l_shape_permissive_config() {
372        let ring = vec![
373            [0, 0],
374            [20 * M, 0],
375            [20 * M, 10 * M],
376            [10 * M, 10 * M],
377            [10 * M, 20 * M],
378            [0, 20 * M],
379        ];
380        let parts = vec![
381            vec![
382                [0, 0],
383                [20 * M, 0],
384                [20 * M, 10 * M],
385                [10 * M, 10 * M],
386                [0, 10 * M],
387            ],
388            vec![[0, 10 * M], [10 * M, 10 * M], [10 * M, 20 * M], [0, 20 * M]],
389        ];
390        let report = validate_decomposition(&ring, &parts, &permissive());
391        assert!(
392            report.valid,
393            "errors: {:?}",
394            report
395                .checks
396                .iter()
397                .filter(|c| !c.passed)
398                .collect::<Vec<_>>()
399        );
400    }
401
402    #[test]
403    fn rejects_overlapping_parts() {
404        let ring = vec![[0, 0], [20 * M, 0], [20 * M, 20 * M], [0, 20 * M]];
405        // Two overlapping squares
406        let parts = vec![
407            vec![[0, 0], [15 * M, 0], [15 * M, 15 * M], [0, 15 * M]],
408            vec![
409                [5 * M, 5 * M],
410                [20 * M, 5 * M],
411                [20 * M, 20 * M],
412                [5 * M, 20 * M],
413            ],
414        ];
415        let report = validate_decomposition(&ring, &parts, &merca());
416        assert!(!report.valid);
417        assert!(report
418            .checks
419            .iter()
420            .any(|c| c.name.starts_with("parts_overlap")));
421    }
422
423    #[test]
424    fn rejects_overlapping_parts_permissive_config() {
425        let ring = vec![[0, 0], [20 * M, 0], [20 * M, 20 * M], [0, 20 * M]];
426        let parts = vec![
427            vec![[0, 0], [15 * M, 0], [15 * M, 15 * M], [0, 15 * M]],
428            vec![
429                [5 * M, 5 * M],
430                [20 * M, 5 * M],
431                [20 * M, 20 * M],
432                [5 * M, 20 * M],
433            ],
434        ];
435        let report = validate_decomposition(&ring, &parts, &permissive());
436        assert!(!report.valid);
437        assert!(report
438            .checks
439            .iter()
440            .any(|c| c.name == "parts_overlap_0_1" && !c.passed));
441    }
442
443    #[test]
444    fn rejects_area_mismatch() {
445        let ring = vec![[0, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
446        // Part is smaller than original
447        let parts = vec![vec![[0, 0], [5 * M, 0], [5 * M, 5 * M], [0, 5 * M]]];
448        let report = validate_decomposition(&ring, &parts, &merca());
449        assert!(!report.valid);
450        assert!(report
451            .checks
452            .iter()
453            .any(|c| c.name == "area_conservation" && !c.passed));
454    }
455
456    #[test]
457    fn rejects_area_mismatch_with_connected_parts() {
458        let ring = vec![[0, 0], [20 * M, 0], [20 * M, 20 * M], [0, 20 * M]];
459        let parts = vec![
460            vec![[0, 0], [20 * M, 0], [20 * M, 10 * M], [0, 10 * M]],
461            vec![[0, 10 * M], [10 * M, 10 * M], [10 * M, 20 * M], [0, 20 * M]],
462        ];
463        let report = validate_decomposition(&ring, &parts, &permissive());
464        assert!(!report.valid);
465        assert!(report
466            .checks
467            .iter()
468            .any(|c| c.name == "area_conservation" && !c.passed));
469    }
470
471    #[test]
472    fn warns_negative_coords() {
473        let ring = vec![
474            [-5 * M, -5 * M],
475            [5 * M, -5 * M],
476            [5 * M, 5 * M],
477            [-5 * M, 5 * M],
478        ];
479        let parts = vec![ring.clone()];
480        let report = validate_decomposition(&ring, &parts, &merca());
481        assert!(report
482            .checks
483            .iter()
484            .any(|c| c.name.contains("coords") && c.severity == "warn"));
485    }
486}