Skip to main content

penrose_memory/
cut_and_project.rs

1//! Generalized cut-and-project compiler.
2//!
3//! Takes a high-dimensional lattice, defines an acceptance window in
4//! perpendicular space, and projects accepted lattice points to a
5//! lower-dimensional aperiodic tiling. The classic Penrose construction
6//! maps 5D → 2D via golden-angle rotations.
7
8#[allow(dead_code)]
9const PHI: f64 = 1.618033988749895;
10#[allow(dead_code)]
11const INV_PHI: f64 = 0.618033988749895;
12#[allow(dead_code)]
13const GOLDEN_ANGLE_RAD: f64 = 2.0 * std::f64::consts::PI / (PHI * PHI); // ≈ 2.399…
14
15/// Tile type classification.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TileType {
18    /// Thick (wide) rhomb — interior angle 72°.
19    Thick,
20    /// Thin (narrow) rhomb — interior angle 36°.
21    Thin,
22    /// Acceptance window rejected this point (should not appear in output).
23    Rejected,
24}
25
26/// Coordinates of a single projected tile.
27#[derive(Debug, Clone)]
28pub struct TileCoord {
29    /// Projected x in target (2D) space.
30    pub x: f64,
31    /// Projected y in target (2D) space.
32    pub y: f64,
33    /// Original lattice coordinates in source space.
34    pub source_coords: Vec<i32>,
35    /// Classified tile type.
36    pub tile_type: TileType,
37}
38
39/// Report from Penrose verification.
40#[derive(Debug, Clone)]
41pub struct PenroseReport {
42    /// Number of tiles generated.
43    pub tile_count: usize,
44    /// Ratio of thick to thin tiles (should approach 1/φ ≈ 0.618).
45    pub thick_thin_ratio: f64,
46    /// Whether the ratio is within tolerance of 1/φ.
47    pub ratio_ok: bool,
48    /// Approximate 5-fold symmetry score (0..1).
49    pub five_fold_score: f64,
50    /// Whether 5-fold symmetry is above threshold.
51    pub five_fold_ok: bool,
52    /// Whether the pattern has no short-period repetition.
53    pub aperiodic: bool,
54    /// Minimum nearest-neighbour distance.
55    pub min_nn_distance: f64,
56    /// Overall pass/fail.
57    pub passes: bool,
58}
59
60/// Generalized cut-and-project compiler.
61pub struct CutAndProjectCompiler {
62    pub(crate) source_dim: usize,
63    pub(crate) target_dim: usize,
64    /// Projection matrix: target_dim × source_dim.
65    pub(crate) projection: Vec<Vec<f64>>,
66    /// Perpendicular-space projection (computed from `projection`).
67    pub(crate) perp_projection: Vec<Vec<f64>>,
68    /// Acceptance window in perpendicular space.
69    pub(crate) window_fn: Box<dyn Fn(&[f64]) -> bool>,
70}
71
72impl CutAndProjectCompiler {
73    /// Create a new compiler with identity-ish projection (will be overridden).
74    pub fn new(source_dim: usize, target_dim: usize) -> Self {
75        assert!(
76            target_dim <= source_dim,
77            "target_dim must be ≤ source_dim"
78        );
79        // Start with zeros; caller should set a real projection.
80        let projection = vec![vec![0.0; source_dim]; target_dim];
81        let perp_dim = source_dim - target_dim;
82        let perp_projection = vec![vec![0.0; source_dim]; perp_dim];
83        Self {
84            source_dim,
85            target_dim,
86            projection,
87            perp_projection,
88            window_fn: Box::new(|_| true),
89        }
90    }
91
92    /// Standard Penrose P3 projection: 5D → 2D with golden-angle rotation.
93    ///
94    /// Each source axis is rotated by k · 2π/5 in the target plane.
95    pub fn with_golden_projection(mut self) -> Self {
96        assert!(
97            self.source_dim == 5 && self.target_dim == 2,
98            "Golden projection requires 5D → 2D"
99        );
100        let angle_step = 2.0 * std::f64::consts::PI / 5.0;
101        for k in 0..5 {
102            let angle = k as f64 * angle_step;
103            self.projection[0][k] = angle.cos();
104            self.projection[1][k] = angle.sin();
105        }
106        self.recompute_perp();
107        // Default window: perpendicular-space coordinates within a strip of
108        // half-width 1/φ centred on the origin.
109        let hw = INV_PHI;
110        self.window_fn = Box::new(move |perp: &[f64]| {
111            perp.iter().all(|&v| v.abs() < hw)
112        });
113        self
114    }
115
116    /// Learned PCA projection from data.
117    ///
118    /// Performs a simple power-iteration PCA on the provided data to find
119    /// the top-`target_dim` principal components, then uses those as the
120    /// projection matrix.
121    pub fn with_pca_projection(mut self, data: &[Vec<f64>]) -> Self {
122        assert!(!data.is_empty(), "Need at least one data vector");
123        assert!(
124            data[0].len() == self.source_dim,
125            "Data dimension must match source_dim"
126        );
127
128        let n = data.len();
129        let d = self.source_dim;
130
131        // Compute mean.
132        let mut mean = vec![0.0; d];
133        for row in data {
134            for (j, &v) in row.iter().enumerate() {
135                mean[j] += v;
136            }
137        }
138        for m in mean.iter_mut() {
139            *m /= n as f64;
140        }
141
142        // Centre the data.
143        let centered: Vec<Vec<f64>> = data
144            .iter()
145            .map(|row| row.iter().enumerate().map(|(j, &v)| v - mean[j]).collect())
146            .collect();
147
148        // Covariance matrix (d × d), upper triangle.
149        let mut cov = vec![vec![0.0; d]; d];
150        for row in &centered {
151            for i in 0..d {
152                for j in i..d {
153                    cov[i][j] += row[i] * row[j];
154                }
155            }
156        }
157        for i in 0..d {
158            for j in i..d {
159                cov[i][j] /= (n - 1).max(1) as f64;
160                cov[j][i] = cov[i][j];
161            }
162        }
163
164        // Power iteration for each target component.
165        let iters = 200;
166        for comp in 0..self.target_dim {
167            let mut vec = vec![0.0; d];
168            vec[comp % d] = 1.0;
169            for _ in 0..iters {
170                let mut new_vec = vec![0.0; d];
171                for i in 0..d {
172                    for j in 0..d {
173                        new_vec[i] += cov[i][j] * vec[j];
174                    }
175                }
176                // Deflate: subtract previously found components.
177                for prev in 0..comp {
178                    let dot: f64 = new_vec
179                        .iter()
180                        .zip(&self.projection[prev])
181                        .map(|(a, b)| a * b)
182                        .sum();
183                    for k in 0..d {
184                        new_vec[k] -= dot * self.projection[prev][k];
185                    }
186                }
187                let norm = new_vec.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-12);
188                for v in new_vec.iter_mut() {
189                    *v /= norm;
190                }
191                vec = new_vec;
192            }
193            self.projection[comp] = vec.clone();
194        }
195
196        self.recompute_perp();
197
198        // Data-driven window: use the spread of perpendicular-space projections.
199        let perp_dim = self.source_dim - self.target_dim;
200        let perp_maxes = if perp_dim > 0 && !centered.is_empty() {
201            let mut maxes = vec![0.0f64; perp_dim];
202            for row in &centered {
203                let perp = self.project_perp(&row.iter().map(|&v| v).collect::<Vec<_>>());
204                for (k, &v) in perp.iter().enumerate() {
205                    maxes[k] = maxes[k].max(v.abs());
206                }
207            }
208            maxes
209        } else {
210            vec![INV_PHI; perp_dim]
211        };
212        self.window_fn = Box::new(move |perp: &[f64]| {
213            perp.iter().enumerate().all(|(k, &v)| v.abs() <= perp_maxes.get(k).copied().unwrap_or(INV_PHI) * 1.5)
214        });
215        self
216    }
217
218    /// Compile the tiling: scan a lattice cube, apply acceptance window,
219    /// project accepted points.
220    pub fn compile(&self, lattice_range: i32) -> Vec<TileCoord> {
221        let mut tiles = Vec::new();
222        self.scan_lattice(&mut tiles, lattice_range, 0, &mut vec![0i32; self.source_dim]);
223        tiles
224    }
225
226    /// Verify that the compiled tiling has Penrose-like properties.
227    pub fn verify_penrose(&self, tiles: &[TileCoord]) -> PenroseReport {
228        let thick = tiles.iter().filter(|t| t.tile_type == TileType::Thick).count();
229        let thin = tiles.iter().filter(|t| t.tile_type == TileType::Thin).count();
230        let total = thick + thin;
231
232        // thick:thin ratio — should approach 1/φ ≈ 0.618.
233        let ratio = if thin > 0 {
234            thick as f64 / thin as f64
235        } else if thick > 0 {
236            f64::INFINITY
237        } else {
238            0.0
239        };
240        let ratio_ok = total > 0 && (ratio - INV_PHI).abs() < 0.15;
241
242        // 5-fold symmetry: rotate all tile coords by 72° and measure overlap.
243        let five_fold_score = if !tiles.is_empty() && self.target_dim == 2 {
244            self.compute_five_fold_score(tiles)
245        } else {
246            1.0
247        };
248        let five_fold_ok = five_fold_score > 0.3;
249
250        // Aperiodicity: check no short-period translational repetition.
251        let aperiodic = self.check_aperiodic(tiles);
252
253        // Minimum nearest-neighbour distance.
254        let min_nn = self.min_nearest_neighbour(tiles);
255
256        let passes = ratio_ok && five_fold_ok && aperiodic && total > 0;
257        PenroseReport {
258            tile_count: total,
259            thick_thin_ratio: ratio,
260            ratio_ok,
261            five_fold_score,
262            five_fold_ok,
263            aperiodic,
264            min_nn_distance: min_nn,
265            passes,
266        }
267    }
268
269    // ── internal helpers ──────────────────────────────────────────
270
271    /// Recompute the perpendicular-space projection via Gram-Schmidt on the
272    /// complement of the row-space of `projection`.
273    fn recompute_perp(&mut self) {
274        let perp_dim = self.source_dim - self.target_dim;
275        self.perp_projection = vec![vec![0.0; self.source_dim]; perp_dim];
276
277        // Gram-Schmidt: start from standard basis, subtract projections onto
278        // existing projection rows, collect remaining orthonormal vectors.
279        let mut basis: Vec<Vec<f64>> = Vec::new();
280        // First add the projection rows (normalised).
281        for row in &self.projection {
282            let norm: f64 = row.iter().map(|v| v * v).sum::<f64>().sqrt();
283            if norm > 1e-12 {
284                basis.push(row.iter().map(|&v| v / norm).collect());
285            }
286        }
287        // Extend with standard basis vectors.
288        for i in 0..self.source_dim {
289            let mut e = vec![0.0; self.source_dim];
290            e[i] = 1.0;
291            // Subtract projections onto existing basis.
292            for b in &basis {
293                let dot: f64 = e.iter().zip(b).map(|(a, b)| a * b).sum();
294                for k in 0..self.source_dim {
295                    e[k] -= dot * b[k];
296                }
297            }
298            let norm: f64 = e.iter().map(|v| v * v).sum::<f64>().sqrt();
299            if norm > 1e-12 {
300                for v in e.iter_mut() {
301                    *v /= norm;
302                }
303                if basis.len() < self.source_dim {
304                    basis.push(e.clone());
305                }
306            }
307        }
308
309        // Fill perp_projection from the extra basis vectors.
310        for (i, row) in basis[self.target_dim..].iter().enumerate() {
311            if i < perp_dim {
312                self.perp_projection[i] = row.clone();
313            }
314        }
315    }
316
317    /// Project source coordinates to target space.
318    fn project(&self, source: &[i32]) -> Vec<f64> {
319        let mut out = vec![0.0; self.target_dim];
320        for (r, row) in self.projection.iter().enumerate() {
321            for (c, &coeff) in row.iter().enumerate() {
322                out[r] += coeff * source[c] as f64;
323            }
324        }
325        out
326    }
327
328    /// Project source coordinates to perpendicular space.
329    fn project_perp(&self, source: &[f64]) -> Vec<f64> {
330        let perp_dim = self.perp_projection.len();
331        let mut out = vec![0.0; perp_dim];
332        for (r, row) in self.perp_projection.iter().enumerate() {
333            for (c, &coeff) in row.iter().enumerate() {
334                out[r] += coeff * source[c];
335            }
336        }
337        out
338    }
339
340    /// Recursive lattice scan.
341    fn scan_lattice(
342        &self,
343        tiles: &mut Vec<TileCoord>,
344        range: i32,
345        dim: usize,
346        coords: &mut Vec<i32>,
347    ) {
348        if dim == self.source_dim {
349            // Project to perpendicular space and check window.
350            let source_f: Vec<f64> = coords.iter().map(|&v| v as f64).collect();
351            let perp = self.project_perp(&source_f);
352            if !(self.window_fn)(&perp) {
353                return;
354            }
355            let target = self.project(coords);
356            let tile_type = self.classify_tile(coords);
357            tiles.push(TileCoord {
358                x: target[0],
359                y: if self.target_dim > 1 { target[1] } else { 0.0 },
360                source_coords: coords.clone(),
361                tile_type,
362            });
363        } else {
364            for v in -range..=range {
365                coords[dim] = v;
366                self.scan_lattice(tiles, range, dim + 1, coords);
367            }
368        }
369    }
370
371    /// Classify a tile as thick or thin using the sum of coordinates mod golden ratio.
372    fn classify_tile(&self, coords: &[i32]) -> TileType {
373        let sum: f64 = coords.iter().map(|&v| (v as f64).abs()).sum::<f64>() * INV_PHI;
374        let frac = sum - sum.floor();
375        if frac < INV_PHI {
376            TileType::Thick
377        } else {
378            TileType::Thin
379        }
380    }
381
382    /// 5-fold symmetry score.
383    fn compute_five_fold_score(&self, tiles: &[TileCoord]) -> f64 {
384        let angle = 2.0 * std::f64::consts::PI / 5.0;
385        let cos_a = angle.cos();
386        let sin_a = angle.sin();
387
388        // Rotate each tile by 72° and check if a close neighbour exists.
389        let mut matched = 0usize;
390        let threshold = 0.5;
391
392        for t in tiles.iter().take(500) {
393            let rx = t.x * cos_a - t.y * sin_a;
394            let ry = t.x * sin_a + t.y * cos_a;
395            let has_match = tiles.iter().take(500).any(|u| {
396                let dx = u.x - rx;
397                let dy = u.y - ry;
398                (dx * dx + dy * dy).sqrt() < threshold
399            });
400            if has_match {
401                matched += 1;
402            }
403        }
404        let n = tiles.iter().take(500).count();
405        if n == 0 { 1.0 } else { matched as f64 / n as f64 }
406    }
407
408    /// Check that the tiling has no short-period repetition.
409    fn check_aperiodic(&self, tiles: &[TileCoord]) -> bool {
410        if tiles.len() < 10 {
411            return true;
412        }
413        // Bin tiles along x-axis and check for periodicity in the bin counts.
414        let n_bins = 50;
415        let mut bins = vec![0u32; n_bins];
416        if tiles.is_empty() {
417            return true;
418        }
419        let x_min = tiles.iter().map(|t| t.x).fold(f64::INFINITY, f64::min);
420        let x_max = tiles.iter().map(|t| t.x).fold(f64::NEG_INFINITY, f64::max);
421        let span = (x_max - x_min).max(1e-12);
422        for t in tiles {
423            let idx = ((t.x - x_min) / span * (n_bins - 1) as f64).round() as usize;
424            let idx = idx.min(n_bins - 1);
425            bins[idx] += 1;
426        }
427        // Check periods 1..10.
428        for period in 1..10 {
429            let mut periodic = true;
430            for i in period..n_bins {
431                if bins[i] != bins[i - period] {
432                    periodic = false;
433                    break;
434                }
435            }
436            if periodic {
437                return false;
438            }
439        }
440        true
441    }
442
443    /// Minimum nearest-neighbour distance.
444    fn min_nearest_neighbour(&self, tiles: &[TileCoord]) -> f64 {
445        if tiles.len() < 2 {
446            return 0.0;
447        }
448        let mut min_d = f64::INFINITY;
449        let limit = tiles.len().min(500);
450        for i in 0..limit {
451            for j in (i + 1)..limit {
452                let dx = tiles[i].x - tiles[j].x;
453                let dy = tiles[i].y - tiles[j].y;
454                let d = (dx * dx + dy * dy).sqrt();
455                if d < min_d {
456                    min_d = d;
457                }
458            }
459        }
460        if min_d == f64::INFINITY { 0.0 } else { min_d }
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    // 1. Golden projection produces tiles.
469    #[test]
470    fn test_golden_projection_produces_tiles() {
471        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
472        let tiles = compiler.compile(3);
473        assert!(!tiles.is_empty(), "Golden projection should produce tiles");
474    }
475
476    // 2. No tiles when window rejects everything.
477    #[test]
478    fn test_reject_all_window() {
479        let mut compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
480        compiler.window_fn = Box::new(|_| false);
481        let tiles = compiler.compile(3);
482        assert!(tiles.is_empty(), "Reject-all window should produce zero tiles");
483    }
484
485    // 3. Tile types are Thick or Thin (never Rejected in output).
486    #[test]
487    fn test_tile_types_valid() {
488        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
489        let tiles = compiler.compile(4);
490        for t in &tiles {
491            assert!(
492                t.tile_type == TileType::Thick || t.tile_type == TileType::Thin,
493                "Output tile should be Thick or Thin, got {:?}",
494                t.tile_type
495            );
496        }
497    }
498
499    // 4. Source coords length matches source_dim.
500    #[test]
501    fn test_source_coords_dimension() {
502        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
503        let tiles = compiler.compile(2);
504        for t in &tiles {
505            assert_eq!(t.source_coords.len(), 5);
506        }
507    }
508
509    // 5. Verify Penrose report runs and returns reasonable results.
510    #[test]
511    fn test_verify_penrose_report() {
512        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
513        let tiles = compiler.compile(5);
514        let report = compiler.verify_penrose(&tiles);
515        assert!(report.tile_count > 0);
516        // The thick:thin ratio should be in a plausible range.
517        assert!(report.thick_thin_ratio > 0.0);
518    }
519
520    // 6. Aperiodicity check on golden projection output.
521    #[test]
522    fn test_aperiodicity() {
523        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
524        let tiles = compiler.compile(6);
525        let report = compiler.verify_penrose(&tiles);
526        assert!(report.aperiodic, "Cut-and-project tiles should be aperiodic");
527    }
528
529    // 7. PCA projection from synthetic data.
530    #[test]
531    fn test_pca_projection() {
532        // Generate synthetic 5D data with clear variance along first 2 axes.
533        let data: Vec<Vec<f64>> = (0..100)
534            .map(|i| {
535                let t = i as f64 * 0.1;
536                vec![t.cos(), t.sin(), 0.01 * t, 0.01 * t, 0.01 * t]
537            })
538            .collect();
539        let compiler = CutAndProjectCompiler::new(5, 2).with_pca_projection(&data);
540        let tiles = compiler.compile(3);
541        // PCA should find the plane of variation and produce tiles.
542        assert!(!tiles.is_empty(), "PCA projection should produce tiles from structured data");
543    }
544
545    // 8. Increasing lattice range produces more tiles.
546    #[test]
547    fn test_more_range_more_tiles() {
548        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
549        let t1 = compiler.compile(2);
550        let t2 = compiler.compile(4);
551        assert!(
552            t2.len() >= t1.len(),
553            "Larger range should produce at least as many tiles: {} vs {}",
554            t2.len(),
555            t1.len()
556        );
557    }
558
559    // 9. Compile with range=0 should produce the origin tile (if accepted).
560    #[test]
561    fn test_range_zero() {
562        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
563        let tiles = compiler.compile(0);
564        // Origin may or may not pass the window; either way it should not panic.
565        for t in &tiles {
566            assert_eq!(t.source_coords.len(), 5);
567        }
568    }
569
570    // 10. Projection matrix dimensions are correct.
571    #[test]
572    fn test_projection_dimensions() {
573        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
574        assert_eq!(compiler.projection.len(), 2);
575        assert_eq!(compiler.projection[0].len(), 5);
576        assert_eq!(compiler.perp_projection.len(), 3);
577        assert_eq!(compiler.perp_projection[0].len(), 5);
578    }
579
580    // 11. Min nearest-neighbour distance is positive for non-trivial tilings.
581    #[test]
582    fn test_min_nn_positive() {
583        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
584        let tiles = compiler.compile(4);
585        if tiles.len() > 1 {
586            let report = compiler.verify_penrose(&tiles);
587            assert!(
588                report.min_nn_distance > 0.0,
589                "Min NN distance should be positive, got {}",
590                report.min_nn_distance
591            );
592        }
593    }
594
595    // 12. 5-fold symmetry on golden projection.
596    #[test]
597    fn test_five_fold_symmetry() {
598        let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
599        let tiles = compiler.compile(6);
600        let report = compiler.verify_penrose(&tiles);
601        assert!(
602            report.five_fold_ok,
603            "Golden projection should have 5-fold symmetry (score={:.3})",
604            report.five_fold_score
605        );
606    }
607}