radix.rs
22.1 kB · rust · 569 lines
1use crate::gauss::Ring;2use std::collections::HashSet;3use std::f64::consts::TAU;45fn divides(ring: Ring, z: (i64, i64), w: (i64, i64)) -> bool {6 let n = ring.norm(w.0, w.1) as i64;7 let (x, y) = ring.mul(z, ring.conjugate(w.0, w.1));8 x % n == 0 && y % n == 09}1011fn argument(ring: Ring, a: i64, b: i64) -> f64 {12 let (x, y) = ring.place(a, b);13 let t = y.atan2(x);14 if t < 0.0 {15 t + TAU16 } else {17 t18 }19}2021/// The base of a radix design: a ring and an element of norm at least two, the scale every word is read against.22#[derive(Clone, Copy, Debug, PartialEq, Eq)]23pub struct Base {24 ring: Ring,25 value: (i64, i64),26}2728impl Base {29 /// Fixes a base in a ring, panicking below norm two.30 pub fn new(ring: Ring, value: (i64, i64)) -> Base {31 assert!(32 ring.norm(value.0, value.1) >= 2,33 "a base needs norm two or more"34 );35 Base { ring, value }36 }37 /// Returns the ring.38 pub fn ring(self) -> Ring {39 self.ring40 }41 /// Returns the base element.42 pub fn value(self) -> (i64, i64) {43 self.value44 }45 /// Returns the norm `q` of the base: the count of residue classes and the square of the scale.46 pub fn norm(self) -> u64 {47 self.ring.norm(self.value.0, self.value.1)48 }49 /// Returns the base raised to a level.50 pub fn power(self, level: usize) -> (i64, i64) {51 let mut out = (1, 0);52 for _ in 0..level {53 out = self.ring.mul(out, self.value);54 }55 out56 }57 /// Returns whether two points are congruent modulo the base.58 pub fn congruent(self, z: (i64, i64), w: (i64, i64)) -> bool {59 divides(self.ring, (z.0 - w.0, z.1 - w.1), self.value)60 }61 /// Returns the canonical complete residue system modulo the base: the `q` representatives of least norm, ties broken by argument in `[0, 2 pi)`.62 ///63 /// The system is built greedily over every point of norm at most `q` sorted by norm and then by argument, which reaches every class because the covering radius of the lattice `b R` is `|b| / sqrt(2)` on the square lattice and `|b| / sqrt(3)` on the hexagonal.64 ///65 /// ```66 /// use mrlynum::gauss::Ring;67 /// use mrlynum::radix::Base;68 /// assert_eq!(Base::new(Ring::Gaussian, (1, 1)).residues(), vec![(0, 0), (1, 0)]);69 /// ```70 pub fn residues(self) -> Vec<(i64, i64)> {71 let q = self.norm();72 let reach = (2 * q).isqrt() as i64 + 1;73 let mut pool = Vec::new();74 for a in -reach..=reach {75 for b in -reach..=reach {76 if self.ring.norm(a, b) <= q {77 pool.push((a, b));78 }79 }80 }81 pool.sort_by(|&(a, b), &(c, d)| {82 let left = (self.ring.norm(a, b), argument(self.ring, a, b));83 let right = (self.ring.norm(c, d), argument(self.ring, c, d));84 left.085 .cmp(&right.0)86 .then_with(|| left.1.total_cmp(&right.1))87 });88 let mut out: Vec<(i64, i64)> = Vec::with_capacity(q as usize);89 for z in pool {90 if out.len() == q as usize {91 break;92 }93 if !out.iter().any(|&w| self.congruent(z, w)) {94 out.push(z);95 }96 }97 assert_eq!(out.len(), q as usize, "the residue system is incomplete");98 out99 }100 /// Returns the index in the canonical residue system of the class of a point.101 pub fn class(self, z: (i64, i64)) -> usize {102 self.residues()103 .iter()104 .position(|&w| self.congruent(z, w))105 .expect("every point lies in a residue class")106 }107 /// Returns whether the conjugate of the base is an associate of the base, which is when the mirror joins the symmetry group.108 pub fn mirrored(self) -> bool {109 let c = self.ring.conjugate(self.value.0, self.value.1);110 self.ring111 .associates(self.value.0, self.value.1)112 .contains(&c)113 }114 /// Returns the symmetry group of the base as permutations of the canonical residue indices: every unit multiplication, and every unit times conjugation when the conjugate of the base is an associate of the base.115 ///116 /// Multiplying every digit by a unit `v` carries the attractor of a design to `v` times that attractor over the same base, so the unit group acts; conjugation carries the base to its conjugate and joins the group exactly when that is an associate.117 ///118 /// The list is the Burnside multiset, one entry per abstract group element, and not the order of the permutation group it induces: the action need not be faithful, so entries repeat, and the acting image is the deduplicated list.119 pub fn group(self) -> Vec<Vec<usize>> {120 let residues = self.residues();121 let mirror = self.mirrored();122 let mut out = Vec::new();123 for unit in self.ring.associates(1, 0) {124 for flip in [false, true] {125 if flip && !mirror {126 continue;127 }128 let map: Vec<usize> = residues129 .iter()130 .map(|&z| {131 let w = if flip {132 self.ring.conjugate(z.0, z.1)133 } else {134 z135 };136 let image = self.ring.mul(unit, w);137 residues138 .iter()139 .position(|&r| self.congruent(image, r))140 .expect("a unit multiple lands in a class")141 })142 .collect();143 out.push(map);144 }145 }146 out147 }148}149150/// A radix design: a digit set inside one ring, placed by a base with a unit twist per digit.151#[derive(Clone, Debug, PartialEq, Eq)]152pub struct Radix {153 base: Base,154 digits: Vec<(i64, i64)>,155 twists: Vec<(i64, i64)>,156}157158impl Radix {159 /// Builds a design from a base, a digit list and a unit twist per digit, panicking on a length mismatch, a twist whose norm is not one, or two digits congruent modulo the base.160 ///161 /// Pairwise incongruent digits are the hypothesis of the untwisted fill law: they are what recovers the last digit from the word read modulo the base, so a repeated class is refused here rather than silently gluing words.162 pub fn new(base: Base, digits: Vec<(i64, i64)>, twists: Vec<(i64, i64)>) -> Radix {163 assert_eq!(digits.len(), twists.len(), "one twist per digit");164 for &(a, b) in &twists {165 assert_eq!(base.ring().norm(a, b), 1, "a twist is a unit");166 }167 for (i, &z) in digits.iter().enumerate() {168 for &w in &digits[..i] {169 assert!(170 !base.congruent(z, w),171 "the digits {w:?} and {z:?} are congruent modulo the base"172 );173 }174 }175 Radix {176 base,177 digits,178 twists,179 }180 }181 /// Builds an untwisted design from a code over the canonical residue system: bit `i` of the code selects residue `i`.182 pub fn from_code(base: Base, code: u128) -> Radix {183 let residues = base.residues();184 assert!(185 code >> residues.len() == 0,186 "code out of range for the base"187 );188 let digits: Vec<(i64, i64)> = residues189 .into_iter()190 .enumerate()191 .filter(|(i, _)| (code >> i) & 1 == 1)192 .map(|(_, z)| z)193 .collect();194 let twists = vec![(1, 0); digits.len()];195 Radix::new(base, digits, twists)196 }197 /// Returns the design with the twists named by their index in the unit list, the units in turning order from one.198 pub fn with_twists(self, units: &[usize]) -> Radix {199 let list = self.base.ring().associates(1, 0);200 let twists = units.iter().map(|&i| list[i]).collect();201 Radix::new(self.base, self.digits, twists)202 }203 /// Returns the base.204 pub fn base(&self) -> Base {205 self.base206 }207 /// Returns the ring.208 pub fn ring(&self) -> Ring {209 self.base.ring()210 }211 /// Returns the digits.212 pub fn digits(&self) -> &[(i64, i64)] {213 &self.digits214 }215 /// Returns the twists.216 pub fn twists(&self) -> &[(i64, i64)] {217 &self.twists218 }219 /// Returns the digit count `|F|`.220 pub fn size(&self) -> usize {221 self.digits.len()222 }223 /// Returns the code of the classes the digits occupy, which names the design only when the digits are the canonical representatives.224 pub fn code(&self) -> u128 {225 let mut out = 0u128;226 for &d in &self.digits {227 out |= 1 << self.base.class(d);228 }229 out230 }231 /// Returns whether every digit is the canonical representative of its class.232 pub fn canonical(&self) -> bool {233 let residues = self.base.residues();234 self.digits.iter().all(|d| residues.contains(d))235 }236 /// Returns the count of words of a level, `|F|^L`.237 pub fn fill(&self, level: usize) -> u128 {238 (self.size() as u128).pow(level as u32)239 }240 /// Returns the similarity dimension `log |F| / log sqrt(q)`, the ratio of the digit count to the scale of the base.241 pub fn dimension(&self) -> f64 {242 (self.size() as f64).ln() / (self.base.norm() as f64).ln() * 2.0243 }244 /// Returns the level-`L` points in exact ring coordinates scaled by `b^L`.245 ///246 /// The place map of digit `d` is `phi_d(x) = (u_d x + d) / b`, a word `d_1 ... d_L` lands on `phi_(d_1)(... phi_(d_L)(0))`, and that point times `b^L` is `sum_(j=1..L) (prod_(i<j) u_(d_i)) d_j b^(L-j)`, which stays in the ring. Words come in digit-lexicographic order, the first digit slowest.247 pub fn words(&self, level: usize) -> Vec<(i64, i64)> {248 let ring = self.ring();249 let mut out = vec![(0i64, 0i64)];250 for step in 1..=level {251 let shift = self.base.power(step - 1);252 let mut next = Vec::with_capacity(out.len() * self.size());253 for (&d, &u) in self.digits.iter().zip(self.twists.iter()) {254 let head = ring.mul(d, shift);255 for &p in &out {256 let t = ring.mul(u, p);257 next.push((head.0 + t.0, head.1 + t.1));258 }259 }260 out = next;261 }262 out263 }264 /// Returns the level-`L` points in the plane, the scaled words divided by `b^L`.265 pub fn plane(&self, level: usize) -> Vec<(f64, f64)> {266 let ring = self.ring();267 let p = self.base.power(level);268 let (px, py) = ring.place(p.0, p.1);269 let den = px * px + py * py;270 self.words(level)271 .into_iter()272 .map(|(a, b)| {273 let (x, y) = ring.place(a, b);274 ((x * px + y * py) / den, (y * px - x * py) / den)275 })276 .collect()277 }278 /// Returns the count of distinct level-`L` points: the glue count, which is the fill exactly when no two words name one point.279 pub fn distinct(&self, level: usize) -> usize {280 self.words(level).into_iter().collect::<HashSet<_>>().len()281 }282}283284/// Returns the Koch curve as a radix design: base `3` on the hexagonal lattice, digits `0, 1, 2 + omega, 2`, twists `1, e^(i pi/3), e^(-i pi/3), 1`.285///286/// The digits are not the canonical residues: `2` and `-1` share a class and the canonical system holds `-1`, so the code alone does not name this design.287pub fn koch() -> Radix {288 let base = Base::new(Ring::Eisenstein, (3, 0));289 let digits = vec![(0, 0), (1, 0), (2, 1), (2, 0)];290 Radix::new(base, digits, vec![(1, 0); 4]).with_twists(&[0, 1, 5, 0])291}292293/// Returns the Sierpinski gasket as a radix design: base `2` on the hexagonal lattice, three of the four residues, code `7`.294pub fn gasket() -> Radix {295 Radix::from_code(Base::new(Ring::Eisenstein, (2, 0)), 7)296}297298/// Returns the twindragon as a radix design: base `1 + i` on the square lattice, the full residue system, code `3`.299pub fn twindragon() -> Radix {300 Radix::from_code(Base::new(Ring::Gaussian, (1, 1)), 3)301}302303/// Returns the terdragon as a radix design: base `2 + omega` on the hexagonal lattice, the full residue system, code `7`, twisted by `1, omega, 1`.304///305/// The untwisted code misses the curve: reading the L-system `F -> F + F - F` at `120` degrees as a turtle, three segments to a level, and normalising by the endpoint gives the segment starts word for word only under this twist.306pub fn terdragon() -> Radix {307 Radix::from_code(Base::new(Ring::Eisenstein, (2, 1)), 7).with_twists(&[0, 2, 0])308}309310/// Returns the flowsnake as a radix design: base `3 + omega` of norm seven on the hexagonal lattice, the full residue system, code `127`.311pub fn flowsnake() -> Radix {312 Radix::from_code(Base::new(Ring::Eisenstein, (3, 1)), 127)313}314315/// Returns the plane design of a cell code as a radix design: base the rational integer `m`, of norm `m^2`, on the square lattice, no twist, digits the box residues `{x + y i : 0 <= x, y < m}`.316///317/// Bit `y m + x` of the code is the cell at row `y` and column `x` of the `m` by `m` tile, the column the real part and the row the imaginary part, so the level-`L` words scaled by `m^L` are exactly the filled cells of the level-`L` tile read as `(column, row)`.318pub fn tile(m: u64, code: u128) -> Radix {319 let base = Base::new(Ring::Gaussian, (m as i64, 0));320 let cells = (m * m) as usize;321 assert!(code >> cells == 0, "code out of range for the base");322 let digits: Vec<(i64, i64)> = (0..cells)323 .filter(|i| (code >> i) & 1 == 1)324 .map(|i| ((i as u64 % m) as i64, (i as u64 / m) as i64))325 .collect();326 let twists = vec![(1, 0); digits.len()];327 Radix::new(base, digits, twists)328}329330#[cfg(test)]331mod tests {332 use super::*;333334 fn mul(z: (f64, f64), w: (f64, f64)) -> (f64, f64) {335 (z.0 * w.0 - z.1 * w.1, z.0 * w.1 + z.1 * w.0)336 }337338 #[test]339 fn the_canonical_residues_are_a_complete_system() {340 let three = Base::new(Ring::Eisenstein, (3, 0));341 assert_eq!(342 three.residues(),343 vec![344 (0, 0),345 (1, 0),346 (1, 1),347 (0, 1),348 (-1, 0),349 (-1, -1),350 (0, -1),351 (2, 1),352 (1, 2)353 ]354 );355 assert_eq!(356 Base::new(Ring::Gaussian, (1, 1)).residues(),357 vec![(0, 0), (1, 0)]358 );359 assert_eq!(360 Base::new(Ring::Gaussian, (2, 1)).residues(),361 vec![(0, 0), (1, 0), (0, 1), (-1, 0), (0, -1)]362 );363 let bases = [364 Base::new(Ring::Gaussian, (2, 0)),365 Base::new(Ring::Gaussian, (1, 1)),366 Base::new(Ring::Gaussian, (2, 1)),367 Base::new(Ring::Gaussian, (3, 0)),368 Base::new(Ring::Eisenstein, (2, 0)),369 Base::new(Ring::Eisenstein, (2, 1)),370 Base::new(Ring::Eisenstein, (3, 0)),371 Base::new(Ring::Eisenstein, (3, 1)),372 ];373 for base in bases {374 let residues = base.residues();375 assert_eq!(residues.len(), base.norm() as usize);376 for (i, &z) in residues.iter().enumerate() {377 for &w in &residues[..i] {378 assert!(!base.congruent(z, w), "{base:?} {z:?} {w:?}");379 }380 }381 for a in -9..=9 {382 for b in -9..=9 {383 let hits = residues384 .iter()385 .filter(|&&r| base.congruent((a, b), r))386 .count();387 assert_eq!(hits, 1, "{base:?} {a} {b}");388 }389 }390 for map in base.group() {391 let seen: HashSet<usize> = map.iter().copied().collect();392 assert_eq!(seen.len(), residues.len(), "{base:?}");393 }394 assert_eq!(395 base.group().len(),396 base.ring().units() * if base.mirrored() { 2 } else { 1 }397 );398 }399 let multiset = Base::new(Ring::Gaussian, (1, 1)).group();400 let image: HashSet<&Vec<usize>> = multiset.iter().collect();401 assert_eq!(multiset.len(), 8);402 assert_eq!(image.len(), 1);403 }404405 #[test]406 #[should_panic(expected = "congruent modulo the base")]407 fn a_congruent_digit_pair_is_refused() {408 let base = Base::new(Ring::Eisenstein, (3, 0));409 assert!(base.congruent((2, 0), (-1, 0)));410 Radix::new(base, vec![(0, 0), (2, 0), (-1, 0)], vec![(1, 0); 3]);411 }412413 #[test]414 fn the_canonical_residues_build_a_design() {415 let bases = [416 Base::new(Ring::Gaussian, (1, 1)),417 Base::new(Ring::Gaussian, (2, 1)),418 Base::new(Ring::Eisenstein, (2, 0)),419 Base::new(Ring::Eisenstein, (3, 0)),420 Base::new(Ring::Eisenstein, (3, 1)),421 ];422 for base in bases {423 let digits = base.residues();424 let twists = vec![(1, 0); digits.len()];425 let design = Radix::new(base, digits, twists);426 assert_eq!(design.size(), base.norm() as usize);427 assert!(design.canonical());428 }429 assert_eq!(koch().size(), 4);430 }431432 #[test]433 fn the_terdragon_is_the_twist_the_l_system_reads() {434 let design = terdragon();435 assert_eq!(design.twists().to_vec(), vec![(1, 0), (0, 1), (1, 0)]);436 let level = 2;437 let mut word = b"F".to_vec();438 for _ in 0..level {439 let mut next = Vec::with_capacity(word.len() * 5);440 for &c in &word {441 if c == b'F' {442 next.extend_from_slice(b"F+F-F");443 } else {444 next.push(c);445 }446 }447 word = next;448 }449 let root = 3f64.sqrt();450 let step = [(1.0, 0.0), (-0.5, root / 2.0), (-0.5, -root / 2.0)];451 let mut heading = 0usize;452 let mut at = (0.0f64, 0.0f64);453 let mut starts = Vec::new();454 for c in word {455 match c {456 b'F' => {457 starts.push(at);458 at = (at.0 + step[heading].0, at.1 + step[heading].1);459 }460 b'+' => heading = (heading + 1) % 3,461 _ => heading = (heading + 2) % 3,462 }463 }464 let den = at.0 * at.0 + at.1 * at.1;465 let want: Vec<(f64, f64)> = starts466 .into_iter()467 .map(|p| {468 (469 (p.0 * at.0 + p.1 * at.1) / den,470 (p.1 * at.0 - p.0 * at.1) / den,471 )472 })473 .collect();474 let got = design.plane(level);475 assert_eq!(got.len(), 9);476 assert_eq!(want.len(), 9);477 let worst = got478 .iter()479 .zip(want.iter())480 .map(|(a, b)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt())481 .fold(0.0f64, f64::max);482 assert!(worst < 1e-12, "deviation {worst}");483 }484485 #[test]486 fn the_twisted_koch_words_are_the_textbook_maps() {487 let koch = koch();488 assert_eq!(koch.size(), 4);489 assert!(!koch.canonical());490 let root = 3f64.sqrt();491 let maps: [((f64, f64), (f64, f64)); 4] = [492 ((1.0 / 3.0, 0.0), (0.0, 0.0)),493 ((1.0 / 6.0, root / 6.0), (1.0 / 3.0, 0.0)),494 ((1.0 / 6.0, -root / 6.0), (0.5, root / 6.0)),495 ((1.0 / 3.0, 0.0), (2.0 / 3.0, 0.0)),496 ];497 let mut truth = vec![(0.0f64, 0.0f64)];498 for level in 1..=2 {499 let mut next = Vec::new();500 for &(scale, shift) in &maps {501 for &p in &truth {502 let t = mul(scale, p);503 next.push((t.0 + shift.0, t.1 + shift.1));504 }505 }506 truth = next;507 let got = koch.plane(level);508 assert_eq!(got.len(), truth.len());509 assert_eq!(got.len(), 4usize.pow(level as u32));510 let worst = got511 .iter()512 .zip(truth.iter())513 .map(|(a, b)| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt())514 .fold(0.0f64, f64::max);515 assert!(worst < 1e-12, "level {level} deviation {worst}");516 }517 assert_eq!(koch.distinct(2), 16);518 }519520 #[test]521 fn the_real_base_with_no_twist_is_the_plane_cell() {522 let carpet = tile(3, 0b111101111);523 assert_eq!(carpet.size(), 8);524 assert!((carpet.dimension() - 8f64.ln() / 3f64.ln()).abs() < 1e-12);525 assert_eq!(carpet.fill(2), 64);526 let got: HashSet<(i64, i64)> = carpet.words(2).into_iter().collect();527 let mut want = HashSet::new();528 for row in 0..9i64 {529 for col in 0..9i64 {530 let hole = |r: i64, c: i64| r == 1 && c == 1;531 if !hole(row / 3, col / 3) && !hole(row % 3, col % 3) {532 want.insert((col, row));533 }534 }535 }536 assert_eq!(got.len(), 64);537 assert_eq!(got, want);538 }539540 #[test]541 fn the_fill_law_counts_every_word_and_the_twist_only_moves_it() {542 for design in [gasket(), twindragon(), terdragon(), flowsnake(), koch()] {543 for level in 0..=4 {544 assert_eq!(design.words(level).len() as u128, design.fill(level));545 assert!(design.distinct(level) <= design.words(level).len());546 }547 assert!(548 (design.dimension() * (design.base().norm() as f64).ln() / 2.0549 - (design.size() as f64).ln())550 .abs()551 < 1e-12552 );553 }554 let plain = twindragon();555 let turned = plain.clone().with_twists(&[0, 1]);556 assert_eq!(plain.words(4).len(), turned.words(4).len());557 assert_ne!(plain.words(4), turned.words(4));558 let glue = Radix::from_code(Base::new(Ring::Gaussian, (2, 0)), 3).with_twists(&[0, 2]);559 assert_eq!(glue.words(2), vec![(0, 0), (1, 0), (2, 0), (1, 0)]);560 assert_eq!(glue.fill(2), 4);561 assert_eq!(glue.distinct(2), 3);562 assert_eq!(gasket().code(), 7);563 assert_eq!(flowsnake().code(), 127);564 assert!((gasket().dimension() - 3f64.ln() / 2f64.ln()).abs() < 1e-12);565 assert!((twindragon().dimension() - 2.0).abs() < 1e-12);566 assert!((terdragon().dimension() - 2.0).abs() < 1e-12);567 assert!((flowsnake().dimension() - 2.0).abs() < 1e-12);568 }569}