tilezz 0.2.0

Utilities to work with perfect-precision polygonal tiles built on top of cyclotomic integer rings.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
//! Tests for the rat_enum pipeline: enumeration/DFS correctness plus the
//! optimization cross-checks (free canonicalization, and the modular /
//! shadow / closure-table prunes). These exercise the library through the
//! same helpers the `rat_enum` binary used to carry inline; they live
//! here now, next to the code they test.

use std::collections::HashSet;

use crate::cyclotomic::IsRing;
use crate::enumerate::canonical::{free_canonical, make_ops};
use crate::enumerate::dfs::rat_enum_with;
use crate::enumerate::prune::Prunes;
use crate::enumerate::stats::DfsStats;
use crate::geom::snake::Snake;

/// Enumerate every simple polygon with boundary length up to
/// `max_steps` over the cyclotomic ring `ZZ`, in canonical-CCW form.
///
/// Depth-first walk: a single `Snake` is mutated in place via
/// [`Snake::add`] / [`Snake::pop`] across the recursion, so the inner
/// loop allocates only when a polygon actually closes (the canonical
/// [`Rat`] needs a fresh vec for hashing). Each polygon's `n` cyclic
/// walks collapse to a single canonical walk via the lex-min
/// rotation prune in [`is_canonical_extended`].
///
/// `step` restricts the DFS to directions that are multiples of `step`.
/// `step = 1` (default) walks every direction. `step = 2` on ZZ20 walks
/// only even-indexed directions, enumerating the ZZ10-equivalent
/// subset; `step = 2` on ZZ24 enumerates the ZZ12 subset; etc.
fn rat_enum<ZZ: IsRing>(max_steps: usize, step: i8) -> (Vec<Vec<i8>>, DfsStats) {
    rat_enum_with::<ZZ, Snake<ZZ>, _>(
        |seq| Snake::<ZZ>::from_slice_trusted(seq),
        max_steps,
        step,
        make_ops(false),
        "enumeration",
        "",
        false,
        &Prunes::default(),
    )
}

/// Free variant of [`rat_enum`].
///
/// Two-stage design (see module-level comment above for rationale):
///
/// 1. [`is_free_canonical_extended`] prunes walk prefixes whose
///    complement rotation is lex-smaller, reducing the search tree
///    (~2.3x speedup at ZZ12 n=10).
///
/// 2. [`free_canonical`] at closure maps the chirality-normalized
///    canonical rotation to the free (lex-min dihedral) form, so both
///    surviving members of each chiral pair hash to the same key.
///
/// Returns the same set as running [`rat_enum`] and quotienting by
/// free equivalence.
fn rat_enum_free<ZZ: IsRing>(max_steps: usize, step: i8) -> (Vec<Vec<i8>>, DfsStats) {
    rat_enum_with::<ZZ, Snake<ZZ>, _>(
        |seq| Snake::<ZZ>::from_slice_trusted(seq),
        max_steps,
        step,
        make_ops(true),
        "free enumeration",
        "free ",
        false,
        &Prunes::default(),
    )
}

#[cfg(test)]
mod free_tests {
    use super::*;
    use crate::cyclotomic::geometry::intersect;
    use crate::cyclotomic::{IsRing, Units, ZZ4, ZZ8, ZZ12};
    use std::collections::HashMap;

    /// Independent validator: reconstruct the polygon from `angles` in
    /// exact ring arithmetic and check three properties without any
    /// dependence on `Snake::can_add`:
    ///   (a) the walk closes at the origin;
    ///   (b) the n vertices visited (origin + n-1 intermediates) are
    ///       all pairwise distinct;
    ///   (c) no two non-adjacent boundary edges share any point
    ///       (proper crossing or shared interior).
    fn validate_simple_polygon<ZZ: IsRing>(angles: &[i8]) -> Result<(), String> {
        let n = angles.len();
        if n < 3 {
            return Err(format!("perimeter {n} < 3"));
        }
        // Reconstruct vertices.
        let mut pts: Vec<ZZ> = Vec::with_capacity(n + 1);
        pts.push(ZZ::zero());
        let mut dir: i64 = 0;
        for &a in angles {
            dir = (dir + a as i64).rem_euclid(ZZ::turn() as i64);
            let next = *pts.last().unwrap() + <ZZ as Units>::unit(dir as i8);
            pts.push(next);
        }
        // (a) closes.
        if !pts.last().unwrap().is_zero() {
            return Err(format!("does not close: last={:?}", pts.last().unwrap()));
        }
        pts.pop(); // drop the closing duplicate; now pts has the n cycle vertices

        // (b) distinct vertices. Use a HashMap to find duplicates and
        // report both indices.
        let mut seen: HashMap<ZZ, usize> = HashMap::new();
        for (i, &p) in pts.iter().enumerate() {
            if let Some(&j) = seen.get(&p) {
                return Err(format!("duplicate vertex: pts[{j}] == pts[{i}]"));
            }
            seen.insert(p, i);
        }

        // (c) no two non-adjacent edges share any point. Brute O(n^2)
        // check via `intersect`. Adjacent edges share an endpoint by
        // construction, so we skip cyclically-adjacent pairs.
        for i in 0..n {
            let a1 = pts[i];
            let a2 = pts[(i + 1) % n];
            for j in (i + 2)..n {
                // Skip the cyclic-adjacent pair (last edge vs first edge).
                if i == 0 && j == n - 1 {
                    continue;
                }
                let b1 = pts[j];
                let b2 = pts[(j + 1) % n];
                if intersect(&(a1, a2), &(b1, b2)) {
                    return Err(format!(
                        "edges {i} ({a1:?}->{a2:?}) and {j} ({b1:?}->{b2:?}) intersect/touch"
                    ));
                }
            }
        }

        Ok(())
    }

    /// Per-boundary-length counts the ZZ12 free enumeration must
    /// match: OEIS A316192 "Number of self-avoiding polygons with
    /// perimeter n and sides = 1 ... not counting rotations and
    /// reflections as distinct" (the boundary path may nowhere touch
    /// or intersect itself).
    ///
    /// Reference values, indexed by perimeter `n`:
    ///   n=1..10 -> [0, 0, 1, 3, 4, 22, 69, 418, 2210, 14024]
    ///
    /// Historical note: this test used to fail at n=9 (2217 vs 2210)
    /// and n=10 (14124 vs 14024). The extras were polygons whose
    /// boundary had a T-touch -- a vertex of one edge sitting on the
    /// strict interior of another edge -- which the segment
    /// intersection predicate missed because the orientation tests
    /// collapsed `wedge == 0` ("on the line") into the same branch
    /// as `wedge < 0` ("right side"). Fixed in `b2df8f4` by adding
    /// explicit endpoint-on-other-segment checks for the colinear
    /// case. T-touches first occur at n=9 on ZZ12, so this test now
    /// covers the bug-sensitive range exactly. See the focused unit
    /// test `test_intersect_zz12_t_touch_endpoint_on_segment` in
    /// `cyclotomic::geometry::tests`.
    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "release-only: full A316192 pin to n>=10 takes ~12 min debug / ~30 s release"
    )]
    fn test_oeis_a316192_zz12() {
        const OEIS: &[(usize, usize)] = &[
            (3, 1),
            (4, 3),
            (5, 4),
            (6, 22),
            (7, 69),
            (8, 418),
            (9, 2210),
            (10, 14024),
        ];
        // Enumerate up to the largest n in the reference. The DFS
        // walks the free variant; output is bucketed
        // per perimeter length.
        let max_n = OEIS.iter().map(|&(n, _)| n).max().unwrap();
        let (rats, _) = super::rat_enum_free::<ZZ12>(max_n, 1);

        // Bucket per perimeter length.
        let mut by_len: std::collections::BTreeMap<usize, usize> =
            std::collections::BTreeMap::new();
        for seq in &rats {
            *by_len.entry(seq.len()).or_insert(0) += 1;
        }

        let mut mismatches: Vec<(usize, usize, usize)> = Vec::new();
        for &(n, expected) in OEIS {
            let got = by_len.get(&n).copied().unwrap_or(0);
            if got != expected {
                mismatches.push((n, got, expected));
            }
        }
        if !mismatches.is_empty() {
            for (n, got, expected) in &mismatches {
                eprintln!(
                    "n={n}: got {got}, expected (OEIS A316192) {expected}, diff {:+}",
                    *got as i64 - *expected as i64
                );
            }
            panic!(
                "free ZZ12 enumeration differs from OEIS A316192 at {} perimeter length(s)",
                mismatches.len()
            );
        }
    }

    #[test]
    fn test_free_output_polygons_are_simple_and_unique() {
        let (free_rats, _) = super::rat_enum_free::<ZZ12>(9, 1);

        // (i) Encodings are pairwise distinct (the HashSet via which
        // they were collected enforces this; assert defensively).
        let unique: std::collections::HashSet<Vec<i8>> = free_rats.iter().cloned().collect();
        assert_eq!(
            unique.len(),
            free_rats.len(),
            "duplicate sequences in free output"
        );

        // (ii) Every polygon is geometrically simple per the
        // independent validator above. Collect failures so a single
        // run reports every case.
        let mut failures: Vec<(Vec<i8>, String)> = Vec::new();
        for seq in &free_rats {
            if let Err(why) = validate_simple_polygon::<ZZ12>(seq) {
                failures.push((seq.clone(), why));
            }
        }
        if !failures.is_empty() {
            eprintln!("{} polygons failed independent validation:", failures.len());
            for (seq, why) in failures.iter().take(20) {
                eprintln!("  {seq:?} -- {why}");
            }
            panic!("non-simple polygons in free enumeration output");
        }
    }

    /// For a single `(ring, max_steps)` pair, check that the
    /// free DFS output equals the rotation DFS output quotiented
    /// by `free_canonical`. Stage-1 over-pruning shows up as
    /// MISSING free classes; stage-2 under-deduplication shows
    /// up as EXTRA. The body prints both lists before asserting so
    /// any future regression is diagnosable from the test log.
    fn check_quotient_match<ZZ: IsRing>(ring_label: &str, max_steps: usize) {
        let (all_rats, _) = super::rat_enum::<ZZ>(max_steps, 1);
        let mut expected: HashSet<Vec<i8>> = HashSet::new();
        for seq in &all_rats {
            expected.insert(super::free_canonical(seq));
        }
        let (free_rats, _) = super::rat_enum_free::<ZZ>(max_steps, 1);
        let actual: HashSet<Vec<i8>> = free_rats.into_iter().collect();

        if expected != actual {
            for s in expected.difference(&actual) {
                eprintln!("[{ring_label} n={max_steps}] MISSING: {s:?}");
            }
            for s in actual.difference(&expected) {
                eprintln!("[{ring_label} n={max_steps}] EXTRA:   {s:?}");
            }
        }
        assert_eq!(
            expected, actual,
            "{ring_label} n={max_steps}: free DFS != rotation DFS / free",
        );
        eprintln!(
            "[{ring_label} n={max_steps}] OK -- {} free classes from {} rotation-canonical rats",
            actual.len(),
            all_rats.len(),
        );
    }

    /// Sweep multiple `(ring, max_steps)` pairs. ZZ4 and ZZ8 are
    /// included to catch ring-specific over-pruning: with only 3 or
    /// 7 candidate directions per step, the per-rotation comparisons
    /// are more constrained and an off-by-one in the prefix prune is
    /// easier to spot than at ZZ12.
    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "release-only: ZZ12 n<=9 + ZZ8 n<=12 dihedral cross-check is ~9 min debug / ~25 s release"
    )]
    fn test_free_enum_matches_dfs_quotient() {
        for n in [4, 5, 6, 7, 8, 9] {
            check_quotient_match::<ZZ12>("ZZ12", n);
        }
        for n in [4, 6, 8, 10, 12] {
            check_quotient_match::<ZZ8>("ZZ8", n);
        }
        for n in [4, 6, 8, 10, 12, 14] {
            check_quotient_match::<ZZ4>("ZZ4", n);
        }
    }

    /// Mechanics of the `--mode list-seeds` + per-seed dispatch:
    /// running the DFS in one shot must yield the same set of
    /// canonical sequences as collecting seeds at some `split_depth`,
    /// then enumerating from each seed and union-ing with the
    /// already-closed polygons. Tested across:
    ///   * small `max_steps` (5-7) for tractable runtime;
    ///   * free on AND off (each uses a different `CanonicalOps`
    ///     pair and a different dedup hash);
    ///   * `split_depth` in 1-3 (1: every root-direction branch is a
    ///     seed; 3: small polygons close before split, exercising
    ///     the `closed` half of the merge).
    ///
    /// This is the regression target for the seed-partition API.
    /// Catches any future drift where collect_seeds and the per-seed
    /// runner stop agreeing on what "the same enumeration" means.
    #[test]
    fn test_seed_partitioning_matches_one_shot() {
        for &n in &[5usize, 6, 7] {
            for &free in &[false, true] {
                for &split_depth in &[1usize, 2, 3] {
                    if split_depth >= n {
                        continue;
                    }
                    let (one_shot_seqs, _) = if free {
                        super::rat_enum_free::<ZZ12>(n, 1)
                    } else {
                        super::rat_enum::<ZZ12>(n, 1)
                    };
                    let one_shot: HashSet<Vec<i8>> = one_shot_seqs.into_iter().collect();

                    let (mut seeded, prefixes) = crate::enumerate::seed::collect_seed_prefixes::<
                        ZZ12,
                    >(n, 1, split_depth, free);
                    for prefix in &prefixes {
                        // Sweep both branches of the n_threads dispatch
                        // (single-threaded direct path AND sub-split +
                        // parallel_drain_seeds) within the same test --
                        // they must produce the same set.
                        for nthreads in &[1usize, 4] {
                            seeded.extend(crate::enumerate::seed::enumerate_from_seed::<ZZ12>(
                                n, 1, prefix, *nthreads, free, false,
                            ));
                        }
                    }

                    assert_eq!(
                        one_shot,
                        seeded,
                        "n={n} free={free} split_depth={split_depth}: \
                         seed-partitioned != one-shot \
                         ({} prefixes + {} pre-closed)",
                        prefixes.len(),
                        seeded.len() - prefixes.iter().map(|_| 0).sum::<usize>(),
                    );
                }
            }
        }
    }
}

/// Correctness tests for the optional DFS prunes.
///
/// For each ring x mode (rotation/free) x thread-count, the test
/// matrix runs the enumeration under every subset of the optional
/// prunes (`mod`, `closure-table`) and asserts the resulting
/// canonical-rat set is bit-identical to the baseline (no prunes). A
/// regression in any single prune -- or any composition -- shows up
/// as a set-equality mismatch on these tests.
///
/// Caps: ring n is chosen so the full sweep stays under ~30 s on a
/// release build. ZZ12 stops at n=8 (no T-touch bug); ZZ8 / ZZ4 go
/// further within their compute budgets.
#[cfg(test)]
mod opt_correctness_tests {
    use super::*;
    use crate::cyclotomic::{ZZ4, ZZ6, ZZ8, ZZ10, ZZ12, ZZ14, ZZ16, ZZ18, ZZ20, ZZ24, ZZ32, ZZ60};
    use crate::enumerate::prune::closure_table::{ClosureTablePrune, collect_closure_keys};
    use crate::enumerate::prune::modular::ModularPrune;
    use crate::enumerate::prune::shadow::ShadowPrune;
    use crate::enumerate::prune::units::unit_vectors_for_ring;
    use crate::enumerate::seed::rat_enum_parallel;
    use std::sync::Arc;

    /// Dispatch helper: collect closure keys for `ring` up to length
    /// `max_l`. Mirrors the per-ring match in `install_closure_table_prune`.
    fn closure_tables_for(ring: u8, max_l: usize) -> rustc_hash::FxHashSet<(Vec<i64>, i8)> {
        crate::dispatch_ring!(ring, collect_closure_keys::<ZZ>(max_l))
    }

    /// Build a `Prunes` for testing per the chosen opt subset.
    /// Both prunes are constructed against `(ring, max_steps)`
    /// regardless of whether they're enabled, so each test case is
    /// self-contained and independent of any global state.
    fn build_prunes(
        ring: u8,
        max_steps: usize,
        with_mod: bool,
        with_ck: bool,
        with_shadow: bool,
    ) -> Prunes {
        let (units, phi) = unit_vectors_for_ring(ring);
        let mut prunes = Prunes::default();
        if with_mod {
            let mp = ModularPrune::build(&units, phi, max_steps, None);
            prunes.modular_prune = Some(Arc::new(mp));
        }
        if with_ck {
            let max_l = 4;
            let keys = closure_tables_for(ring, max_l);
            prunes.closure_table_prune = Some(Arc::new(ClosureTablePrune { max_l, keys }));
        }
        if with_shadow {
            prunes.shadow_prune = Some(Arc::new(ShadowPrune::for_ring(ring)));
        }
        prunes
    }

    /// Run the DFS with explicit prunes on ring `ZZ` and return the
    /// resulting canonical-rat set. Bypasses the global PRUNES static
    /// so different test cases don't fight over it.
    fn run<ZZ: IsRing + Sync>(
        max_steps: usize,
        free: bool,
        n_threads: usize,
        prunes: &Prunes,
    ) -> std::collections::HashSet<Vec<i8>> {
        let ops = make_ops(free);
        let mk = |seq: &[i8]| Snake::<ZZ>::from_slice_trusted(seq);
        let (rats, _) = if n_threads <= 1 {
            rat_enum_with::<ZZ, Snake<ZZ>, _>(mk, max_steps, 1, ops, "test", "", false, prunes)
        } else {
            rat_enum_parallel::<ZZ, Snake<ZZ>, _>(
                mk, max_steps, 1, n_threads, ops, "test", "", false, prunes,
            )
        };
        rats.into_iter().collect()
    }

    /// Enumerate the 4 subsets of {mod, closure-table}.
    fn opt_subsets() -> impl Iterator<Item = (bool, bool, bool)> {
        (0..8).map(|mask| (mask & 1 != 0, mask & 2 != 0, mask & 4 != 0))
    }

    /// Cross-validation matrix: every opt subset x every thread count
    /// must produce the same canonical-rat set as the baseline.
    fn check_ring<ZZ: IsRing + Sync>(ring: u8, max_steps: usize) {
        for &free in &[false, true] {
            let baseline = run::<ZZ>(max_steps, free, 1, &Prunes::default());
            for (with_mod, with_ck, with_shadow) in opt_subsets() {
                let prunes = build_prunes(ring, max_steps, with_mod, with_ck, with_shadow);
                for &n_threads in &[1usize, 4] {
                    let got = run::<ZZ>(max_steps, free, n_threads, &prunes);
                    assert_eq!(
                        got,
                        baseline,
                        "ZZ{ring} n={max_steps} free={free} \
                         mod={with_mod} ck={with_ck} shadow={with_shadow} threads={n_threads}: \
                         result set differs from baseline ({} vs {} rats)",
                        got.len(),
                        baseline.len(),
                    );
                }
            }
        }
    }

    #[test]
    fn cross_validate_zz4() {
        check_ring::<ZZ4>(4, 10);
    }

    #[test]
    fn cross_validate_zz8() {
        check_ring::<ZZ8>(8, 10);
    }

    #[test]
    fn cross_validate_zz12() {
        // Capped at n=8 for runtime budget -- the 32-combo matrix
        // at higher n explodes runtime quickly. The OEIS test below
        // covers ZZ12 free up to n=10 for every opt combo,
        // which is the bug-sensitive range (T-touches first occur
        // at n=9 -- see the historical note in test_oeis_a316192_zz12).
        check_ring::<ZZ12>(12, 8);
    }

    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "release-only: ZZ14 n=7 32-prune-combo cross-check is ~5 min debug / ~15 s release"
    )]
    fn cross_validate_zz14() {
        // Capped at n=7 for runtime budget. ZZ14 has no OEIS oracle
        // for the full-ring counts (A316197 is the bipartite subset),
        // so cross-prune correctness is the strongest independent
        // check we have. Even at n=7, the 32-combo matrix verifies
        // that the cubic-root-based real_sign + multivariate-sign
        // CellFloor produce a consistent rat set across all prune
        // subsets.
        check_ring::<ZZ14>(14, 7);
    }

    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "release-only: ZZ18 n=7 32-prune-combo cross-check is ~13 min debug / ~40 s release"
    )]
    fn cross_validate_zz18() {
        // ZZ18 shares the cubic-root sign infrastructure with ZZ14
        // (different minpoly, same algorithm). Same runtime-budget
        // cap; no OEIS oracle for full-ring counts (A316199 is the
        // bipartite subset).
        check_ring::<ZZ18>(18, 7);
    }

    /// **External verification of ZZ18's sign infrastructure** via a
    /// completely independent code path. ZZ18 contains ZZ6 (since
    /// `6 | 18`), and `--step 3` on ZZ18 restricts the DFS to turns
    /// that are multiples of `pi/3` -- which is exactly ZZ6's unit
    /// turn angle. So ZZ18-step-3 enumerates the same polygons as
    /// ZZ6-step-1, but through ZZ18's algebraic machinery (cubic-
    /// root sign helper, multivariate sign helper, custom CellFloor).
    ///
    /// ZZ6 is OEIS-pinned to A284869 (12 published terms verified by
    /// `oeis_a284869_zz6_pin`). If ZZ18-step-3 matches ZZ6, that
    /// transitively verifies ZZ18's sign machinery against external
    /// data -- otherwise the only oracle for ZZ18 is internal cross-
    /// prune consistency, which uses the same sign code throughout
    /// and so couldn't catch a sign-helper bug.
    ///
    /// Runs to perim 8 (~10 ms each) covering the bug-sensitive
    /// range where our sign infrastructure differs structurally
    /// from the simpler ZZ6 (which uses `sign_m_plus_n_sqrt3`).
    #[test]
    fn cross_validate_zz18_step3_matches_zz6() {
        // ZZ6 reference enumeration (uses `sign_m_plus_n_sqrt3`,
        // which is independently OEIS-verified).
        let (zz6_rats, _) = rat_enum_free::<ZZ6>(8, 1);
        let mut zz6_by_len: std::collections::BTreeMap<usize, usize> =
            std::collections::BTreeMap::new();
        for r in &zz6_rats {
            *zz6_by_len.entry(r.len()).or_insert(0) += 1;
        }

        // ZZ18 step=3 enumeration (uses our new cubic-root sign +
        // multivariate sign + custom CellFloor for the imag axis).
        let (zz18_rats, _) = rat_enum_free::<ZZ18>(8, 3);
        let mut zz18_by_len: std::collections::BTreeMap<usize, usize> =
            std::collections::BTreeMap::new();
        for r in &zz18_rats {
            *zz18_by_len.entry(r.len()).or_insert(0) += 1;
        }

        // Compare per-length counts.
        let mut all_lens: std::collections::BTreeSet<usize> = zz6_by_len.keys().copied().collect();
        all_lens.extend(zz18_by_len.keys().copied());
        let mut mismatches: Vec<(usize, usize, usize)> = Vec::new();
        for &n in &all_lens {
            let z6 = zz6_by_len.get(&n).copied().unwrap_or(0);
            let z18 = zz18_by_len.get(&n).copied().unwrap_or(0);
            if z6 != z18 {
                mismatches.push((n, z6, z18));
            }
        }
        if !mismatches.is_empty() {
            for (n, z6, z18) in &mismatches {
                eprintln!(
                    "perim={n}: ZZ6={z6}, ZZ18-step-3={z18}, diff={:+}",
                    *z18 as i64 - *z6 as i64
                );
            }
            panic!(
                "ZZ18-step-3 disagrees with ZZ6 at {} perimeter(s) -- sign helper or \
                 cell_floor regression in ZZ18",
                mismatches.len()
            );
        }
    }

    /// Generic step-subset cross-check. `ZZ_big --step k` walks only
    /// turns that are multiples of `k`; when `k * small = big` that
    /// enumerates exactly the `ZZ_small`-equivalent polygons -- a
    /// bijection on turn sequences, so the per-perimeter counts must
    /// match. This drives the BIG ring's own sign + cell_floor
    /// machinery (the i128 nested-sqrt helpers, for the deep rings)
    /// yet compares against a ring that is OEIS-pinned, transitively
    /// anchoring the big ring to external data with no brute-force
    /// oracle. `--step k` collapses the effective branching to ~n/k
    /// directions, so at the small `max_steps` used here these stay
    /// in the fast default suite (each well under a second).
    fn assert_step_subset<Big: IsRing, Small: IsRing>(max_steps: usize, step: i8, label: &str) {
        let count_by_len = |rats: &[Vec<i8>]| {
            let mut m = std::collections::BTreeMap::<usize, usize>::new();
            for r in rats {
                *m.entry(r.len()).or_insert(0) += 1;
            }
            m
        };
        let (big, _) = rat_enum_free::<Big>(max_steps, step);
        let (small, _) = rat_enum_free::<Small>(max_steps, 1);
        assert_eq!(
            count_by_len(&big),
            count_by_len(&small),
            "{label}: step-{step} subset counts disagree with the reference ring \
             -- a sign-helper or cell_floor bug in the bigger ring",
        );
    }

    /// Step-subset anchors for the rings WITHOUT their own OEIS
    /// sequence: each reduces (via `--step`) to an OEIS-pinned ring,
    /// transitively verifying its exact-geometry machinery. ZZ14
    /// (= 2*7) is the sole ring with no such reduction and is checked
    /// separately. Small n keeps the suite fast; a deeper sweep lives
    /// in the `#[ignore]` release tests.
    #[test]
    fn zz16_step2_matches_zz8() {
        assert_step_subset::<ZZ16, ZZ8>(8, 2, "zz16_step2");
    }
    #[test]
    fn zz20_step2_matches_zz10() {
        assert_step_subset::<ZZ20, ZZ10>(7, 2, "zz20_step2");
    }
    #[test]
    fn zz24_step2_matches_zz12() {
        assert_step_subset::<ZZ24, ZZ12>(7, 2, "zz24_step2");
    }
    #[test]
    fn zz32_step4_matches_zz8() {
        assert_step_subset::<ZZ32, ZZ8>(6, 4, "zz32_step4");
    }
    #[test]
    fn zz60_step5_matches_zz12() {
        assert_step_subset::<ZZ60, ZZ12>(6, 5, "zz60_step5");
    }

    /// External anchor: OEIS A316192 per-length counts for ZZ12
    /// free matchstick polygons, n=3..=10. The range
    /// deliberately includes n=9, 10 -- the T-touch-sensitive
    /// lengths that the now-fixed segment-intersection bug used to
    /// miscount. Every opt combination must reproduce the OEIS
    /// reference there: a regression in any prune that re-introduces
    /// the missed T-touch (or any other geometry weakness) shows up
    /// as a count mismatch.
    ///
    /// Slow test (~60-90 s release): n=10 baseline alone is ~22s,
    /// repeated across opt subsets. Worth it -- this is the one
    /// test that ties our enumeration to a published external
    /// reference at the bug-sensitive lengths.
    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "release-only: A316192 pin x 32 prune combos is ~17 min debug / ~60-90 s release"
    )]
    fn oeis_a316192_each_opt_combo() {
        const OEIS: &[(usize, usize)] = &[
            (3, 1),
            (4, 3),
            (5, 4),
            (6, 22),
            (7, 69),
            (8, 418),
            (9, 2210),   // T-touches first appear here; bug-fix-sensitive
            (10, 14024), // bug-fix-sensitive
        ];
        let max_n = OEIS.iter().map(|&(n, _)| n).max().unwrap();

        for (with_mod, with_ck, with_shadow) in opt_subsets() {
            let prunes = build_prunes(12, max_n, with_mod, with_ck, with_shadow);
            for &n_threads in &[1usize, 4] {
                let rats = run::<ZZ12>(max_n, true, n_threads, &prunes);
                let mut by_len: std::collections::BTreeMap<usize, usize> =
                    std::collections::BTreeMap::new();
                for seq in &rats {
                    *by_len.entry(seq.len()).or_insert(0) += 1;
                }

                let mut mismatches: Vec<(usize, usize, usize)> = Vec::new();
                for &(n, expected) in OEIS {
                    let got = by_len.get(&n).copied().unwrap_or(0);
                    if got != expected {
                        mismatches.push((n, got, expected));
                    }
                }
                if !mismatches.is_empty() {
                    for (n, got, expected) in &mismatches {
                        eprintln!(
                            "n={n}: got {got}, expected (OEIS A316192) {expected}, diff {:+}",
                            *got as i64 - *expected as i64
                        );
                    }
                    panic!(
                        "OEIS mismatch with mod={with_mod} ck={with_ck} shadow={with_shadow} \
                         threads={n_threads}: {} length(s) differ",
                        mismatches.len()
                    );
                }
            }
        }
    }

    /// Frontier regression guard for the A316192 EXTENSION terms
    /// (ZZ12 free, n >= 11) -- the ones OEIS does not publish and that
    /// we would submit, so they have no external oracle.
    ///
    /// Two oracle-free checks at n=11 (the first unanchored term):
    /// prune-invariance -- the optional prunes (mod + closure-table)
    /// must not change the free count vs the baseline (free/canonical/
    /// reachability prunes only), so an over-aggressive prune that
    /// dropped a valid rat is caught one step past the pinned range;
    /// plus value pins for a(11)=89075 and a(12)=597581 against
    /// silent drift.
    ///
    /// n<=12 only: the unpruned baseline at n>=13 is too slow even
    /// for a release test; deeper terms rely on the prunes'
    /// analytical (n-independent) soundness, the verification gate,
    /// and an independent recompute at submission time (see
    /// docs/oeis-A316200-correction for the recompute methodology).
    #[test]
    #[ignore = "opt-in (cargo test -- --include-ignored): ~4 min; pre-submission / thorough CI \
                guard, not part of the default debug or release suite"]
    fn zz12_extension_frontier_guard() {
        // Prune-invariance at n=11 (all prunes on vs off).
        let none = build_prunes(12, 11, false, false, false);
        let all11 = build_prunes(12, 11, true, true, true);
        let base = by_length(&run::<ZZ12>(11, true, 0, &none));
        let pruned = by_length(&run::<ZZ12>(11, true, 0, &all11));
        assert_eq!(
            base, pruned,
            "ZZ12 free n=11: prunes changed the count -- over-pruning at the frontier",
        );
        // Value pins for the first two extension terms.
        let all12 = build_prunes(12, 12, true, true, true);
        let c12 = by_length(&run::<ZZ12>(12, true, 0, &all12));
        assert_eq!(c12.get(&11).copied(), Some(89075), "ZZ12 a(11) regression");
        assert_eq!(c12.get(&12).copied(), Some(597581), "ZZ12 a(12) regression");
    }

    /// Group an enumerated `Vec<Vec<i8>>` into `len -> count`.
    fn by_length(
        rats: &std::collections::HashSet<Vec<i8>>,
    ) -> std::collections::BTreeMap<usize, usize> {
        let mut by_len = std::collections::BTreeMap::new();
        for seq in rats {
            *by_len.entry(seq.len()).or_insert(0) += 1;
        }
        by_len
    }

    /// Shared pin assertion: enumerate ring `ZZ` in free mode up to
    /// `max_n` with all prunes enabled (single-threaded for
    /// determinism), then assert each `(perim, expected)` pair from
    /// `oeis`. Used by the cheap external-anchor pins below.
    fn assert_oeis_pins<ZZ: IsRing + Sync>(ring: u8, oeis_name: &str, oeis: &[(usize, usize)]) {
        let max_n = oeis.iter().map(|&(n, _)| n).max().unwrap();
        let prunes = build_prunes(ring, max_n, true, true, true);
        let rats = run::<ZZ>(max_n, true, 1, &prunes);
        let by_len = by_length(&rats);
        let mut mismatches: Vec<(usize, usize, usize)> = Vec::new();
        for &(n, expected) in oeis {
            let got = by_len.get(&n).copied().unwrap_or(0);
            if got != expected {
                mismatches.push((n, got, expected));
            }
        }
        if !mismatches.is_empty() {
            for (n, got, expected) in &mismatches {
                eprintln!(
                    "{oeis_name} ZZ{ring} perim={n}: got {got}, OEIS says {expected}, diff {:+}",
                    *got as i64 - *expected as i64
                );
            }
            panic!("{oeis_name} ZZ{ring}: {} term(s) differ", mismatches.len());
        }
    }

    /// External anchor: OEIS A266549 per-length counts for ZZ4
    /// free matchstick polygons on the square lattice. Indexed by
    /// perim 2n (square-lattice polygons only close at even perim).
    /// Independently confirmed by our enumeration through perim 14;
    /// see `docs/notes/oeis.md` for the full overview.
    #[test]
    fn oeis_a266549_zz4_pin() {
        const OEIS: &[(usize, usize)] = &[
            (4, 1),   // a(2)
            (6, 1),   // a(3)
            (8, 3),   // a(4)
            (10, 6),  // a(5)
            (12, 25), // a(6)
            (14, 86), // a(7)
        ];
        assert_oeis_pins::<ZZ4>(4, "A266549", OEIS);
    }

    /// External anchor: OEIS A316198 per-length counts for ZZ8
    /// free matchstick polygons. Indexed by perim 2n. Hugo's
    /// data covers perim 4..12; our perim 14 = 240549 is a new term
    /// we can contribute back to OEIS (see `docs/notes/oeis.md`).
    #[test]
    fn oeis_a316198_zz8_pin() {
        const OEIS: &[(usize, usize)] = &[
            (4, 2),      // a(2)
            (6, 6),      // a(3)
            (8, 59),     // a(4)
            (10, 695),   // a(5)
            (12, 12198), // a(6)
        ];
        assert_oeis_pins::<ZZ8>(8, "A316198", OEIS);
    }

    /// External anchor: OEIS A316200 per-length counts for ZZ10
    /// free matchstick polygons. Pins perim 4..10 ONLY: at
    /// perim 11 our enumeration disagrees with OEIS (we say 9883,
    /// OEIS says 19405) and we believe the OEIS term is in error.
    /// Five independent enumeration paths (ring 10 step 1, ring 20
    /// step 2, single/multi-threaded, with/without prunes, plus the
    /// full streaming pipeline) all return 9883. See `docs/notes/oeis.md`
    /// for the discrepancy analysis.
    #[test]
    fn oeis_a316200_zz10_pin() {
        const OEIS: &[(usize, usize)] = &[
            (4, 2),   // a(4)
            (5, 2),   // a(5)
            (6, 10),  // a(6)
            (7, 15),  // a(7)
            (8, 124), // a(8)
            (9, 352), // a(9)
            (10, 2378), // a(10)
                      // a(11): OEIS says 19405, we say 9883. Pin omitted
                      // until the discrepancy is resolved with OEIS.
        ];
        assert_oeis_pins::<ZZ10>(10, "A316200", OEIS);
    }

    /// External anchor: OEIS A284869 per-length counts for ZZ6
    /// free matchstick polygons on the triangular lattice. This
    /// is the deepest external oracle in our coverage -- A284869
    /// publishes a(3..24), giving us 22 verification anchors against
    /// an independent source. We pin perim 3..14 here (matches first
    /// 12 of the 22 terms exactly) since enumeration past n=14 grows
    /// by ~3-4x per step and becomes test-runtime expensive; deeper
    /// terms are spot-checked manually rather than under CI.
    #[test]
    fn oeis_a284869_zz6_pin() {
        const OEIS: &[(usize, usize)] = &[
            (3, 1),      // a(3)
            (4, 1),      // a(4)
            (5, 1),      // a(5)
            (6, 4),      // a(6)
            (7, 5),      // a(7)
            (8, 16),     // a(8)
            (9, 37),     // a(9)
            (10, 120),   // a(10)
            (11, 344),   // a(11)
            (12, 1175),  // a(12)
            (13, 3807),  // a(13)
            (14, 13224), // a(14)
        ];
        assert_oeis_pins::<ZZ6>(6, "A284869", OEIS);
    }
}