Skip to main content

exact_poly/
decompose.rs

1use crate::area::{areas_conserved, twice_area_fp2};
2use crate::bayazit::{bayazit_decompose, find_steiner_points};
3use crate::ear_clip::ear_clip_triangulate;
4use crate::exact_partition::exact_vertex_partition;
5use crate::hertel_mehlhorn::optimize_partition;
6use crate::ring::{is_simple, normalize_ring, rotate_ring};
7use crate::topology::validate_multipart_topology;
8use crate::types::{
9    Attempt, DecompError, DecomposeOptions, DecomposeResult, Outcome, ProtocolConfig, Strategy,
10};
11use crate::validation::validate_part;
12
13type Parts = Vec<Vec<[i64; 2]>>;
14
15#[derive(Debug)]
16enum AttemptError {
17    TooManyParts { count: usize },
18    ValidationFailed(Vec<String>),
19    Failed(String),
20}
21
22struct StrategySuccess {
23    parts: Parts,
24    strategy: Strategy,
25}
26
27pub fn decompose(
28    ring: &[[i64; 2]],
29    options: &DecomposeOptions,
30    config: &ProtocolConfig,
31) -> Result<DecomposeResult, DecompError> {
32    decompose_with_strategies(
33        ring,
34        options,
35        config,
36        exact_vertex_partition,
37        bayazit_decompose,
38        ear_clip_triangulate,
39        find_steiner_points,
40    )
41}
42
43fn decompose_with_strategies<Exact, Bayazit, EarClip, Steiner>(
44    ring: &[[i64; 2]],
45    options: &DecomposeOptions,
46    config: &ProtocolConfig,
47    exact: Exact,
48    bayazit: Bayazit,
49    ear_clip: EarClip,
50    steiner_points: Steiner,
51) -> Result<DecomposeResult, DecompError>
52where
53    Exact: Fn(&[[i64; 2]]) -> Result<Parts, String>,
54    Bayazit: Fn(&[[i64; 2]], bool, &ProtocolConfig) -> Result<Parts, String>,
55    EarClip: Fn(&[[i64; 2]]) -> Result<Parts, String>,
56    Steiner: Fn(&[[i64; 2]], &[Vec<[i64; 2]>]) -> Vec<[i64; 2]>,
57{
58    if ring.len() < 3 {
59        return Err(DecompError::TooFewVertices);
60    }
61
62    let normalized = normalize_ring(ring).ok_or(DecompError::TooFewVertices)?;
63    if !is_simple(&normalized) {
64        return Err(DecompError::NotSimple);
65    }
66
67    let original_area = twice_area_fp2(&normalized);
68    if original_area == 0 {
69        return Err(DecompError::TooFewVertices);
70    }
71
72    let attempt_count = normalized.len().min(options.max_rotation_attempts.max(1));
73    let mut trace = options.collect_trace.then(Vec::new);
74
75    if options.minimize_parts {
76        return run_minimize_parts(
77            ring,
78            &normalized,
79            options,
80            config,
81            original_area,
82            attempt_count,
83            &exact,
84            &bayazit,
85            &ear_clip,
86            &steiner_points,
87            &mut trace,
88        );
89    }
90
91    let mut saw_too_many_parts = false;
92    let mut last_error: Option<String> = None;
93
94    for rotation in 0..attempt_count {
95        let rotated = rotate_ring(&normalized, rotation);
96        match try_decompose(
97            &rotated,
98            options,
99            config,
100            original_area,
101            &exact,
102            &bayazit,
103            &ear_clip,
104            rotation,
105            &mut trace,
106        ) {
107            Ok(success) => {
108                let steiner = steiner_points(ring, &success.parts);
109                let strategy = wrap_rotation(success.strategy, rotation);
110                return Ok(DecomposeResult {
111                    parts: success.parts,
112                    steiner_points: steiner,
113                    strategy,
114                    trace,
115                });
116            }
117            Err(AttemptError::TooManyParts { count }) => {
118                saw_too_many_parts = true;
119                last_error = Some(format!("decomposition produced {count} parts"));
120            }
121            Err(AttemptError::ValidationFailed(errors)) => {
122                last_error = Some(errors.join("; "));
123            }
124            Err(AttemptError::Failed(err)) => last_error = Some(err),
125        }
126    }
127
128    if saw_too_many_parts {
129        Err(DecompError::TooManyParts)
130    } else {
131        Err(DecompError::Failed(last_error.unwrap_or_else(|| {
132            "all decomposition strategies exhausted".into()
133        })))
134    }
135}
136
137/// Candidate produced by a single (strategy, rotation) pair in minimize mode.
138struct Candidate {
139    parts: Parts,
140    strategy: Strategy,
141    rotation: usize,
142    steiner_points: Vec<[i64; 2]>,
143}
144
145/// Lexicographic ranking key for minimize_parts tiebreaking.
146/// Lower is better on every coordinate.
147fn candidate_key(c: &Candidate) -> (usize, usize, usize, u8) {
148    let strategy_rank = match c.strategy {
149        Strategy::AlreadyConvex => 0,
150        Strategy::ExactPartition => 1,
151        Strategy::Bayazit => 2,
152        Strategy::EarClipMerge => 3,
153        Strategy::Rotation { .. } => 4, // shouldn't appear pre-wrap, defensive
154    };
155    (
156        c.parts.len(),
157        c.steiner_points.len(),
158        c.rotation,
159        strategy_rank,
160    )
161}
162
163#[allow(clippy::too_many_arguments)]
164fn run_minimize_parts<Exact, Bayazit, EarClip, Steiner>(
165    original_ring: &[[i64; 2]],
166    normalized: &[[i64; 2]],
167    options: &DecomposeOptions,
168    config: &ProtocolConfig,
169    original_area: u128,
170    attempt_count: usize,
171    exact: &Exact,
172    bayazit: &Bayazit,
173    ear_clip: &EarClip,
174    steiner_points: &Steiner,
175    trace: &mut Option<Vec<Attempt>>,
176) -> Result<DecomposeResult, DecompError>
177where
178    Exact: Fn(&[[i64; 2]]) -> Result<Parts, String>,
179    Bayazit: Fn(&[[i64; 2]], bool, &ProtocolConfig) -> Result<Parts, String>,
180    EarClip: Fn(&[[i64; 2]]) -> Result<Parts, String>,
181    Steiner: Fn(&[[i64; 2]], &[Vec<[i64; 2]>]) -> Vec<[i64; 2]>,
182{
183    let mut candidates: Vec<Candidate> = Vec::new();
184    let mut saw_too_many_parts = false;
185    let mut last_error: Option<String> = None;
186
187    for rotation in 0..attempt_count {
188        let rotated = rotate_ring(normalized, rotation);
189        collect_candidates_for_rotation(
190            original_ring,
191            &rotated,
192            options,
193            config,
194            original_area,
195            rotation,
196            exact,
197            bayazit,
198            ear_clip,
199            steiner_points,
200            trace,
201            &mut candidates,
202            &mut saw_too_many_parts,
203            &mut last_error,
204        );
205    }
206
207    match candidates.into_iter().min_by_key(candidate_key) {
208        Some(best) => {
209            let wrapped = wrap_rotation(best.strategy, best.rotation);
210            Ok(DecomposeResult {
211                parts: best.parts,
212                steiner_points: best.steiner_points,
213                strategy: wrapped,
214                trace: trace.take(),
215            })
216        }
217        None => {
218            if saw_too_many_parts {
219                Err(DecompError::TooManyParts)
220            } else {
221                Err(DecompError::Failed(last_error.unwrap_or_else(|| {
222                    "no strategy produced a valid decomposition".into()
223                })))
224            }
225        }
226    }
227}
228
229#[allow(clippy::too_many_arguments)]
230fn collect_candidates_for_rotation<Exact, Bayazit, EarClip, Steiner>(
231    original_ring: &[[i64; 2]],
232    rotated: &[[i64; 2]],
233    options: &DecomposeOptions,
234    config: &ProtocolConfig,
235    original_area: u128,
236    rotation: usize,
237    exact: &Exact,
238    bayazit: &Bayazit,
239    ear_clip: &EarClip,
240    steiner_points: &Steiner,
241    trace: &mut Option<Vec<Attempt>>,
242    candidates: &mut Vec<Candidate>,
243    saw_too_many_parts: &mut bool,
244    last_error: &mut Option<String>,
245) where
246    Exact: Fn(&[[i64; 2]]) -> Result<Parts, String>,
247    Bayazit: Fn(&[[i64; 2]], bool, &ProtocolConfig) -> Result<Parts, String>,
248    EarClip: Fn(&[[i64; 2]]) -> Result<Parts, String>,
249    Steiner: Fn(&[[i64; 2]], &[Vec<[i64; 2]>]) -> Vec<[i64; 2]>,
250{
251    // Strategy 1: ExactPartition
252    record_strategy_candidate(
253        Strategy::ExactPartition,
254        rotation,
255        exact(rotated),
256        config,
257        original_area,
258        original_ring,
259        steiner_points,
260        trace,
261        candidates,
262        saw_too_many_parts,
263        last_error,
264    );
265
266    // Strategy 2: Bayazit (only when Steiner points are allowed)
267    if options.allow_steiner {
268        record_strategy_candidate(
269            Strategy::Bayazit,
270            rotation,
271            bayazit(rotated, true, config),
272            config,
273            original_area,
274            original_ring,
275            steiner_points,
276            trace,
277            candidates,
278            saw_too_many_parts,
279            last_error,
280        );
281    }
282
283    // Strategy 3: EarClip triangulation followed by Hertel-Mehlhorn merge.
284    // The inner Err path also has to be surfaced to trace under the merged
285    // strategy name, matching cascade mode's behaviour.
286    let ear_result = ear_clip(rotated).map(|triangles| optimize_partition(&triangles));
287    record_strategy_candidate(
288        Strategy::EarClipMerge,
289        rotation,
290        ear_result,
291        config,
292        original_area,
293        original_ring,
294        steiner_points,
295        trace,
296        candidates,
297        saw_too_many_parts,
298        last_error,
299    );
300}
301
302#[allow(clippy::too_many_arguments)]
303fn record_strategy_candidate<Steiner>(
304    strategy: Strategy,
305    rotation: usize,
306    raw: Result<Parts, String>,
307    config: &ProtocolConfig,
308    original_area: u128,
309    original_ring: &[[i64; 2]],
310    steiner_points: &Steiner,
311    trace: &mut Option<Vec<Attempt>>,
312    candidates: &mut Vec<Candidate>,
313    saw_too_many_parts: &mut bool,
314    last_error: &mut Option<String>,
315) where
316    Steiner: Fn(&[[i64; 2]], &[Vec<[i64; 2]>]) -> Vec<[i64; 2]>,
317{
318    match raw {
319        Ok(parts) => match finalize_parts(parts, config, original_area) {
320            Ok(parts) => {
321                push_attempt(
322                    trace,
323                    strategy.clone(),
324                    rotation,
325                    Outcome::Success {
326                        part_count: parts.len(),
327                    },
328                );
329                let steiner = steiner_points(original_ring, &parts);
330                candidates.push(Candidate {
331                    parts,
332                    strategy,
333                    rotation,
334                    steiner_points: steiner,
335                });
336            }
337            Err(err) => {
338                push_attempt(trace, strategy, rotation, outcome_from_error(&err));
339                match err {
340                    AttemptError::TooManyParts { .. } => *saw_too_many_parts = true,
341                    AttemptError::ValidationFailed(errors) => {
342                        *last_error = Some(errors.join("; "));
343                    }
344                    AttemptError::Failed(message) => *last_error = Some(message),
345                }
346            }
347        },
348        Err(err) => {
349            let formatted = format!("{strategy:?} failed: {err}");
350            push_attempt(
351                trace,
352                strategy,
353                rotation,
354                Outcome::AlgorithmFailed {
355                    error: formatted.clone(),
356                },
357            );
358            *last_error = Some(formatted);
359        }
360    }
361}
362
363fn try_decompose<Exact, Bayazit, EarClip>(
364    ring: &[[i64; 2]],
365    options: &DecomposeOptions,
366    config: &ProtocolConfig,
367    original_area: u128,
368    exact: &Exact,
369    bayazit: &Bayazit,
370    ear_clip: &EarClip,
371    rotation: usize,
372    trace: &mut Option<Vec<Attempt>>,
373) -> Result<StrategySuccess, AttemptError>
374where
375    Exact: Fn(&[[i64; 2]]) -> Result<Parts, String>,
376    Bayazit: Fn(&[[i64; 2]], bool, &ProtocolConfig) -> Result<Parts, String>,
377    EarClip: Fn(&[[i64; 2]]) -> Result<Parts, String>,
378{
379    let mut saw_too_many_parts = false;
380    let mut last_error: Option<String> = None;
381
382    match exact(ring) {
383        Ok(parts) => match finalize_parts(parts, config, original_area) {
384            Ok(parts) => {
385                push_attempt(
386                    trace,
387                    Strategy::ExactPartition,
388                    rotation,
389                    Outcome::Success {
390                        part_count: parts.len(),
391                    },
392                );
393                return Ok(StrategySuccess {
394                    parts,
395                    strategy: Strategy::ExactPartition,
396                });
397            }
398            Err(err @ AttemptError::TooManyParts { .. }) => {
399                push_attempt(
400                    trace,
401                    Strategy::ExactPartition,
402                    rotation,
403                    outcome_from_error(&err),
404                );
405                saw_too_many_parts = true;
406            }
407            Err(err @ AttemptError::ValidationFailed(_)) => {
408                push_attempt(
409                    trace,
410                    Strategy::ExactPartition,
411                    rotation,
412                    outcome_from_error(&err),
413                );
414                if let AttemptError::ValidationFailed(errors) = err {
415                    last_error = Some(errors.join("; "));
416                }
417            }
418            Err(err @ AttemptError::Failed(_)) => {
419                push_attempt(
420                    trace,
421                    Strategy::ExactPartition,
422                    rotation,
423                    outcome_from_error(&err),
424                );
425                if let AttemptError::Failed(message) = err {
426                    last_error = Some(message);
427                }
428            }
429        },
430        Err(err) => {
431            let error = format!("exact vertex partition failed: {err}");
432            push_attempt(
433                trace,
434                Strategy::ExactPartition,
435                rotation,
436                Outcome::AlgorithmFailed {
437                    error: error.clone(),
438                },
439            );
440            last_error = Some(error);
441        }
442    }
443
444    if options.allow_steiner {
445        match bayazit(ring, true, config) {
446            Ok(parts) => match finalize_parts(parts, config, original_area) {
447                Ok(parts) => {
448                    push_attempt(
449                        trace,
450                        Strategy::Bayazit,
451                        rotation,
452                        Outcome::Success {
453                            part_count: parts.len(),
454                        },
455                    );
456                    return Ok(StrategySuccess {
457                        parts,
458                        strategy: Strategy::Bayazit,
459                    });
460                }
461                Err(err @ AttemptError::TooManyParts { .. }) => {
462                    push_attempt(trace, Strategy::Bayazit, rotation, outcome_from_error(&err));
463                    saw_too_many_parts = true;
464                }
465                Err(err @ AttemptError::ValidationFailed(_)) => {
466                    push_attempt(trace, Strategy::Bayazit, rotation, outcome_from_error(&err));
467                    if let AttemptError::ValidationFailed(errors) = err {
468                        last_error = Some(errors.join("; "));
469                    }
470                }
471                Err(err @ AttemptError::Failed(_)) => {
472                    push_attempt(trace, Strategy::Bayazit, rotation, outcome_from_error(&err));
473                    if let AttemptError::Failed(message) = err {
474                        last_error = Some(message);
475                    }
476                }
477            },
478            Err(err) => {
479                let error = format!("bayazit failed: {err}");
480                push_attempt(
481                    trace,
482                    Strategy::Bayazit,
483                    rotation,
484                    Outcome::AlgorithmFailed {
485                        error: error.clone(),
486                    },
487                );
488                last_error = Some(error);
489            }
490        }
491    }
492
493    match ear_clip(ring) {
494        Ok(parts) => match finalize_parts(optimize_partition(&parts), config, original_area) {
495            Ok(parts) => {
496                push_attempt(
497                    trace,
498                    Strategy::EarClipMerge,
499                    rotation,
500                    Outcome::Success {
501                        part_count: parts.len(),
502                    },
503                );
504                Ok(StrategySuccess {
505                    parts,
506                    strategy: Strategy::EarClipMerge,
507                })
508            }
509            Err(err @ AttemptError::TooManyParts { .. }) => {
510                push_attempt(
511                    trace,
512                    Strategy::EarClipMerge,
513                    rotation,
514                    outcome_from_error(&err),
515                );
516                Err(err)
517            }
518            Err(err @ AttemptError::ValidationFailed(_)) => {
519                push_attempt(
520                    trace,
521                    Strategy::EarClipMerge,
522                    rotation,
523                    outcome_from_error(&err),
524                );
525                if saw_too_many_parts {
526                    Err(AttemptError::TooManyParts {
527                        count: config.max_parts.saturating_add(1),
528                    })
529                } else {
530                    Err(err)
531                }
532            }
533            Err(err @ AttemptError::Failed(_)) => {
534                push_attempt(
535                    trace,
536                    Strategy::EarClipMerge,
537                    rotation,
538                    outcome_from_error(&err),
539                );
540                if saw_too_many_parts {
541                    Err(AttemptError::TooManyParts {
542                        count: config.max_parts.saturating_add(1),
543                    })
544                } else {
545                    Err(err)
546                }
547            }
548        },
549        Err(err) => {
550            let error = format!("ear clipping failed: {err}");
551            push_attempt(
552                trace,
553                Strategy::EarClipMerge,
554                rotation,
555                Outcome::AlgorithmFailed {
556                    error: error.clone(),
557                },
558            );
559            if saw_too_many_parts {
560                Err(AttemptError::TooManyParts {
561                    count: config.max_parts.saturating_add(1),
562                })
563            } else {
564                Err(AttemptError::Failed(last_error.unwrap_or(error)))
565            }
566        }
567    }
568}
569
570fn finalize_parts(
571    parts: Parts,
572    config: &ProtocolConfig,
573    original_area: u128,
574) -> Result<Parts, AttemptError> {
575    if parts.len() > config.max_parts {
576        return Err(AttemptError::TooManyParts { count: parts.len() });
577    }
578
579    let validation_errors = validate_all_parts(&parts, config);
580    if !validation_errors.is_empty() {
581        return Err(AttemptError::ValidationFailed(validation_errors));
582    }
583
584    let part_areas: Vec<u128> = parts.iter().map(|part| twice_area_fp2(part)).collect();
585    if !areas_conserved(original_area, &part_areas) {
586        return Err(AttemptError::ValidationFailed(vec![
587            "area not conserved".into()
588        ]));
589    }
590
591    // On-chain runs `validate_multipart_topology` on every accepted polygon.
592    // Per-part structural checks alone are necessary but not sufficient:
593    // boundary compactness, connectivity, and the no-holes invariant are
594    // group-level properties. If a strategy produces structurally valid parts
595    // whose union would still be rejected on-chain, treat it as a failed
596    // candidate so the cascade can try the next strategy/rotation.
597    if let Err(topology_err) = validate_multipart_topology(&parts, false, config) {
598        return Err(AttemptError::ValidationFailed(vec![format!(
599            "topology: {topology_err}"
600        )]));
601    }
602
603    Ok(parts)
604}
605
606fn validate_all_parts(parts: &[Vec<[i64; 2]>], config: &ProtocolConfig) -> Vec<String> {
607    parts
608        .iter()
609        .enumerate()
610        .filter_map(|(idx, part)| {
611            validate_part(part, config).map(|err| format!("part {idx}: {err}"))
612        })
613        .collect()
614}
615
616fn push_attempt(
617    trace: &mut Option<Vec<Attempt>>,
618    strategy: Strategy,
619    rotation: usize,
620    outcome: Outcome,
621) {
622    if let Some(trace) = trace.as_mut() {
623        trace.push(Attempt {
624            strategy,
625            rotation,
626            outcome,
627        });
628    }
629}
630
631fn outcome_from_error(error: &AttemptError) -> Outcome {
632    match error {
633        AttemptError::TooManyParts { count } => Outcome::TooManyParts { count: *count },
634        AttemptError::ValidationFailed(errors) => Outcome::ValidationFailed {
635            errors: errors.clone(),
636        },
637        AttemptError::Failed(error) => Outcome::AlgorithmFailed {
638            error: error.clone(),
639        },
640    }
641}
642
643fn wrap_rotation(strategy: Strategy, rotation: usize) -> Strategy {
644    if rotation == 0 {
645        strategy
646    } else {
647        Strategy::Rotation {
648            offset: rotation,
649            inner: Box::new(strategy),
650        }
651    }
652}
653
654pub fn collect_steiner_points(original: &[[i64; 2]], parts: &[Vec<[i64; 2]>]) -> Vec<[i64; 2]> {
655    find_steiner_points(original, parts)
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use std::cell::Cell;
662
663    const M: i64 = 1_000_000;
664
665    fn square() -> Vec<[i64; 2]> {
666        vec![[0, 0], [12 * M, 0], [12 * M, 12 * M], [0, 12 * M]]
667    }
668
669    fn triangle() -> Vec<[i64; 2]> {
670        vec![[0, 0], [12 * M, 0], [6 * M, 10 * M]]
671    }
672
673    fn l_shape() -> Vec<[i64; 2]> {
674        vec![
675            [0, 0],
676            [24 * M, 0],
677            [24 * M, 8 * M],
678            [8 * M, 8 * M],
679            [8 * M, 24 * M],
680            [0, 24 * M],
681        ]
682    }
683
684    fn t_shape() -> Vec<[i64; 2]> {
685        vec![
686            [0, 0],
687            [30 * M, 0],
688            [30 * M, 10 * M],
689            [20 * M, 10 * M],
690            [20 * M, 24 * M],
691            [10 * M, 24 * M],
692            [10 * M, 10 * M],
693            [0, 10 * M],
694        ]
695    }
696
697    fn step_shape() -> Vec<[i64; 2]> {
698        vec![
699            [0, 0],
700            [30 * M, 0],
701            [30 * M, 10 * M],
702            [20 * M, 10 * M],
703            [20 * M, 20 * M],
704            [10 * M, 20 * M],
705            [10 * M, 30 * M],
706            [0, 30 * M],
707        ]
708    }
709
710    /// 12-vertex comb with two interior notches. Historically the Rust
711    /// library's per-part compactness rejection broke decomposition of this
712    /// shape (the interior narrow parts produced by the cut strategies
713    /// satisfied every on-chain per-part rule but were thin enough to trip
714    /// the extra Rust-only compactness check). This shape is the minimal
715    /// regression for that bug.
716    fn comb_twelve_vertices() -> Vec<[i64; 2]> {
717        vec![
718            [0, 0],
719            [10 * M, 0],
720            [10 * M, 10 * M],
721            [8 * M, 10 * M],
722            [8 * M, 4 * M],
723            [6 * M, 4 * M],
724            [6 * M, 10 * M],
725            [4 * M, 10 * M],
726            [4 * M, 4 * M],
727            [2 * M, 4 * M],
728            [2 * M, 10 * M],
729            [0, 10 * M],
730        ]
731    }
732
733    fn valid_samples() -> Vec<Vec<[i64; 2]>> {
734        vec![
735            square(),
736            triangle(),
737            l_shape(),
738            t_shape(),
739            step_shape(),
740            comb_twelve_vertices(),
741        ]
742    }
743
744    fn default_opts() -> DecomposeOptions {
745        DecomposeOptions::default()
746    }
747
748    fn merca_config() -> ProtocolConfig {
749        ProtocolConfig::merca()
750    }
751
752    fn single_part_config() -> ProtocolConfig {
753        ProtocolConfig {
754            max_parts: 1,
755            ..ProtocolConfig::merca()
756        }
757    }
758
759    fn assert_valid_decomposition(ring: &[[i64; 2]], options: &DecomposeOptions) {
760        let result = decompose(ring, options, &merca_config()).unwrap();
761        assert!(!result.parts.is_empty());
762
763        let original_area = twice_area_fp2(&normalize_ring(ring).unwrap());
764        let parts_area: u128 = result.parts.iter().map(|part| twice_area_fp2(part)).sum();
765        assert_eq!(parts_area, original_area);
766
767        for part in &result.parts {
768            assert!(
769                validate_part(part, &merca_config()).is_none(),
770                "invalid part: {part:?}"
771            );
772        }
773    }
774
775    #[test]
776    fn convex_family_stays_single_part() {
777        for ring in [square(), triangle()] {
778            let result = decompose(&ring, &default_opts(), &merca_config()).unwrap();
779            assert_eq!(result.parts.len(), 1, "ring={ring:?}");
780        }
781    }
782
783    /// Regression: on-chain accepts the assembled comb (compactness is a
784    /// boundary property), so the Rust decomposer must also succeed. Before
785    /// removing the per-part compactness check, this shape returned
786    /// `DecompError::Failed` because intermediate narrow parts were rejected
787    /// even though the final polygon would have passed on-chain.
788    #[test]
789    fn comb_polygon_decomposes_and_passes_topology() {
790        let ring = comb_twelve_vertices();
791        let result =
792            decompose(&ring, &default_opts(), &merca_config()).expect("comb must decompose");
793
794        assert!(!result.parts.is_empty());
795        assert!(result.parts.len() <= crate::constants::MAX_PARTS);
796
797        // Every accepted decomposition must satisfy full topology validation
798        // — finalize_parts guarantees this, but we double-check here because
799        // this is the regression anchor for the bug.
800        assert_eq!(
801            crate::topology::validate_multipart_topology(&result.parts, false, &merca_config()),
802            Ok(())
803        );
804    }
805
806    #[test]
807    fn all_sample_polygons_conserve_area() {
808        for ring in valid_samples() {
809            assert_valid_decomposition(&ring, &default_opts());
810        }
811    }
812
813    #[test]
814    fn all_sample_polygons_respect_max_parts() {
815        for ring in valid_samples() {
816            let result = decompose(&ring, &default_opts(), &merca_config()).unwrap();
817            assert!(result.parts.len() <= crate::constants::MAX_PARTS);
818        }
819    }
820
821    #[test]
822    fn all_output_parts_validate_for_sample_family() {
823        for ring in valid_samples() {
824            let result = decompose(&ring, &default_opts(), &merca_config()).unwrap();
825            assert!(
826                validate_all_parts(&result.parts, &merca_config()).is_empty(),
827                "ring={ring:?}"
828            );
829        }
830    }
831
832    #[test]
833    fn clockwise_inputs_normalize_successfully() {
834        for mut ring in valid_samples() {
835            ring.reverse();
836            assert_valid_decomposition(&ring, &default_opts());
837        }
838    }
839
840    #[test]
841    fn steiner_points_empty_when_disallowed() {
842        let options = DecomposeOptions {
843            allow_steiner: false,
844            ..default_opts()
845        };
846
847        for ring in valid_samples() {
848            let result = decompose(&ring, &options, &merca_config()).unwrap();
849            assert!(result.steiner_points.is_empty(), "ring={ring:?}");
850        }
851    }
852
853    #[test]
854    fn too_few_vertices_returns_error() {
855        let ring = vec![[0, 0], [M, 0]];
856        assert!(matches!(
857            decompose(&ring, &default_opts(), &merca_config()),
858            Err(DecompError::TooFewVertices)
859        ));
860    }
861
862    #[test]
863    fn self_intersecting_polygon_returns_error() {
864        let bow_tie = vec![[0, 0], [4 * M, 4 * M], [4 * M, 0], [0, 4 * M]];
865        assert!(matches!(
866            decompose(&bow_tie, &default_opts(), &merca_config()),
867            Err(DecompError::NotSimple)
868        ));
869    }
870
871    #[test]
872    fn cascade_prefers_exact_before_other_strategies() {
873        let bayazit_calls = Cell::new(0usize);
874        let ear_calls = Cell::new(0usize);
875        let ring = square();
876
877        let result = decompose_with_strategies(
878            &ring,
879            &default_opts(),
880            &merca_config(),
881            |poly| Ok(vec![poly.to_vec()]),
882            |_, _, _| {
883                bayazit_calls.set(bayazit_calls.get() + 1);
884                Err("should not run".into())
885            },
886            |_| {
887                ear_calls.set(ear_calls.get() + 1);
888                Err("should not run".into())
889            },
890            |_, _| Vec::new(),
891        )
892        .unwrap();
893
894        assert_eq!(result.parts.len(), 1);
895        assert_eq!(bayazit_calls.get(), 0);
896        assert_eq!(ear_calls.get(), 0);
897    }
898
899    #[test]
900    fn cascade_falls_back_to_bayazit_when_exact_fails() {
901        let bayazit_calls = Cell::new(0usize);
902        let ear_calls = Cell::new(0usize);
903        let ring = square();
904
905        let result = decompose_with_strategies(
906            &ring,
907            &default_opts(),
908            &merca_config(),
909            |_| Err("exact failed".into()),
910            |poly, allow_steiner, _| {
911                bayazit_calls.set(bayazit_calls.get() + 1);
912                assert!(allow_steiner);
913                Ok(vec![poly.to_vec()])
914            },
915            |_| {
916                ear_calls.set(ear_calls.get() + 1);
917                Err("should not run".into())
918            },
919            |_, _| Vec::new(),
920        )
921        .unwrap();
922
923        assert_eq!(result.parts.len(), 1);
924        assert_eq!(bayazit_calls.get(), 1);
925        assert_eq!(ear_calls.get(), 0);
926    }
927
928    #[test]
929    fn cascade_falls_back_to_ear_clip_when_others_fail() {
930        let bayazit_calls = Cell::new(0usize);
931        let ear_calls = Cell::new(0usize);
932        let ring = square();
933
934        let result = decompose_with_strategies(
935            &ring,
936            &default_opts(),
937            &merca_config(),
938            |_| Err("exact failed".into()),
939            |_, _, _| {
940                bayazit_calls.set(bayazit_calls.get() + 1);
941                Err("bayazit failed".into())
942            },
943            |poly| {
944                ear_calls.set(ear_calls.get() + 1);
945                Ok(vec![
946                    vec![poly[0], poly[1], poly[2]],
947                    vec![poly[0], poly[2], poly[3]],
948                ])
949            },
950            |_, _| Vec::new(),
951        )
952        .unwrap();
953
954        assert_eq!(bayazit_calls.get(), 1);
955        assert_eq!(ear_calls.get(), 1);
956        assert!(!result.parts.is_empty());
957        assert!(validate_all_parts(&result.parts, &merca_config()).is_empty());
958        let original_area = twice_area_fp2(&normalize_ring(&ring).unwrap());
959        let parts_area: u128 = result.parts.iter().map(|part| twice_area_fp2(part)).sum();
960        assert_eq!(parts_area, original_area);
961    }
962
963    #[test]
964    fn bayazit_is_skipped_when_steiner_is_disallowed() {
965        let bayazit_calls = Cell::new(0usize);
966        let ring = square();
967        let options = DecomposeOptions {
968            allow_steiner: false,
969            ..default_opts()
970        };
971
972        let result = decompose_with_strategies(
973            &ring,
974            &options,
975            &merca_config(),
976            |_| Err("exact failed".into()),
977            |_, _, _| {
978                bayazit_calls.set(bayazit_calls.get() + 1);
979                Err("should not run".into())
980            },
981            |poly| {
982                Ok(vec![
983                    vec![poly[0], poly[1], poly[2]],
984                    vec![poly[0], poly[2], poly[3]],
985                ])
986            },
987            |_, _| Vec::new(),
988        )
989        .unwrap();
990
991        assert_eq!(bayazit_calls.get(), 0);
992        assert!(!result.parts.is_empty());
993        assert!(validate_all_parts(&result.parts, &merca_config()).is_empty());
994        assert!(matches!(result.strategy, Strategy::EarClipMerge));
995    }
996
997    #[test]
998    fn rotation_retry_recovers_on_later_start_vertex() {
999        let attempts = Cell::new(0usize);
1000        let ring = square();
1001        let expected_start = ring[1];
1002
1003        let result = decompose_with_strategies(
1004            &ring,
1005            &default_opts(),
1006            &merca_config(),
1007            |poly| {
1008                attempts.set(attempts.get() + 1);
1009                if poly[0] == expected_start {
1010                    Ok(vec![
1011                        vec![poly[0], poly[1], poly[2]],
1012                        vec![poly[0], poly[2], poly[3]],
1013                    ])
1014                } else {
1015                    Err("try another rotation".into())
1016                }
1017            },
1018            |_, _, _| Err("should not run".into()),
1019            |_| Err("should not run".into()),
1020            |_, _| Vec::new(),
1021        )
1022        .unwrap();
1023
1024        assert_eq!(attempts.get(), 2);
1025        assert_eq!(result.parts.len(), 2);
1026    }
1027
1028    #[test]
1029    fn max_rotation_attempts_limits_retry_budget() {
1030        let attempts = Cell::new(0usize);
1031        let ring = square();
1032        let options = DecomposeOptions {
1033            max_rotation_attempts: 1,
1034            ..default_opts()
1035        };
1036
1037        let error = decompose_with_strategies(
1038            &ring,
1039            &options,
1040            &merca_config(),
1041            |poly| {
1042                attempts.set(attempts.get() + 1);
1043                if poly[0] == ring[1] {
1044                    Ok(vec![poly.to_vec()])
1045                } else {
1046                    Err("need more rotations".into())
1047                }
1048            },
1049            |_, _, _| Err("bayazit failed".into()),
1050            |_| Err("ear failed".into()),
1051            |_, _| Vec::new(),
1052        )
1053        .unwrap_err();
1054
1055        assert_eq!(attempts.get(), 1);
1056        assert!(matches!(error, DecompError::Failed(_)));
1057    }
1058
1059    #[test]
1060    fn exact_partition_can_succeed_with_single_part_budget() {
1061        let ring = square();
1062        let result = decompose_with_strategies(
1063            &ring,
1064            &default_opts(),
1065            &single_part_config(),
1066            |poly| Ok(vec![poly.to_vec()]),
1067            |_, _, _| Err("bayazit failed".into()),
1068            |poly| {
1069                Ok(vec![
1070                    vec![poly[0], poly[1], poly[2]],
1071                    vec![poly[0], poly[2], poly[3]],
1072                ])
1073            },
1074            |_, _| Vec::new(),
1075        )
1076        .unwrap();
1077
1078        assert_eq!(result.parts.len(), 1);
1079        assert!(validate_all_parts(&result.parts, &single_part_config()).is_empty());
1080        assert!(matches!(result.strategy, Strategy::ExactPartition));
1081    }
1082
1083    #[test]
1084    fn collect_steiner_points_reports_new_vertices_only() {
1085        let original = square();
1086        let midpoint = [6 * M, 0];
1087        let parts = vec![
1088            vec![original[0], midpoint, original[3]],
1089            vec![midpoint, original[1], original[2], original[3]],
1090        ];
1091
1092        assert_eq!(collect_steiner_points(&original, &parts), vec![midpoint]);
1093    }
1094
1095    #[test]
1096    fn collect_steiner_points_from_bayazit_parts_stays_within_bounds() {
1097        let ring = comb_twelve_vertices();
1098        let midpoint = [6 * M, 0];
1099        let parts = vec![
1100            vec![ring[0], midpoint, ring[5]],
1101            vec![midpoint, ring[1], ring[2], ring[3], ring[4], ring[5]],
1102        ];
1103
1104        let steiner = collect_steiner_points(&ring, &parts);
1105        assert_eq!(steiner, vec![midpoint]);
1106
1107        let (min_x, max_x) = ring.iter().fold((i64::MAX, i64::MIN), |(min_x, max_x), v| {
1108            (min_x.min(v[0]), max_x.max(v[0]))
1109        });
1110        let (min_y, max_y) = ring.iter().fold((i64::MAX, i64::MIN), |(min_y, max_y), v| {
1111            (min_y.min(v[1]), max_y.max(v[1]))
1112        });
1113
1114        for point in steiner {
1115            assert!(!ring.contains(&point), "{point:?} should be new");
1116            assert!(point[0] >= min_x && point[0] <= max_x);
1117            assert!(point[1] >= min_y && point[1] <= max_y);
1118        }
1119    }
1120
1121    #[test]
1122    fn hertel_mehlhorn_optimizes_ear_clip_fallback() {
1123        let ring = vec![
1124            [0, 0],
1125            [20 * M, 0],
1126            [20 * M, 10 * M],
1127            [10 * M, 10 * M],
1128            [10 * M, 20 * M],
1129            [0, 20 * M],
1130        ];
1131
1132        let result = decompose_with_strategies(
1133            &ring,
1134            &default_opts(),
1135            &merca_config(),
1136            |_| Err("exact failed".into()),
1137            |_, _, _| Err("bayazit failed".into()),
1138            |poly| ear_clip_triangulate(poly),
1139            |_, _| Vec::new(),
1140        )
1141        .unwrap();
1142
1143        assert!(result.parts.len() < 4);
1144        assert!(result.parts.len() >= 2);
1145    }
1146
1147    #[test]
1148    fn trace_disabled_by_default() {
1149        let ring = vec![[0i64, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
1150        let result = decompose(&ring, &DecomposeOptions::default(), &merca_config()).unwrap();
1151        assert!(result.trace.is_none());
1152    }
1153
1154    #[test]
1155    fn trace_enabled_records_attempts() {
1156        let ring = vec![[0i64, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
1157        let opts = DecomposeOptions {
1158            collect_trace: true,
1159            ..Default::default()
1160        };
1161        let result = decompose(&ring, &opts, &merca_config()).unwrap();
1162        let trace = result.trace.unwrap();
1163        assert!(!trace.is_empty());
1164        assert!(trace
1165            .iter()
1166            .any(|a| matches!(a.outcome, Outcome::Success { .. })));
1167    }
1168
1169    #[test]
1170    fn strategy_reports_correct_winner() {
1171        let square = vec![[0i64, 0], [10 * M, 0], [10 * M, 10 * M], [0, 10 * M]];
1172        let result = decompose(&square, &DecomposeOptions::default(), &merca_config()).unwrap();
1173        assert_eq!(result.parts.len(), 1);
1174        assert!(matches!(
1175            result.strategy,
1176            Strategy::AlreadyConvex | Strategy::ExactPartition
1177        ));
1178    }
1179
1180    #[test]
1181    fn minimize_parts_picks_fewest_across_strategies() {
1182        // Rigged strategies: ExactPartition yields 3 parts, Bayazit yields 2,
1183        // EarClipMerge yields 4. Cascade mode would short-circuit on Exact
1184        // (3 parts). minimize_parts mode must reach past Exact and return
1185        // the Bayazit result (2 parts).
1186        let ring = square();
1187        let opts = DecomposeOptions {
1188            minimize_parts: true,
1189            ..default_opts()
1190        };
1191
1192        let result = decompose_with_strategies(
1193            &ring,
1194            &opts,
1195            &merca_config(),
1196            |poly| {
1197                // 3 parts — split the square into three thin strips.
1198                let y0 = poly[0][1];
1199                let y1 = poly[2][1];
1200                let third = (y1 - y0) / 3;
1201                let a = [poly[0][0], y0 + third];
1202                let b = [poly[1][0], y0 + third];
1203                let c = [poly[0][0], y0 + 2 * third];
1204                let d = [poly[1][0], y0 + 2 * third];
1205                Ok(vec![
1206                    vec![poly[0], poly[1], b, a],
1207                    vec![a, b, d, c],
1208                    vec![c, d, poly[2], poly[3]],
1209                ])
1210            },
1211            |poly, _allow, _| {
1212                // 2 parts — optimal split down the middle.
1213                let mid_bot = [(poly[0][0] + poly[1][0]) / 2, poly[0][1]];
1214                let mid_top = [(poly[2][0] + poly[3][0]) / 2, poly[2][1]];
1215                Ok(vec![
1216                    vec![poly[0], mid_bot, mid_top, poly[3]],
1217                    vec![mid_bot, poly[1], poly[2], mid_top],
1218                ])
1219            },
1220            |poly| {
1221                // 4 parts from a fake triangulation.
1222                Ok(vec![
1223                    vec![poly[0], poly[1], [poly[0][0] + M, poly[0][1] + M]],
1224                    vec![poly[1], poly[2], [poly[0][0] + M, poly[0][1] + M]],
1225                    vec![poly[2], poly[3], [poly[0][0] + M, poly[0][1] + M]],
1226                    vec![poly[3], poly[0], [poly[0][0] + M, poly[0][1] + M]],
1227                ])
1228            },
1229            |_, _| Vec::new(),
1230        )
1231        .unwrap();
1232
1233        assert_eq!(
1234            result.parts.len(),
1235            2,
1236            "minimize_parts should pick the 2-part split"
1237        );
1238        assert!(matches!(
1239            result.strategy,
1240            Strategy::Bayazit | Strategy::Rotation { inner: _, .. }
1241        ));
1242    }
1243
1244    #[test]
1245    fn minimize_parts_breaks_ties_with_exact_over_bayazit() {
1246        // Both ExactPartition and Bayazit return 2 parts with zero Steiner
1247        // points. Tiebreaker order is (parts, steiner, rotation, strategy),
1248        // so Exact must win on the strategy rank. EarClip is disabled here
1249        // because Hertel-Mehlhorn would merge the two mock triangles back
1250        // into a single convex square and steal the win with 1 part —
1251        // which would be correct in the general case but unrelated to the
1252        // tiebreak rule we're testing.
1253        let ring = square();
1254        let opts = DecomposeOptions {
1255            minimize_parts: true,
1256            ..default_opts()
1257        };
1258
1259        let result = decompose_with_strategies(
1260            &ring,
1261            &opts,
1262            &merca_config(),
1263            |poly| {
1264                Ok(vec![
1265                    vec![poly[0], poly[1], poly[2]],
1266                    vec![poly[0], poly[2], poly[3]],
1267                ])
1268            },
1269            |poly, _, _| {
1270                Ok(vec![
1271                    vec![poly[0], poly[1], poly[2]],
1272                    vec![poly[0], poly[2], poly[3]],
1273                ])
1274            },
1275            |_| Err("ear clip disabled for this tiebreak test".into()),
1276            |_, _| Vec::new(),
1277        )
1278        .unwrap();
1279
1280        assert_eq!(result.parts.len(), 2);
1281        assert!(matches!(result.strategy, Strategy::ExactPartition));
1282    }
1283
1284    #[test]
1285    fn minimize_parts_prefers_fewer_steiner_points_on_tie() {
1286        // 2 parts from both strategies, but Bayazit produces one Steiner point.
1287        // Even though Bayazit is called after Exact, the tiebreaker on
1288        // steiner_count should still pick Exact.
1289        let ring = square();
1290        let opts = DecomposeOptions {
1291            minimize_parts: true,
1292            ..default_opts()
1293        };
1294
1295        let result = decompose_with_strategies(
1296            &ring,
1297            &opts,
1298            &merca_config(),
1299            |poly| {
1300                Ok(vec![
1301                    vec![poly[0], poly[1], poly[2]],
1302                    vec![poly[0], poly[2], poly[3]],
1303                ])
1304            },
1305            |poly, _, _| {
1306                // Split with a Steiner point on the bottom edge.
1307                let steiner = [(poly[0][0] + poly[1][0]) / 2, poly[0][1]];
1308                Ok(vec![
1309                    vec![poly[0], steiner, poly[2], poly[3]],
1310                    vec![steiner, poly[1], poly[2]],
1311                ])
1312            },
1313            |_| Err("ear clip skipped".into()),
1314            |original, parts| {
1315                // Report points that are in parts but not in the original ring.
1316                let mut originals: std::collections::HashSet<[i64; 2]> =
1317                    std::collections::HashSet::new();
1318                for v in original {
1319                    originals.insert(*v);
1320                }
1321                let mut result: Vec<[i64; 2]> = Vec::new();
1322                for part in parts {
1323                    for v in part {
1324                        if !originals.contains(v) && !result.contains(v) {
1325                            result.push(*v);
1326                        }
1327                    }
1328                }
1329                result
1330            },
1331        )
1332        .unwrap();
1333
1334        assert_eq!(result.parts.len(), 2);
1335        assert!(matches!(result.strategy, Strategy::ExactPartition));
1336        assert!(result.steiner_points.is_empty());
1337    }
1338
1339    #[test]
1340    fn minimize_parts_falls_through_when_exact_invalid() {
1341        // Exact produces a decomposition that fails validation (too many
1342        // parts). Bayazit succeeds with 2 parts. Result must be Bayazit's,
1343        // without a TooManyParts error bubbling up.
1344        let ring = square();
1345        let opts = DecomposeOptions {
1346            minimize_parts: true,
1347            ..default_opts()
1348        };
1349        let config = ProtocolConfig {
1350            max_parts: 3,
1351            ..merca_config()
1352        };
1353
1354        let result = decompose_with_strategies(
1355            &ring,
1356            &opts,
1357            &config,
1358            |poly| {
1359                // 4 parts → rejected by max_parts=3 in finalize_parts.
1360                Ok(vec![
1361                    vec![poly[0], poly[1], poly[2]],
1362                    vec![poly[0], poly[2], poly[3]],
1363                    vec![poly[0], poly[1], poly[2]],
1364                    vec![poly[0], poly[2], poly[3]],
1365                ])
1366            },
1367            |poly, _, _| {
1368                Ok(vec![
1369                    vec![poly[0], poly[1], poly[2]],
1370                    vec![poly[0], poly[2], poly[3]],
1371                ])
1372            },
1373            |_| Err("ear clip skipped".into()),
1374            |_, _| Vec::new(),
1375        )
1376        .unwrap();
1377
1378        assert_eq!(result.parts.len(), 2);
1379        assert!(matches!(result.strategy, Strategy::Bayazit));
1380    }
1381
1382    #[test]
1383    fn l_shape_trace_shows_multiple_attempts() {
1384        let l_shape = vec![
1385            [0, 0],
1386            [20 * M, 0],
1387            [20 * M, 10 * M],
1388            [10 * M, 10 * M],
1389            [10 * M, 20 * M],
1390            [0, 20 * M],
1391        ];
1392        let opts = DecomposeOptions {
1393            collect_trace: true,
1394            ..Default::default()
1395        };
1396        let result = decompose(&l_shape, &opts, &merca_config()).unwrap();
1397        let trace = result.trace.unwrap();
1398        assert!(!trace.is_empty());
1399        assert!(trace.len() >= 1);
1400    }
1401}