Skip to main content

fleet_coordinate/
tile.rs

1//! PLATO Tile Integration — fleet coordination via tile forwarding
2
3use serde::{Deserialize, Serialize};
4
5/// TileBounds — encodes Laman threshold into tile geometry.
6/// Inspired by FM's HolonomyBounds in holonomy-consensus/src/constraints.rs.
7/// A tile that exceeds its deviation bound is a constraint violation.
8#[derive(Clone, Debug, Serialize, Deserialize)]
9pub struct TileBounds {
10    /// Maximum deviation from equilibrium position
11    pub max_deviation: f64,
12    /// Cycles before rechecking
13    pub max_age: u32,
14    /// Minimum neighbors that must agree (Laman threshold: 2v'-3 per subgraph)
15    pub min_agreement: usize,
16}
17
18impl TileBounds {
19    pub fn new(max_deviation: f64, max_age: u32, min_agreement: usize) -> Self {
20        Self { max_deviation, max_age, min_agreement }
21    }
22
23    /// Check if a given deviation is within bounds
24    pub fn check_bounds(&self, deviation: f64) -> bool {
25        deviation <= self.max_deviation
26    }
27
28    /// Default bounds for fleet tiles
29    pub fn default_fleet() -> Self {
30        Self {
31            max_deviation: 1.0,
32            max_age: 100,
33            min_agreement: 3, // minimum agents for Laman-rigid subgraph
34        }
35    }
36}
37
38/// A PLATO tile forwarded through the fleet
39#[derive(Clone, Debug, Serialize, Deserialize)]
40pub struct FleetTile {
41    pub room: String,
42    pub question: String,
43    pub answer: String,
44    pub domain: Option<String>,
45    pub tags: Vec<String>,
46    pub author: String,
47    pub timestamp: f64,
48}
49
50impl FleetTile {
51    pub fn new(room: String, question: String, answer: String) -> Self {
52        Self {
53            room,
54            question,
55            answer,
56            domain: None,
57            tags: vec![],
58            author: "fleet".to_string(),
59            timestamp: std::time::SystemTime::now()
60                .duration_since(std::time::UNIX_EPOCH)
61                .map(|d| d.as_secs() as f64)
62                .unwrap_or(0.0),
63        }
64    }
65
66    pub fn with_domain(mut self, domain: String) -> Self {
67        self.domain = Some(domain);
68        self
69    }
70
71    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
72        self.tags = tags;
73        self
74    }
75}
76
77/// Tile coordination state for a room
78#[derive(Clone, Debug, Serialize, Deserialize)]
79pub struct TileCoordination {
80    pub room: String,
81    pub tiles: Vec<FleetTile>,
82    pub consensus_tiles: Vec<String>,
83    pub pending_tiles: Vec<String>,
84}
85
86impl TileCoordination {
87    pub fn new(room: String) -> Self {
88        Self {
89            room,
90            tiles: Vec::new(),
91            consensus_tiles: Vec::new(),
92            pending_tiles: Vec::new(),
93        }
94    }
95
96    pub fn add_tile(&mut self, tile: FleetTile) {
97        if !self.tiles.iter().any(|t| t.question == tile.question) {
98            self.tiles.push(tile.clone());
99            self.pending_tiles.push(tile.question.clone());
100        }
101    }
102
103    pub fn mark_consensus(&mut self, question: &str) {
104        self.pending_tiles.retain(|q| q != question);
105        if !self.consensus_tiles.contains(&question.to_string()) {
106            self.consensus_tiles.push(question.to_string());
107        }
108    }
109
110    pub fn tile_count(&self) -> usize {
111        self.tiles.len()
112    }
113}
114
115/// Integration with cocapn-glue-core TILE messages
116pub fn tile_to_glue(tile: &FleetTile) -> Vec<u8> {
117    let msg = serde_json::json!({
118        "t": 0x05,
119        "room": tile.room,
120        "question": tile.question,
121        "answer": tile.answer,
122        "author": tile.author,
123    });
124    serde_json::to_vec(&msg).unwrap_or_default()
125}
126
127/// Parse a glue TILE message into a FleetTile
128pub fn glue_to_tile(data: &[u8]) -> Option<FleetTile> {
129    let msg: serde_json::Value = serde_json::from_slice(data).ok()?;
130    Some(FleetTile {
131        room: msg["room"].as_str()?.to_string(),
132        question: msg["question"].as_str()?.to_string(),
133        answer: msg["answer"].as_str()?.to_string(),
134        domain: msg["domain"].as_str().map(String::from),
135        tags: msg["tags"].as_array()
136            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
137            .unwrap_or_default(),
138        author: msg["author"].as_str().unwrap_or("fleet").to_string(),
139        timestamp: msg["timestamp"].as_f64().unwrap_or(0.0),
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_tile_roundtrip() {
149        let tile = FleetTile::new(
150            "fleet_math".to_string(),
151            "What is the Betti number beta1?".to_string(),
152            "beta1 = E - V + C for a graph with C connected components.".to_string(),
153        );
154        let data = tile_to_glue(&tile);
155        assert!(!data.is_empty());
156    }
157
158    #[test]
159    fn test_coordination_mark_consensus() {
160        let mut coord = TileCoordination::new("test".to_string());
161        coord.add_tile(FleetTile::new("test".to_string(), "Q1?".to_string(), "A1".to_string()));
162        coord.add_tile(FleetTile::new("test".to_string(), "Q2?".to_string(), "A2".to_string()));
163        assert_eq!(coord.tile_count(), 2);
164        assert_eq!(coord.pending_tiles.len(), 2);
165        coord.mark_consensus("Q1?");
166        assert_eq!(coord.consensus_tiles.len(), 1);
167        assert_eq!(coord.pending_tiles.len(), 1);
168    }
169
170    #[test]
171    fn test_tilebounds_check_bounds() {
172        let bounds = TileBounds::new(1.0, 100, 3);
173        assert!(bounds.check_bounds(0.5));
174        assert!(bounds.check_bounds(1.0));
175        assert!(!bounds.check_bounds(1.1));
176    }
177
178
179    #[test]
180    fn test_tilebounds_default_fleet() {
181        let bounds = TileBounds::default_fleet();
182        assert!(bounds.check_bounds(0.5));
183        assert_eq!(bounds.max_age, 100);
184        assert_eq!(bounds.min_agreement, 3);
185    }
186
187    #[test]
188    fn test_tilebounds_zero_deviation() {
189        let bounds = TileBounds::new(0.0, 50, 5);
190        assert!(bounds.check_bounds(0.0));
191        assert!(!bounds.check_bounds(0.001));
192    }
193
194    #[test]
195    fn test_tilebounds_exactly_at_bound() {
196        let bounds = TileBounds::new(2.5, 200, 4);
197        assert!(bounds.check_bounds(2.5));
198        assert!(!bounds.check_bounds(2.5001));
199    }
200}