Skip to main content

penrose_memory/
lib.rs

1//! # Penrose Memory
2//!
3//! Aperiodic memory palace for AI agents.
4//!
5//! Embeddings are projected to 2D Penrose coordinates using golden-ratio hashing.
6//! The Fibonacci word determines tile bits (thick:thin → 1/φ). Matching rules
7//! verify valid positions. Recall uses dead reckoning: walk from query toward
8//! stored memories. 3-coloring for sharding, golden hierarchy (φ^k) for deflation.
9//!
10//! ## Quick Start
11//!
12//! ```rust
13//! use penrose_memory::PenroseMemory;
14//!
15//! let mut pm = PenroseMemory::new(4);
16//!
17//! // Store an embedding with content
18//! let id = pm.store(&[0.1, 0.2, 0.3, 0.4], 42);
19//!
20//! // Recall by nearest embedding
21//! let results = pm.recall(&[0.1, 0.2, 0.3, 0.39], 5);
22//! assert!(!results.is_empty());
23//! assert_eq!(results[0].content, 42);
24//! ```
25
26pub mod cut_and_project;
27pub mod compiler;
28
29const PHI: f64 = 1.618033988749895;
30const INV_PHI: f64 = 0.618033988749895;
31const GOLDEN_ANGLE: f64 = 2.399963229728653; // π(3 − √5)
32
33/// Result of a recall operation.
34#[derive(Debug, Clone)]
35pub struct RecallResult {
36    pub tile_id: u64,
37    pub content: u64,
38    pub confidence: f64,
39    pub distance: f64,
40    pub heading: f64,
41}
42
43/// Internal tile stored in the memory.
44#[derive(Debug, Clone)]
45#[allow(dead_code)]
46struct Tile {
47    tile_id: u64,
48    content: u64,
49    x: f64,
50    y: f64,
51    color: u8,     // 3-coloring: 0, 1, or 2
52    level: u32,    // golden hierarchy level
53}
54
55/// Aperiodic memory palace navigated by dead reckoning.
56///
57/// Embeddings are projected to 2D Penrose coordinates using golden-ratio hashing.
58/// The Fibonacci word determines tile bits. Recall walks from query toward stored
59/// memories via dead reckoning.
60pub struct PenroseMemory {
61    embedding_dim: usize,
62    tiles: Vec<Tile>,
63    next_id: u64,
64}
65
66impl PenroseMemory {
67    /// Create a new PenroseMemory with the given embedding dimension.
68    pub fn new(embedding_dim: usize) -> Self {
69        Self {
70            embedding_dim,
71            tiles: Vec::new(),
72            next_id: 1,
73        }
74    }
75
76    /// Project an embedding to 2D Penrose coordinates using golden-ratio hashing.
77    ///
78    /// Each dimension contributes a golden-angle rotated component, producing
79    /// an aperiodic 2D point from any embedding vector.
80    fn project_to_2d(&self, embedding: &[f64]) -> (f64, f64) {
81        let mut x = 0.0_f64;
82        let mut y = 0.0_f64;
83        let dim = self.embedding_dim.min(embedding.len());
84
85        for i in 0..dim {
86            let val = if i < embedding.len() { embedding[i] } else { 0.0 };
87            let angle = (i as f64) * GOLDEN_ANGLE;
88            let magnitude = val.abs();
89            x += magnitude * angle.cos();
90            y += magnitude * angle.sin();
91        }
92
93        // Normalize by dimension to keep coordinates bounded
94        let scale = if dim > 0 { 1.0 / (dim as f64).sqrt() } else { 1.0 };
95        (x * scale, y * scale)
96    }
97
98    /// Fibonacci word bit at a given lattice position.
99    ///
100    /// Uses golden ratio hashing: bit n is thick (1) if ⌊(n+1)/φ⌋ ≠ ⌊n/φ⌋.
101    /// The ratio of thick:thin converges to 1/φ ≈ 0.618.
102    fn tile_bit(&self, qx: i64, qy: i64) -> bool {
103        // Hash the 2D lattice position to a 1D index using golden-ratio mixing
104        let hash = (qx.wrapping_mul(0x9E3779B97F4A7C15u64 as i64))
105            .wrapping_add(qy.wrapping_mul(0x517CC1B727220A95u64 as i64));
106        let idx = (hash.wrapping_abs() % 10000) as u64;
107
108        let current = ((idx as f64) * INV_PHI).floor() as u64;
109        let next = (((idx + 1) as f64) * INV_PHI).floor() as u64;
110        next != current
111    }
112
113    /// Compute the 3-coloring of a lattice position.
114    ///
115    /// Uses a hash mod 3 to assign one of three colors.
116    /// For valid Penrose-like tilings, every tile gets a unique color
117    /// such that no two adjacent same-color tiles exist.
118    fn three_color(&self, qx: i64, qy: i64) -> u8 {
119        let hash = (qx.wrapping_mul(0x517CC1B727220A95u64 as i64))
120            .wrapping_add(qy.wrapping_mul(0x9E3779B97F4A7C15u64 as i64));
121        (hash.wrapping_abs() % 3) as u8
122    }
123
124    /// Quantize continuous 2D coordinates to a lattice position.
125    fn quantize(&self, x: f64, y: f64) -> (i64, i64) {
126        let scale = PHI;
127        (
128            (x / scale).round() as i64,
129            (y / scale).round() as i64,
130        )
131    }
132
133    /// Compute Euclidean distance between two 2D points.
134    fn euclidean_distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
135        ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
136    }
137
138    /// Compute heading (angle in radians) from point 1 to point 2.
139    fn heading_to(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
140        (y2 - y1).atan2(x2 - x1)
141    }
142
143    /// Store an embedding with associated content.
144    ///
145    /// Projects the embedding to 2D Penrose coordinates and stores it.
146    /// Returns the tile_id.
147    pub fn store(&mut self, embedding: &[f64], content: u64) -> u64 {
148        let (x, y) = self.project_to_2d(embedding);
149        let (qx, qy) = self.quantize(x, y);
150        let color = self.three_color(qx, qy);
151        let id = self.next_id;
152        self.next_id += 1;
153
154        self.tiles.push(Tile {
155            tile_id: id,
156            content,
157            x,
158            y,
159            color,
160            level: 0,
161        });
162
163        id
164    }
165
166    /// Recall memories by dead reckoning from a query embedding.
167    ///
168    /// Projects the query to 2D, then finds the closest stored tiles.
169    /// Dead reckoning: we walk from the query point toward stored memories,
170    /// measuring distance and confidence at each step.
171    ///
172    /// `max_steps` controls how many intermediate waypoints to check along
173    /// the path to each candidate.
174    pub fn recall(&self, query: &[f64], max_steps: usize) -> Vec<RecallResult> {
175        if self.tiles.is_empty() {
176            return Vec::new();
177        }
178
179        let (qx, qy) = self.project_to_2d(query);
180
181        let mut results: Vec<RecallResult> = self.tiles.iter().map(|tile| {
182            let dist = Self::euclidean_distance(qx, qy, tile.x, tile.y);
183            let heading = Self::heading_to(qx, qy, tile.x, tile.y);
184
185            // Confidence from distance: 1.0 at zero distance, decaying with distance
186            // Using a Gaussian-like falloff
187            let sigma = 2.0; // scale parameter
188            let confidence = (-dist * dist / (2.0 * sigma * sigma)).exp();
189
190            // Dead reckoning: verify path by checking intermediate points
191            let mut path_confidence = 1.0;
192            if max_steps > 0 && dist > 0.0 {
193                let mut verified = 0usize;
194                let mut total = 0usize;
195                for step in 1..=max_steps {
196                    let t = step as f64 / (max_steps + 1) as f64;
197                    let ix = qx + t * (tile.x - qx);
198                    let iy = qy + t * (tile.y - qy);
199                    let (iqx, iqy) = self.quantize(ix, iy);
200
201                    // Check matching rule at intermediate point
202                    let bit = self.tile_bit(iqx, iqy);
203                    let neighbors = [
204                        (iqx + 1, iqy), (iqx - 1, iqy),
205                        (iqx, iqy + 1), (iqx, iqy - 1),
206                    ];
207                    let any_diff = neighbors.iter().any(|&(nx, ny)| self.tile_bit(nx, ny) != bit);
208                    if any_diff { verified += 1; }
209                    total += 1;
210                }
211                path_confidence = if total > 0 { verified as f64 / total as f64 } else { 1.0 };
212            }
213
214            RecallResult {
215                tile_id: tile.tile_id,
216                content: tile.content,
217                confidence: confidence * path_confidence,
218                distance: dist,
219                heading,
220            }
221        }).collect();
222
223        // Sort by confidence descending (closest first)
224        results.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
225        results
226    }
227
228    /// Navigate from a tile by distance and heading.
229    ///
230    /// Dead reckoning navigation: start from the given tile, walk the specified
231    /// distance at the given heading, and find tiles near the destination.
232    /// Returns tile IDs of tiles found within the arrival radius.
233    pub fn navigate(&self, tile_id: u64, distance: f64, heading: f64) -> Vec<u64> {
234        let start = match self.tiles.iter().find(|t| t.tile_id == tile_id) {
235            Some(t) => (t.x, t.y),
236            None => return Vec::new(),
237        };
238
239        // Walk by dead reckoning
240        let dest_x = start.0 + distance * heading.cos();
241        let dest_y = start.1 + distance * heading.sin();
242
243        // Arrival radius scales with φ
244        let arrival_radius = PHI * 0.5;
245
246        self.tiles.iter()
247            .filter(|t| {
248                let d = Self::euclidean_distance(dest_x, dest_y, t.x, t.y);
249                d <= arrival_radius
250            })
251            .map(|t| t.tile_id)
252            .collect()
253    }
254
255    /// Consolidate (deflate) old memories using golden hierarchy.
256    ///
257    /// Tiles at level 0 that are close together (within φ^1 distance) are
258    /// merged into a single tile at level 1. The merged content is XOR'd.
259    /// Returns the number of tiles removed.
260    pub fn consolidate(&mut self) {
261        if self.tiles.len() < 2 {
262            return;
263        }
264
265        let merge_distance = PHI; // φ^1
266
267        // Find clusters of level-0 tiles
268        let mut merged: Vec<bool> = vec![false; self.tiles.len()];
269        let mut new_tiles: Vec<Tile> = Vec::new();
270
271        for i in 0..self.tiles.len() {
272            if merged[i] || self.tiles[i].level > 0 {
273                continue;
274            }
275
276            let mut cluster_x = self.tiles[i].x;
277            let mut cluster_y = self.tiles[i].y;
278            let mut cluster_content = self.tiles[i].content;
279            let mut cluster_size = 1usize;
280            let mut cluster_ids = vec![i];
281
282            for j in (i + 1)..self.tiles.len() {
283                if merged[j] || self.tiles[j].level > 0 {
284                    continue;
285                }
286                let d = Self::euclidean_distance(cluster_x, cluster_y, self.tiles[j].x, self.tiles[j].y);
287                if d < merge_distance {
288                    cluster_content ^= self.tiles[j].content;
289                    cluster_x = (cluster_x * cluster_size as f64 + self.tiles[j].x) / (cluster_size + 1) as f64;
290                    cluster_y = (cluster_y * cluster_size as f64 + self.tiles[j].y) / (cluster_size + 1) as f64;
291                    cluster_size += 1;
292                    cluster_ids.push(j);
293                }
294            }
295
296            if cluster_size > 1 {
297                for &idx in &cluster_ids {
298                    merged[idx] = true;
299                }
300                let (qx, qy) = self.quantize(cluster_x, cluster_y);
301                new_tiles.push(Tile {
302                    tile_id: self.next_id,
303                    content: cluster_content,
304                    x: cluster_x,
305                    y: cluster_y,
306                    color: self.three_color(qx, qy),
307                    level: 1,
308                });
309                self.next_id += 1;
310            }
311        }
312
313        // Remove merged tiles, keep unmerged and new
314        let mut kept: Vec<Tile> = self.tiles.drain(..)
315            .enumerate()
316            .filter(|(i, _)| !merged[*i])
317            .map(|(_, t)| t)
318            .collect();
319        kept.extend(new_tiles);
320        self.tiles = kept;
321    }
322
323    /// Number of stored tiles.
324    pub fn len(&self) -> usize {
325        self.tiles.len()
326    }
327
328    /// Check matching rules at a lattice position.
329    /// A tile matches if it has at least one neighbor of the opposite type.
330    pub fn matching_rule_holds(&self, qx: i64, qy: i64) -> bool {
331        let bit = self.tile_bit(qx, qy);
332        let neighbors = [
333            (qx + 1, qy), (qx - 1, qy),
334            (qx, qy + 1), (qx, qy - 1),
335            (qx + 1, qy - 1), (qx - 1, qy + 1),
336        ];
337        neighbors.iter().any(|&(nx, ny)| self.tile_bit(nx, ny) != bit)
338    }
339
340    /// Get the golden-ratio hash bit for a position.
341    pub fn get_tile_bit(&self, qx: i64, qy: i64) -> bool {
342        self.tile_bit(qx, qy)
343    }
344
345    /// Get the 3-coloring for a position.
346    pub fn get_color(&self, qx: i64, qy: i64) -> u8 {
347        self.three_color(qx, qy)
348    }
349
350    /// Check if the memory is empty.
351    pub fn is_empty(&self) -> bool {
352        self.tiles.is_empty()
353    }
354}
355
356impl Default for PenroseMemory {
357    fn default() -> Self {
358        Self::new(1536)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    // 1. Store + recall roundtrip
367    #[test]
368    fn test_store_recall_roundtrip() {
369        let mut pm = PenroseMemory::new(4);
370        let id = pm.store(&[0.1, 0.2, 0.3, 0.4], 42);
371        let results = pm.recall(&[0.1, 0.2, 0.3, 0.4], 3);
372        assert!(!results.is_empty());
373        assert_eq!(results[0].content, 42);
374        assert_eq!(results[0].tile_id, id);
375    }
376
377    // 2. Different embeddings → different tiles
378    #[test]
379    fn test_different_embeddings_different_tiles() {
380        let mut pm = PenroseMemory::new(4);
381        let id1 = pm.store(&[1.0, 0.0, 0.0, 0.0], 1);
382        let id2 = pm.store(&[0.0, 0.0, 0.0, 1.0], 2);
383        assert_ne!(id1, id2);
384
385        let results1 = pm.recall(&[1.0, 0.0, 0.0, 0.0], 3);
386        let results2 = pm.recall(&[0.0, 0.0, 0.0, 1.0], 3);
387        assert_eq!(results1[0].content, 1);
388        assert_eq!(results2[0].content, 2);
389    }
390
391    // 3. Nearby embeddings → nearby tiles (small distance)
392    #[test]
393    fn test_nearby_embeddings_nearby_tiles() {
394        let mut pm = PenroseMemory::new(4);
395        pm.store(&[1.0, 2.0, 3.0, 4.0], 100);
396
397        // Slightly perturbed query
398        let results = pm.recall(&[1.01, 2.01, 3.01, 4.01], 3);
399        assert!(!results.is_empty());
400        assert!(results[0].distance < 0.1, "Nearby embeddings should be close");
401        assert_eq!(results[0].content, 100);
402    }
403
404    // 4. Matching rules hold
405    #[test]
406    fn test_matching_rules_hold() {
407        let pm = PenroseMemory::new(4);
408        let mut valid = 0usize;
409        let total = 1000;
410        for q in 0..total {
411            if pm.matching_rule_holds(q, 0) {
412                valid += 1;
413            }
414        }
415        // Most positions should satisfy matching rules (at least 80%)
416        let ratio = valid as f64 / total as f64;
417        assert!(ratio > 0.75, "Matching rules should hold at most positions, got {}", ratio);
418    }
419
420    // 5. Fibonacci ratio → 1/φ
421    #[test]
422    fn test_fibonacci_ratio() {
423        let pm = PenroseMemory::new(4);
424        let n = 10000;
425        let thick_count = (0..n).filter(|&q| pm.tile_bit(q, 0)).count();
426        let ratio = thick_count as f64 / n as f64;
427        assert!(
428            (ratio - INV_PHI).abs() < 0.03,
429            "Ratio {:.4} should approach 1/φ ≈ {:.4}",
430            ratio, INV_PHI
431        );
432    }
433
434    // 6. 3-coloring covers all positions
435    #[test]
436    fn test_three_coloring_covers_all() {
437        let pm = PenroseMemory::new(4);
438        let mut colors = [false; 3];
439        for q in 0..300 {
440            let c = pm.get_color(q, 0) as usize;
441            assert!(c < 3);
442            colors[c] = true;
443        }
444        // All 3 colors should appear
445        assert!(colors[0] && colors[1] && colors[2], "All 3 colors should appear");
446    }
447
448    // 7. Consolidation reduces count
449    #[test]
450    fn test_consolidation_reduces_count() {
451        let mut pm = PenroseMemory::new(4);
452        // Store many similar embeddings that cluster close together
453        for i in 0..20u64 {
454            let val = 1.0 + (i as f64) * 0.001;
455            pm.store(&[val, 2.0, 3.0, 4.0], i);
456        }
457        let before = pm.len();
458        pm.consolidate();
459        let after = pm.len();
460        assert!(after < before, "Consolidation should reduce tile count: {} -> {}", before, after);
461    }
462
463    // 8. Region fingerprints are unique
464    #[test]
465    fn test_region_fingerprints_unique() {
466        let pm = PenroseMemory::new(4);
467        let mut fingerprints = std::collections::HashSet::new();
468        for q in 0..100i64 {
469            // Fingerprint: 3x3 neighborhood of tile bits
470            let mut fp = 0u16;
471            for dx in -1..=1 {
472                for dy in -1..=1 {
473                    let bit = pm.tile_bit(q + dx, dy) as u16;
474                    fp = (fp << 1) | bit;
475                }
476            }
477            fingerprints.insert(fp);
478        }
479        // Should have many unique fingerprints (aperiodic = many distinct neighborhoods)
480        assert!(fingerprints.len() > 50, "Should have many unique fingerprints, got {}", fingerprints.len());
481    }
482
483    // 9. Aperiodic patterns (no periodicity)
484    #[test]
485    fn test_aperiodic_patterns() {
486        let pm = PenroseMemory::new(4);
487        let pattern: Vec<bool> = (0..100).map(|q| pm.tile_bit(q, 0)).collect();
488
489        // Check no short period repeats
490        for period in 2..20 {
491            let mut periodic = true;
492            for i in period..100 {
493                if pattern[i] != pattern[i - period] {
494                    periodic = false;
495                    break;
496                }
497            }
498            assert!(!periodic, "Pattern should not be periodic with period {}", period);
499        }
500    }
501
502    // 10. Empty recall returns empty
503    #[test]
504    fn test_empty_recall() {
505        let pm = PenroseMemory::new(4);
506        let results = pm.recall(&[1.0, 2.0, 3.0, 4.0], 5);
507        assert!(results.is_empty());
508    }
509
510    // 11. Dead reckoning navigation
511    #[test]
512    fn test_dead_reckoning_navigation() {
513        let mut pm = PenroseMemory::new(4);
514        let id = pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
515        // Navigate from the stored tile
516        let results = pm.navigate(id, 0.0, 0.0);
517        assert!(results.contains(&id), "Zero-distance navigation should find the same tile");
518    }
519
520    // 12. Large embedding (1536 dims) works
521    #[test]
522    fn test_large_embedding() {
523        let mut pm = PenroseMemory::new(1536);
524        let embedding: Vec<f64> = (0..1536).map(|i| (i as f64).sin() * 0.1).collect();
525        let id = pm.store(&embedding, 999);
526        let results = pm.recall(&embedding, 3);
527        assert!(!results.is_empty());
528        assert_eq!(results[0].content, 999);
529        assert_eq!(results[0].tile_id, id);
530    }
531
532    // 13. Store many (1000+) memories
533    #[test]
534    fn test_store_many_memories() {
535        let mut pm = PenroseMemory::new(8);
536        for i in 0..1000u64 {
537            let emb: Vec<f64> = (0..8).map(|j| (i * 8 + j) as f64 * 0.001).collect();
538            pm.store(&emb, i);
539        }
540        assert_eq!(pm.len(), 1000);
541    }
542
543    // 14. Confidence decreases with distance
544    #[test]
545    fn test_confidence_decreases_with_distance() {
546        let mut pm = PenroseMemory::new(4);
547        pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
548
549        // Close query
550        let close = pm.recall(&[1.0, 2.0, 3.0, 4.0], 0);
551        // Far query
552        let far = pm.recall(&[10.0, 20.0, 30.0, 40.0], 0);
553
554        assert!(!close.is_empty());
555        assert!(!far.is_empty());
556        assert!(
557            close[0].confidence > far[0].confidence,
558            "Close confidence ({}) should be > far confidence ({})",
559            close[0].confidence, far[0].confidence
560        );
561    }
562
563    // 15. Multi-step recall finds closer match
564    #[test]
565    fn test_multi_step_recall_finds_closer() {
566        let mut pm = PenroseMemory::new(4);
567        pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
568        pm.store(&[0.0, 0.0, 0.0, 1.0], 20);
569
570        // Query close to the first embedding
571        let results = pm.recall(&[0.9, 0.0, 0.0, 0.0], 5);
572        assert!(!results.is_empty());
573        assert_eq!(results[0].content, 10, "Should find closest match first");
574    }
575}