Skip to main content

memory_crystal/
telephone.rs

1use std::collections::HashMap;
2
3use crate::encoder::ConstraintExtractor;
4use crate::tile::Tile;
5
6/// Tracks a chain of tiles through the "telephone game" —
7/// each round re-encodes the previous reconstruction, measuring fact drift.
8pub struct TelephoneChain {
9    tiles: Vec<Tile>,
10    fact_tracker: FactTracker,
11    original_facts: HashMap<String, String>,
12}
13
14impl TelephoneChain {
15    /// Start a new telephone chain from source content.
16    pub fn new(source: &str) -> Self {
17        let extractor = ConstraintExtractor;
18        let original_facts = extractor.extract(source);
19
20        let encoder = crate::encoder::TileEncoder::default();
21        let tile = encoder.encode(source, &crate::tile::SalienceMap::default());
22
23        let mut fact_tracker = FactTracker::new(&original_facts);
24        fact_tracker.record_round(&tile);
25
26        Self {
27            tiles: vec![tile],
28            fact_tracker,
29            original_facts,
30        }
31    }
32
33    /// Add a round: the reconstruction is re-encoded into a new tile.
34    pub fn add_round(&mut self, reconstruction: &str) -> &Tile {
35        let encoder = crate::encoder::TileEncoder::default();
36        let parent_id = self.tiles.last().unwrap().id.clone();
37
38        let mut tile = encoder.encode(reconstruction, &crate::tile::SalienceMap::default());
39        tile.generation = self.tiles.len() as u32;
40        tile.parent_id = Some(parent_id);
41
42        self.fact_tracker.record_round(&tile);
43        self.tiles.push(tile);
44        self.tiles.last().unwrap()
45    }
46
47    /// Number of rounds in the chain.
48    pub fn len(&self) -> usize {
49        self.tiles.len()
50    }
51
52    /// Is the chain empty?
53    pub fn is_empty(&self) -> bool {
54        self.tiles.is_empty()
55    }
56
57    /// Get a tile by round number.
58    pub fn get(&self, round: usize) -> Option<&Tile> {
59        self.tiles.get(round)
60    }
61
62    /// Find the crystallization point — the round where fact survival stabilizes.
63    ///
64    /// Returns the round index where survival rate stops dropping significantly.
65    pub fn crystallization_point(&self) -> Option<usize> {
66        if self.tiles.len() < 3 {
67            return None;
68        }
69
70        let mut prev_rate = 1.0;
71        for round in 0..self.tiles.len() {
72            let rate = self.fact_tracker.survival_rate(round);
73            let drop = prev_rate - rate;
74            // If the drop is less than 5%, we've crystallized.
75            if drop < 0.05 && round > 0 {
76                return Some(round);
77            }
78            prev_rate = rate;
79        }
80
81        None
82    }
83
84    /// Get the fact timeline: for each original fact, whether it survived each round.
85    pub fn fact_timeline(&self) -> HashMap<String, Vec<bool>> {
86        self.fact_tracker.timeline.clone()
87    }
88
89    /// Get the original facts.
90    pub fn original_facts(&self) -> &HashMap<String, String> {
91        &self.original_facts
92    }
93}
94
95/// Tracks which facts from the original source survive each telephone round.
96pub struct FactTracker {
97    facts: HashMap<String, String>,
98    timeline: HashMap<String, Vec<bool>>,
99}
100
101impl FactTracker {
102    /// Create a new tracker from the original set of facts.
103    pub fn new(facts: &HashMap<String, String>) -> Self {
104        Self {
105            facts: facts.clone(),
106            timeline: facts.keys().map(|k| (k.clone(), vec![])).collect(),
107        }
108    }
109
110    /// Record a round: check which original facts are still present in the tile.
111    pub fn record_round(&mut self, tile: &Tile) {
112        let tile_text = format!("{} {}", tile.summary, tile.constraints.values().cloned().collect::<Vec<_>>().join(" ")).to_lowercase();
113
114        for (key, original_value) in &self.facts {
115            let survived = tile_text.contains(&original_value.to_lowercase());
116            self.timeline.entry(key.clone()).or_default().push(survived);
117        }
118    }
119
120    /// Get survival rate at a specific round (fraction of facts still present).
121    pub fn survival_rate(&self, round: usize) -> f64 {
122        if self.timeline.is_empty() {
123            return 1.0;
124        }
125
126        let survived: usize = self
127            .timeline
128            .values()
129            .map(|v| v.get(round).copied().unwrap_or(false) as usize)
130            .sum();
131
132        survived as f64 / self.timeline.len() as f64
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn telephone_chain_basic() {
142        let chain = TelephoneChain::new("Alice went to Seattle on March 15th to work on Project Alpha.");
143        assert_eq!(chain.len(), 1);
144        assert!(!chain.tiles[0].summary.is_empty());
145    }
146
147    #[test]
148    fn add_rounds() {
149        let mut chain = TelephoneChain::new("Alice discovered a critical bug in the production system.");
150        chain.add_round("Alice found a bug in production.");
151        chain.add_round("There was a bug found.");
152        assert_eq!(chain.len(), 3);
153
154        // Each round should have incrementing generation.
155        assert_eq!(chain.tiles[0].generation, 0);
156        assert_eq!(chain.tiles[1].generation, 1);
157        assert_eq!(chain.tiles[2].generation, 2);
158
159        // Each round should have parent pointing to previous.
160        assert!(chain.tiles[0].parent_id.is_none());
161        assert_eq!(chain.tiles[1].parent_id.as_ref(), Some(&chain.tiles[0].id));
162    }
163
164    #[test]
165    fn fact_timeline_tracks() {
166        let chain = TelephoneChain::new("Alice went to Seattle for the important meeting.");
167        let timeline = chain.fact_timeline();
168        assert!(!timeline.is_empty());
169
170        // First round should have all facts present.
171        for (_, rounds) in &timeline {
172            assert!(!rounds.is_empty());
173        }
174    }
175
176    #[test]
177    fn survival_rate_declines() {
178        let mut chain = TelephoneChain::new("Alice reported a critical emergency in Seattle on March 15th.");
179        let rate_0 = chain.fact_tracker.survival_rate(0);
180
181        chain.add_round("Someone reported something somewhere.");
182        let rate_1 = chain.fact_tracker.survival_rate(1);
183
184        // Generally, survival declines with lossy rounds.
185        // (This is a heuristic test — exact behavior depends on the encoder.)
186        assert!(rate_0 >= 0.0 && rate_0 <= 1.0);
187        assert!(rate_1 >= 0.0 && rate_1 <= 1.0);
188    }
189
190    #[test]
191    fn crystallization_point_needs_multiple_rounds() {
192        let chain = TelephoneChain::new("Test content");
193        assert!(chain.crystallization_point().is_none());
194    }
195}