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;
28pub mod tensor_tile;
29
30const PHI: f64 = 1.618033988749895;
31const INV_PHI: f64 = 0.618033988749895;
32const GOLDEN_ANGLE: f64 = 2.399963229728653; // π(3 − √5)
33
34/// Tile lifecycle states — mirrors PLATO v3.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum TileLifecycle {
37    Active,
38    Superseded,
39    Retracted,
40}
41
42impl std::fmt::Display for TileLifecycle {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            TileLifecycle::Active => write!(f, "Active"),
46            TileLifecycle::Superseded => write!(f, "Superseded"),
47            TileLifecycle::Retracted => write!(f, "Retracted"),
48        }
49    }
50}
51
52/// Lamport clock for causal ordering across agents.
53#[derive(Debug, Clone)]
54pub struct LamportClock {
55    time: u64,
56}
57
58impl LamportClock {
59    pub fn new() -> Self { Self { time: 0 } }
60    pub fn tick(&mut self) -> u64 { self.time += 1; self.time }
61    pub fn merge(&mut self, remote: u64) -> u64 { self.time = self.time.max(remote) + 1; self.time }
62    pub fn now(&self) -> u64 { self.time }
63}
64
65/// A prediction about where a memory will be found.
66#[derive(Debug, Clone)]
67pub struct MemoryPrediction {
68    pub predicted_tile_id: Option<u64>,
69    pub predicted_heading: f64,
70    pub predicted_distance: f64,
71    pub lamport: u64,
72    pub confirmed: bool,
73    pub actual_tile_id: Option<u64>,
74    pub actual_distance: Option<f64>,
75}
76
77/// Result of a recall operation.
78#[derive(Debug, Clone)]
79pub struct RecallResult {
80    pub tile_id: u64,
81    pub content: u64,
82    pub confidence: f64,
83    pub distance: f64,
84    pub heading: f64,
85}
86
87/// Internal tile stored in the memory.
88#[derive(Debug, Clone)]
89#[allow(dead_code)]
90struct Tile {
91    tile_id: u64,
92    content: u64,
93    x: f64,
94    y: f64,
95    color: u8,     // 3-coloring: 0, 1, or 2
96    level: u32,    // golden hierarchy level
97    lifecycle: TileLifecycle,
98    lamport: u64,
99}
100
101/// Aperiodic memory palace navigated by dead reckoning.
102///
103/// Embeddings are projected to 2D Penrose coordinates using golden-ratio hashing.
104/// The Fibonacci word determines tile bits. Recall walks from query toward stored
105/// memories via dead reckoning.
106pub struct PenroseMemory {
107    embedding_dim: usize,
108    tiles: Vec<Tile>,
109    next_id: u64,
110}
111
112impl PenroseMemory {
113    /// Create a new PenroseMemory with the given embedding dimension.
114    pub fn new(embedding_dim: usize) -> Self {
115        Self {
116            embedding_dim,
117            tiles: Vec::new(),
118            next_id: 1,
119        }
120    }
121
122    /// Project an embedding to 2D Penrose coordinates using golden-ratio hashing.
123    ///
124    /// Each dimension contributes a golden-angle rotated component, producing
125    /// an aperiodic 2D point from any embedding vector.
126    fn project_to_2d(&self, embedding: &[f64]) -> (f64, f64) {
127        let mut x = 0.0_f64;
128        let mut y = 0.0_f64;
129        let dim = self.embedding_dim.min(embedding.len());
130
131        for i in 0..dim {
132            let val = if i < embedding.len() { embedding[i] } else { 0.0 };
133            let angle = (i as f64) * GOLDEN_ANGLE;
134            let magnitude = val.abs();
135            x += magnitude * angle.cos();
136            y += magnitude * angle.sin();
137        }
138
139        // Normalize by dimension to keep coordinates bounded
140        let scale = if dim > 0 { 1.0 / (dim as f64).sqrt() } else { 1.0 };
141        (x * scale, y * scale)
142    }
143
144    /// Fibonacci word bit at a given lattice position.
145    ///
146    /// Uses golden ratio hashing: bit n is thick (1) if ⌊(n+1)/φ⌋ ≠ ⌊n/φ⌋.
147    /// The ratio of thick:thin converges to 1/φ ≈ 0.618.
148    fn tile_bit(&self, qx: i64, qy: i64) -> bool {
149        // Hash the 2D lattice position to a 1D index using golden-ratio mixing
150        let hash = (qx.wrapping_mul(0x9E3779B97F4A7C15u64 as i64))
151            .wrapping_add(qy.wrapping_mul(0x517CC1B727220A95u64 as i64));
152        let idx = (hash.wrapping_abs() % 10000) as u64;
153
154        let current = ((idx as f64) * INV_PHI).floor() as u64;
155        let next = (((idx + 1) as f64) * INV_PHI).floor() as u64;
156        next != current
157    }
158
159    /// Compute the 3-coloring of a lattice position.
160    ///
161    /// Uses a hash mod 3 to assign one of three colors.
162    /// For valid Penrose-like tilings, every tile gets a unique color
163    /// such that no two adjacent same-color tiles exist.
164    fn three_color(&self, qx: i64, qy: i64) -> u8 {
165        let hash = (qx.wrapping_mul(0x517CC1B727220A95u64 as i64))
166            .wrapping_add(qy.wrapping_mul(0x9E3779B97F4A7C15u64 as i64));
167        (hash.wrapping_abs() % 3) as u8
168    }
169
170    /// Quantize continuous 2D coordinates to a lattice position.
171    fn quantize(&self, x: f64, y: f64) -> (i64, i64) {
172        let scale = PHI;
173        (
174            (x / scale).round() as i64,
175            (y / scale).round() as i64,
176        )
177    }
178
179    /// Compute Euclidean distance between two 2D points.
180    fn euclidean_distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
181        ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
182    }
183
184    /// Compute heading (angle in radians) from point 1 to point 2.
185    fn heading_to(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
186        (y2 - y1).atan2(x2 - x1)
187    }
188
189    /// Store an embedding with associated content.
190    ///
191    /// Projects the embedding to 2D Penrose coordinates and stores it.
192    /// Returns the tile_id.
193    pub fn store(&mut self, embedding: &[f64], content: u64) -> u64 {
194        let (x, y) = self.project_to_2d(embedding);
195        let (qx, qy) = self.quantize(x, y);
196        let color = self.three_color(qx, qy);
197        let id = self.next_id;
198        self.next_id += 1;
199
200        self.tiles.push(Tile {
201            tile_id: id,
202            content,
203            x,
204            y,
205            color,
206            level: 0,
207            lifecycle: TileLifecycle::Active,
208            lamport: 0,
209        });
210
211        id
212    }
213
214    /// Recall memories by dead reckoning from a query embedding.
215    ///
216    /// Projects the query to 2D, then finds the closest stored tiles.
217    /// Dead reckoning: we walk from the query point toward stored memories,
218    /// measuring distance and confidence at each step.
219    ///
220    /// `max_steps` controls how many intermediate waypoints to check along
221    /// the path to each candidate.
222    pub fn recall(&self, query: &[f64], max_steps: usize) -> Vec<RecallResult> {
223        if self.tiles.is_empty() {
224            return Vec::new();
225        }
226
227        let (qx, qy) = self.project_to_2d(query);
228
229        let mut results: Vec<RecallResult> = self.tiles.iter()
230            .filter(|t| t.lifecycle == TileLifecycle::Active)
231            .map(|tile| {
232            let dist = Self::euclidean_distance(qx, qy, tile.x, tile.y);
233            let heading = Self::heading_to(qx, qy, tile.x, tile.y);
234
235            // Confidence from distance: 1.0 at zero distance, decaying with distance
236            // Using a Gaussian-like falloff
237            let sigma = 2.0; // scale parameter
238            let confidence = (-dist * dist / (2.0 * sigma * sigma)).exp();
239
240            // Dead reckoning: verify path by checking intermediate points
241            let mut path_confidence = 1.0;
242            if max_steps > 0 && dist > 0.0 {
243                let mut verified = 0usize;
244                let mut total = 0usize;
245                for step in 1..=max_steps {
246                    let t = step as f64 / (max_steps + 1) as f64;
247                    let ix = qx + t * (tile.x - qx);
248                    let iy = qy + t * (tile.y - qy);
249                    let (iqx, iqy) = self.quantize(ix, iy);
250
251                    // Check matching rule at intermediate point
252                    let bit = self.tile_bit(iqx, iqy);
253                    let neighbors = [
254                        (iqx + 1, iqy), (iqx - 1, iqy),
255                        (iqx, iqy + 1), (iqx, iqy - 1),
256                    ];
257                    let any_diff = neighbors.iter().any(|&(nx, ny)| self.tile_bit(nx, ny) != bit);
258                    if any_diff { verified += 1; }
259                    total += 1;
260                }
261                path_confidence = if total > 0 { verified as f64 / total as f64 } else { 1.0 };
262            }
263
264            RecallResult {
265                tile_id: tile.tile_id,
266                content: tile.content,
267                confidence: confidence * path_confidence,
268                distance: dist,
269                heading,
270            }
271        }).collect();
272
273        // Sort by confidence descending (closest first)
274        results.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
275        results
276    }
277
278    /// Navigate from a tile by distance and heading.
279    ///
280    /// Dead reckoning navigation: start from the given tile, walk the specified
281    /// distance at the given heading, and find tiles near the destination.
282    /// Returns tile IDs of tiles found within the arrival radius.
283    pub fn navigate(&self, tile_id: u64, distance: f64, heading: f64) -> Vec<u64> {
284        let start = match self.tiles.iter().find(|t| t.tile_id == tile_id) {
285            Some(t) => (t.x, t.y),
286            None => return Vec::new(),
287        };
288
289        // Walk by dead reckoning
290        let dest_x = start.0 + distance * heading.cos();
291        let dest_y = start.1 + distance * heading.sin();
292
293        // Arrival radius scales with φ
294        let arrival_radius = PHI * 0.5;
295
296        self.tiles.iter()
297            .filter(|t| {
298                let d = Self::euclidean_distance(dest_x, dest_y, t.x, t.y);
299                d <= arrival_radius
300            })
301            .map(|t| t.tile_id)
302            .collect()
303    }
304
305    /// Consolidate (deflate) old memories using golden hierarchy.
306    ///
307    /// Tiles at level 0 that are close together (within φ^1 distance) are
308    /// merged into a single tile at level 1. The merged content is XOR'd.
309    /// Returns the number of tiles removed.
310    pub fn consolidate(&mut self) {
311        if self.tiles.len() < 2 {
312            return;
313        }
314
315        let merge_distance = PHI; // φ^1
316
317        // Find clusters of level-0 tiles
318        let mut merged: Vec<bool> = vec![false; self.tiles.len()];
319        let mut new_tiles: Vec<Tile> = Vec::new();
320
321        for i in 0..self.tiles.len() {
322            if merged[i] || self.tiles[i].level > 0 {
323                continue;
324            }
325
326            let mut cluster_x = self.tiles[i].x;
327            let mut cluster_y = self.tiles[i].y;
328            let mut cluster_content = self.tiles[i].content;
329            let mut cluster_size = 1usize;
330            let mut cluster_ids = vec![i];
331
332            for j in (i + 1)..self.tiles.len() {
333                if merged[j] || self.tiles[j].level > 0 {
334                    continue;
335                }
336                let d = Self::euclidean_distance(cluster_x, cluster_y, self.tiles[j].x, self.tiles[j].y);
337                if d < merge_distance {
338                    cluster_content ^= self.tiles[j].content;
339                    cluster_x = (cluster_x * cluster_size as f64 + self.tiles[j].x) / (cluster_size + 1) as f64;
340                    cluster_y = (cluster_y * cluster_size as f64 + self.tiles[j].y) / (cluster_size + 1) as f64;
341                    cluster_size += 1;
342                    cluster_ids.push(j);
343                }
344            }
345
346            if cluster_size > 1 {
347                for &idx in &cluster_ids {
348                    merged[idx] = true;
349                }
350                let (qx, qy) = self.quantize(cluster_x, cluster_y);
351                new_tiles.push(Tile {
352                    tile_id: self.next_id,
353                    content: cluster_content,
354                    x: cluster_x,
355                    y: cluster_y,
356                    color: self.three_color(qx, qy),
357                    level: 1,
358                    lifecycle: TileLifecycle::Active,
359                    lamport: 0,
360                });
361                self.next_id += 1;
362            }
363        }
364
365        // Remove merged tiles, keep unmerged and new
366        let mut kept: Vec<Tile> = self.tiles.drain(..)
367            .enumerate()
368            .filter(|(i, _)| !merged[*i])
369            .map(|(_, t)| t)
370            .collect();
371        kept.extend(new_tiles);
372        self.tiles = kept;
373    }
374
375    /// Number of stored tiles.
376    pub fn len(&self) -> usize {
377        self.tiles.len()
378    }
379
380    /// Check matching rules at a lattice position.
381    /// A tile matches if it has at least one neighbor of the opposite type.
382    pub fn matching_rule_holds(&self, qx: i64, qy: i64) -> bool {
383        let bit = self.tile_bit(qx, qy);
384        let neighbors = [
385            (qx + 1, qy), (qx - 1, qy),
386            (qx, qy + 1), (qx, qy - 1),
387            (qx + 1, qy - 1), (qx - 1, qy + 1),
388        ];
389        neighbors.iter().any(|&(nx, ny)| self.tile_bit(nx, ny) != bit)
390    }
391
392    /// Get the golden-ratio hash bit for a position.
393    pub fn get_tile_bit(&self, qx: i64, qy: i64) -> bool {
394        self.tile_bit(qx, qy)
395    }
396
397    /// Get the 3-coloring for a position.
398    pub fn get_color(&self, qx: i64, qy: i64) -> u8 {
399        self.three_color(qx, qy)
400    }
401
402    /// Check if the memory is empty.
403    pub fn is_empty(&self) -> bool {
404        self.tiles.is_empty()
405    }
406
407    // ── Tile Lifecycle (v1.1.0) ─────────────────────────────
408
409    /// Supersede a tile — mark old as Superseded, return its content.
410    pub fn supersede_tile(&mut self, tile_id: u64) -> Option<u64> {
411        let tile = self.tiles.iter_mut().find(|t| t.tile_id == tile_id)?;
412        if tile.lifecycle != TileLifecycle::Active {
413            return None;
414        }
415        tile.lifecycle = TileLifecycle::Superseded;
416        Some(tile.content)
417    }
418
419    /// Retract a tile — mark as Retracted with reason.
420    pub fn retract_tile(&mut self, tile_id: u64) -> bool {
421        if let Some(tile) = self.tiles.iter_mut().find(|t| t.tile_id == tile_id) {
422            if tile.lifecycle == TileLifecycle::Active {
423                tile.lifecycle = TileLifecycle::Retracted;
424                return true;
425            }
426        }
427        false
428    }
429
430    /// Get only active tile IDs.
431    pub fn active_tile_ids(&self) -> Vec<u64> {
432        self.tiles.iter()
433            .filter(|t| t.lifecycle == TileLifecycle::Active)
434            .map(|t| t.tile_id)
435            .collect()
436    }
437
438    /// Count tiles by lifecycle state.
439    pub fn lifecycle_stats(&self) -> (usize, usize, usize) {
440        let active = self.tiles.iter().filter(|t| t.lifecycle == TileLifecycle::Active).count();
441        let superseded = self.tiles.iter().filter(|t| t.lifecycle == TileLifecycle::Superseded).count();
442        let retracted = self.tiles.iter().filter(|t| t.lifecycle == TileLifecycle::Retracted).count();
443        (active, superseded, retracted)
444    }
445
446    // ── Simulation-First Predictions (v1.1.0) ──────────────
447
448    /// Predict where a memory will be found before walking there.
449    /// Uses the same projection as recall but without executing the walk.
450    pub fn predict_recall(&self, query: &[f64], clock: &mut LamportClock) -> MemoryPrediction {
451        let (qx, qy) = self.project_to_2d(query);
452
453        // Find closest active tile without walking
454        let best = self.tiles.iter()
455            .filter(|t| t.lifecycle == TileLifecycle::Active)
456            .min_by(|a, b| {
457                let da = Self::euclidean_distance(qx, qy, a.x, a.y);
458                let db = Self::euclidean_distance(qx, qy, b.x, b.y);
459                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
460            });
461
462        match best {
463            Some(tile) => MemoryPrediction {
464                predicted_tile_id: Some(tile.tile_id),
465                predicted_heading: Self::heading_to(qx, qy, tile.x, tile.y),
466                predicted_distance: Self::euclidean_distance(qx, qy, tile.x, tile.y),
467                lamport: clock.tick(),
468                confirmed: false,
469                actual_tile_id: None,
470                actual_distance: None,
471            },
472            None => MemoryPrediction {
473                predicted_tile_id: None,
474                predicted_heading: 0.0,
475                predicted_distance: f64::MAX,
476                lamport: clock.tick(),
477                confirmed: false,
478                actual_tile_id: None,
479                actual_distance: None,
480            },
481        }
482    }
483
484    /// Confirm a prediction against actual recall results.
485    pub fn confirm_prediction(&self, prediction: &mut MemoryPrediction, actual: &[RecallResult]) -> bool {
486        if let (Some(pred_id), Some(first)) = (prediction.predicted_tile_id, actual.first()) {
487            prediction.actual_tile_id = Some(first.tile_id);
488            prediction.actual_distance = Some(first.distance);
489            prediction.confirmed = pred_id == first.tile_id;
490            prediction.confirmed
491        } else {
492            prediction.confirmed = actual.is_empty() && prediction.predicted_tile_id.is_none();
493            prediction.confirmed
494        }
495    }
496}
497
498impl Default for PenroseMemory {
499    fn default() -> Self {
500        Self::new(1536)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    // 1. Store + recall roundtrip
509    #[test]
510    fn test_store_recall_roundtrip() {
511        let mut pm = PenroseMemory::new(4);
512        let id = pm.store(&[0.1, 0.2, 0.3, 0.4], 42);
513        let results = pm.recall(&[0.1, 0.2, 0.3, 0.4], 3);
514        assert!(!results.is_empty());
515        assert_eq!(results[0].content, 42);
516        assert_eq!(results[0].tile_id, id);
517    }
518
519    // 2. Different embeddings → different tiles
520    #[test]
521    fn test_different_embeddings_different_tiles() {
522        let mut pm = PenroseMemory::new(4);
523        let id1 = pm.store(&[1.0, 0.0, 0.0, 0.0], 1);
524        let id2 = pm.store(&[0.0, 0.0, 0.0, 1.0], 2);
525        assert_ne!(id1, id2);
526
527        let results1 = pm.recall(&[1.0, 0.0, 0.0, 0.0], 3);
528        let results2 = pm.recall(&[0.0, 0.0, 0.0, 1.0], 3);
529        assert_eq!(results1[0].content, 1);
530        assert_eq!(results2[0].content, 2);
531    }
532
533    // 3. Nearby embeddings → nearby tiles (small distance)
534    #[test]
535    fn test_nearby_embeddings_nearby_tiles() {
536        let mut pm = PenroseMemory::new(4);
537        pm.store(&[1.0, 2.0, 3.0, 4.0], 100);
538
539        // Slightly perturbed query
540        let results = pm.recall(&[1.01, 2.01, 3.01, 4.01], 3);
541        assert!(!results.is_empty());
542        assert!(results[0].distance < 0.1, "Nearby embeddings should be close");
543        assert_eq!(results[0].content, 100);
544    }
545
546    // 4. Matching rules hold
547    #[test]
548    fn test_matching_rules_hold() {
549        let pm = PenroseMemory::new(4);
550        let mut valid = 0usize;
551        let total = 1000;
552        for q in 0..total {
553            if pm.matching_rule_holds(q, 0) {
554                valid += 1;
555            }
556        }
557        // Most positions should satisfy matching rules (at least 80%)
558        let ratio = valid as f64 / total as f64;
559        assert!(ratio > 0.75, "Matching rules should hold at most positions, got {}", ratio);
560    }
561
562    // 5. Fibonacci ratio → 1/φ
563    #[test]
564    fn test_fibonacci_ratio() {
565        let pm = PenroseMemory::new(4);
566        let n = 10000;
567        let thick_count = (0..n).filter(|&q| pm.tile_bit(q, 0)).count();
568        let ratio = thick_count as f64 / n as f64;
569        assert!(
570            (ratio - INV_PHI).abs() < 0.03,
571            "Ratio {:.4} should approach 1/φ ≈ {:.4}",
572            ratio, INV_PHI
573        );
574    }
575
576    // 6. 3-coloring covers all positions
577    #[test]
578    fn test_three_coloring_covers_all() {
579        let pm = PenroseMemory::new(4);
580        let mut colors = [false; 3];
581        for q in 0..300 {
582            let c = pm.get_color(q, 0) as usize;
583            assert!(c < 3);
584            colors[c] = true;
585        }
586        // All 3 colors should appear
587        assert!(colors[0] && colors[1] && colors[2], "All 3 colors should appear");
588    }
589
590    // 7. Consolidation reduces count
591    #[test]
592    fn test_consolidation_reduces_count() {
593        let mut pm = PenroseMemory::new(4);
594        // Store many similar embeddings that cluster close together
595        for i in 0..20u64 {
596            let val = 1.0 + (i as f64) * 0.001;
597            pm.store(&[val, 2.0, 3.0, 4.0], i);
598        }
599        let before = pm.len();
600        pm.consolidate();
601        let after = pm.len();
602        assert!(after < before, "Consolidation should reduce tile count: {} -> {}", before, after);
603    }
604
605    // 8. Region fingerprints are unique
606    #[test]
607    fn test_region_fingerprints_unique() {
608        let pm = PenroseMemory::new(4);
609        let mut fingerprints = std::collections::HashSet::new();
610        for q in 0..100i64 {
611            // Fingerprint: 3x3 neighborhood of tile bits
612            let mut fp = 0u16;
613            for dx in -1..=1 {
614                for dy in -1..=1 {
615                    let bit = pm.tile_bit(q + dx, dy) as u16;
616                    fp = (fp << 1) | bit;
617                }
618            }
619            fingerprints.insert(fp);
620        }
621        // Should have many unique fingerprints (aperiodic = many distinct neighborhoods)
622        assert!(fingerprints.len() > 50, "Should have many unique fingerprints, got {}", fingerprints.len());
623    }
624
625    // 9. Aperiodic patterns (no periodicity)
626    #[test]
627    fn test_aperiodic_patterns() {
628        let pm = PenroseMemory::new(4);
629        let pattern: Vec<bool> = (0..100).map(|q| pm.tile_bit(q, 0)).collect();
630
631        // Check no short period repeats
632        for period in 2..20 {
633            let mut periodic = true;
634            for i in period..100 {
635                if pattern[i] != pattern[i - period] {
636                    periodic = false;
637                    break;
638                }
639            }
640            assert!(!periodic, "Pattern should not be periodic with period {}", period);
641        }
642    }
643
644    // 10. Empty recall returns empty
645    #[test]
646    fn test_empty_recall() {
647        let pm = PenroseMemory::new(4);
648        let results = pm.recall(&[1.0, 2.0, 3.0, 4.0], 5);
649        assert!(results.is_empty());
650    }
651
652    // 11. Dead reckoning navigation
653    #[test]
654    fn test_dead_reckoning_navigation() {
655        let mut pm = PenroseMemory::new(4);
656        let id = pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
657        // Navigate from the stored tile
658        let results = pm.navigate(id, 0.0, 0.0);
659        assert!(results.contains(&id), "Zero-distance navigation should find the same tile");
660    }
661
662    // 12. Large embedding (1536 dims) works
663    #[test]
664    fn test_large_embedding() {
665        let mut pm = PenroseMemory::new(1536);
666        let embedding: Vec<f64> = (0..1536).map(|i| (i as f64).sin() * 0.1).collect();
667        let id = pm.store(&embedding, 999);
668        let results = pm.recall(&embedding, 3);
669        assert!(!results.is_empty());
670        assert_eq!(results[0].content, 999);
671        assert_eq!(results[0].tile_id, id);
672    }
673
674    // 13. Store many (1000+) memories
675    #[test]
676    fn test_store_many_memories() {
677        let mut pm = PenroseMemory::new(8);
678        for i in 0..1000u64 {
679            let emb: Vec<f64> = (0..8).map(|j| (i * 8 + j) as f64 * 0.001).collect();
680            pm.store(&emb, i);
681        }
682        assert_eq!(pm.len(), 1000);
683    }
684
685    // 14. Confidence decreases with distance
686    #[test]
687    fn test_confidence_decreases_with_distance() {
688        let mut pm = PenroseMemory::new(4);
689        pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
690
691        // Close query
692        let close = pm.recall(&[1.0, 2.0, 3.0, 4.0], 0);
693        // Far query
694        let far = pm.recall(&[10.0, 20.0, 30.0, 40.0], 0);
695
696        assert!(!close.is_empty());
697        assert!(!far.is_empty());
698        assert!(
699            close[0].confidence > far[0].confidence,
700            "Close confidence ({}) should be > far confidence ({})",
701            close[0].confidence, far[0].confidence
702        );
703    }
704
705    // 15. Multi-step recall finds closer match
706    #[test]
707    fn test_multi_step_recall_finds_closer() {
708        let mut pm = PenroseMemory::new(4);
709        pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
710        pm.store(&[0.0, 0.0, 0.0, 1.0], 20);
711
712        let results = pm.recall(&[0.9, 0.0, 0.0, 0.0], 5);
713        assert!(!results.is_empty());
714        assert_eq!(results[0].content, 10, "Should find closest match first");
715    }
716
717    // ── v1.1.0: Tile lifecycle tests ───────────────────────
718
719    #[test]
720    fn test_new_tile_is_active() {
721        let mut pm = PenroseMemory::new(4);
722        pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
723        let ids = pm.active_tile_ids();
724        assert_eq!(ids.len(), 1);
725        let (active, sup, ret) = pm.lifecycle_stats();
726        assert_eq!(active, 1);
727        assert_eq!(sup, 0);
728        assert_eq!(ret, 0);
729    }
730
731    #[test]
732    fn test_supersede_excludes_from_recall() {
733        let mut pm = PenroseMemory::new(4);
734        let id1 = pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
735        let _id2 = pm.store(&[0.9, 0.0, 0.0, 0.0], 20);
736
737        pm.supersede_tile(id1);
738
739        let results = pm.recall(&[1.0, 0.0, 0.0, 0.0], 3);
740        assert!(!results.iter().any(|r| r.tile_id == id1), "Superseded tile should not appear in recall");
741
742        let (active, sup, _) = pm.lifecycle_stats();
743        assert_eq!(active, 1);
744        assert_eq!(sup, 1);
745    }
746
747    #[test]
748    fn test_retract_excludes_from_recall() {
749        let mut pm = PenroseMemory::new(4);
750        let id = pm.store(&[1.0, 0.0, 0.0, 0.0], 10);
751
752        let retracted = pm.retract_tile(id);
753        assert!(retracted);
754
755        let results = pm.recall(&[1.0, 0.0, 0.0, 0.0], 3);
756        assert!(results.is_empty(), "Retracted tile should not appear in recall");
757    }
758
759    #[test]
760    fn test_cannot_supersede_retracted() {
761        let mut pm = PenroseMemory::new(4);
762        let id = pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
763        pm.retract_tile(id);
764        let result = pm.supersede_tile(id);
765        assert!(result.is_none(), "Cannot supersede a retracted tile");
766    }
767
768    // ── v1.1.0: Simulation-first prediction tests ─────────
769
770    #[test]
771    fn test_predict_recall_finds_tile() {
772        let mut pm = PenroseMemory::new(4);
773        let id = pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
774        let mut clock = LamportClock::new();
775
776        let pred = pm.predict_recall(&[1.0, 2.0, 3.0, 4.0], &mut clock);
777        assert_eq!(pred.predicted_tile_id, Some(id));
778        assert!(pred.predicted_distance < 0.01);
779        assert_eq!(pred.lamport, 1);
780        assert!(!pred.confirmed);
781    }
782
783    #[test]
784    fn test_confirm_prediction_matches() {
785        let mut pm = PenroseMemory::new(4);
786        let _id = pm.store(&[1.0, 2.0, 3.0, 4.0], 42);
787        let mut clock = LamportClock::new();
788
789        let mut pred = pm.predict_recall(&[1.0, 2.0, 3.0, 4.0], &mut clock);
790        let actual = pm.recall(&[1.0, 2.0, 3.0, 4.0], 3);
791
792        let confirmed = pm.confirm_prediction(&mut pred, &actual);
793        assert!(confirmed, "Prediction should match actual recall");
794        assert!(pred.confirmed);
795    }
796
797    #[test]
798    fn test_lamport_clock_monotonic() {
799        let mut clock = LamportClock::new();
800        let t1 = clock.tick();
801        let t2 = clock.tick();
802        let t3 = clock.merge(100); // max(2, 100) + 1 = 101
803        assert!(t1 < t2);
804        assert_eq!(t3, 101);
805    }
806}