1#[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); #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TileType {
18 Thick,
20 Thin,
22 Rejected,
24}
25
26#[derive(Debug, Clone)]
28pub struct TileCoord {
29 pub x: f64,
31 pub y: f64,
33 pub source_coords: Vec<i32>,
35 pub tile_type: TileType,
37}
38
39#[derive(Debug, Clone)]
41pub struct PenroseReport {
42 pub tile_count: usize,
44 pub thick_thin_ratio: f64,
46 pub ratio_ok: bool,
48 pub five_fold_score: f64,
50 pub five_fold_ok: bool,
52 pub aperiodic: bool,
54 pub min_nn_distance: f64,
56 pub passes: bool,
58}
59
60pub struct CutAndProjectCompiler {
62 pub(crate) source_dim: usize,
63 pub(crate) target_dim: usize,
64 pub(crate) projection: Vec<Vec<f64>>,
66 pub(crate) perp_projection: Vec<Vec<f64>>,
68 pub(crate) window_fn: Box<dyn Fn(&[f64]) -> bool>,
70}
71
72impl CutAndProjectCompiler {
73 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 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 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 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 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 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 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 let mut cov = vec![vec![0.0; d]; d];
150 for row in ¢ered {
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 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 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 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 ¢ered {
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 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 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 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 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 let aperiodic = self.check_aperiodic(tiles);
252
253 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 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 let mut basis: Vec<Vec<f64>> = Vec::new();
280 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 for i in 0..self.source_dim {
289 let mut e = vec![0.0; self.source_dim];
290 e[i] = 1.0;
291 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 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 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 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 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 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 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 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 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 fn check_aperiodic(&self, tiles: &[TileCoord]) -> bool {
410 if tiles.len() < 10 {
411 return true;
412 }
413 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 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 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 #[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 #[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 #[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 #[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 #[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 assert!(report.thick_thin_ratio > 0.0);
518 }
519
520 #[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 #[test]
531 fn test_pca_projection() {
532 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 assert!(!tiles.is_empty(), "PCA projection should produce tiles from structured data");
543 }
544
545 #[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 #[test]
561 fn test_range_zero() {
562 let compiler = CutAndProjectCompiler::new(5, 2).with_golden_projection();
563 let tiles = compiler.compile(0);
564 for t in &tiles {
566 assert_eq!(t.source_coords.len(), 5);
567 }
568 }
569
570 #[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 #[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 #[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}