Skip to main content

phago_runtime/
session.rs

1//! Session persistence — save/load colony graph state.
2//!
3//! Serializes the knowledge graph (nodes + edges) to JSON for
4//! persistence across sessions. Agent state is not serialized
5//! (agents are reconstructed from config on load).
6
7use crate::colony::Colony;
8use phago_core::topology::TopologyGraph;
9use phago_core::types::*;
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12
13/// Serializable snapshot of the knowledge graph.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct GraphState {
16    pub nodes: Vec<SerializedNode>,
17    pub edges: Vec<SerializedEdge>,
18    pub metadata: SessionMetadata,
19}
20
21/// Serializable node.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SerializedNode {
24    pub label: String,
25    pub node_type: String,
26    pub access_count: u64,
27    pub position_x: f64,
28    pub position_y: f64,
29    #[serde(default)]
30    pub created_tick: u64,
31}
32
33/// Serializable edge.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SerializedEdge {
36    pub from_label: String,
37    pub to_label: String,
38    pub weight: f64,
39    pub co_activations: u64,
40    #[serde(default)]
41    pub created_tick: u64,
42    #[serde(default)]
43    pub last_activated_tick: u64,
44}
45
46/// Session metadata.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SessionMetadata {
49    pub session_id: String,
50    pub tick: u64,
51    pub node_count: usize,
52    pub edge_count: usize,
53    pub files_indexed: Vec<String>,
54}
55
56/// Save the colony's knowledge graph to a JSON file.
57pub fn save_session(colony: &Colony, path: &Path, files_indexed: &[String]) -> std::io::Result<()> {
58    let graph = colony.substrate().graph();
59    let all_nodes = graph.all_nodes();
60
61    let nodes: Vec<SerializedNode> = all_nodes.iter()
62        .filter_map(|nid| graph.get_node(nid))
63        .map(|n| SerializedNode {
64            label: n.label.clone(),
65            node_type: format!("{:?}", n.node_type),
66            access_count: n.access_count,
67            position_x: n.position.x,
68            position_y: n.position.y,
69            created_tick: n.created_tick,
70        })
71        .collect();
72
73    let edges: Vec<SerializedEdge> = graph.all_edges().iter()
74        .filter_map(|(from, to, edge)| {
75            let from_label = graph.get_node(from)?.label.clone();
76            let to_label = graph.get_node(to)?.label.clone();
77            Some(SerializedEdge {
78                from_label,
79                to_label,
80                weight: edge.weight,
81                co_activations: edge.co_activations,
82                created_tick: edge.created_tick,
83                last_activated_tick: edge.last_activated_tick,
84            })
85        })
86        .collect();
87
88    let state = GraphState {
89        metadata: SessionMetadata {
90            session_id: uuid::Uuid::new_v4().to_string(),
91            tick: colony.stats().tick,
92            node_count: nodes.len(),
93            edge_count: edges.len(),
94            files_indexed: files_indexed.to_vec(),
95        },
96        nodes,
97        edges,
98    };
99
100    let json = serde_json::to_string_pretty(&state)
101        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
102
103    // Create parent directory if needed
104    if let Some(parent) = path.parent() {
105        std::fs::create_dir_all(parent)?;
106    }
107
108    std::fs::write(path, json)
109}
110
111/// Load a saved session from JSON.
112pub fn load_session(path: &Path) -> std::io::Result<GraphState> {
113    let json = std::fs::read_to_string(path)?;
114    serde_json::from_str(&json)
115        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
116}
117
118/// Restore a graph state into a colony.
119/// Adds all nodes and edges from the saved state.
120pub fn restore_into_colony(colony: &mut Colony, state: &GraphState) {
121    use phago_core::substrate::Substrate;
122    use std::collections::HashMap;
123
124    let mut label_to_id: HashMap<String, NodeId> = HashMap::new();
125
126    // Add nodes
127    for node in &state.nodes {
128        let node_type = match node.node_type.as_str() {
129            "Concept" => NodeType::Concept,
130            "Insight" => NodeType::Insight,
131            "Anomaly" => NodeType::Anomaly,
132            _ => NodeType::Concept,
133        };
134
135        let data = NodeData {
136            id: NodeId::new(),
137            label: node.label.clone(),
138            node_type,
139            position: Position::new(node.position_x, node.position_y),
140            access_count: node.access_count,
141            created_tick: node.created_tick,
142        };
143        let id = colony.substrate_mut().add_node(data);
144        label_to_id.insert(node.label.clone(), id);
145    }
146
147    // Add edges with full temporal state
148    for edge in &state.edges {
149        if let (Some(&from_id), Some(&to_id)) = (
150            label_to_id.get(&edge.from_label),
151            label_to_id.get(&edge.to_label),
152        ) {
153            colony.substrate_mut().set_edge(from_id, to_id, EdgeData {
154                weight: edge.weight,
155                co_activations: edge.co_activations,
156                created_tick: edge.created_tick,
157                last_activated_tick: edge.last_activated_tick,
158            });
159        }
160    }
161
162    // Advance colony tick to match the saved session
163    // so that maturation/staleness calculations remain correct
164    let target_tick = state.metadata.tick;
165    while colony.stats().tick < target_tick {
166        colony.substrate_mut().advance_tick();
167    }
168}
169
170/// Check if save/load preserves node and edge counts.
171pub fn verify_fidelity(original: &Colony, restored: &Colony) -> (bool, usize, usize, usize, usize) {
172    let orig_nodes = original.substrate().graph().node_count();
173    let orig_edges = original.substrate().graph().edge_count();
174    let rest_nodes = restored.substrate().graph().node_count();
175    let rest_edges = restored.substrate().graph().edge_count();
176    let identical = orig_nodes == rest_nodes && orig_edges == rest_edges;
177    (identical, orig_nodes, orig_edges, rest_nodes, rest_edges)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::colony::Colony;
184
185    #[test]
186    fn save_load_roundtrip() {
187        let mut colony = Colony::new();
188        colony.ingest_document("test", "cell membrane protein", Position::new(0.0, 0.0));
189
190        use phago_agents::digester::Digester;
191        colony.spawn(Box::new(Digester::new(Position::new(0.0, 0.0)).with_max_idle(80)));
192        colony.run(15);
193
194        let tmp = std::env::temp_dir().join("phago_session_test.json");
195        save_session(&colony, &tmp, &["test.rs".to_string()]).unwrap();
196
197        let state = load_session(&tmp).unwrap();
198        assert!(!state.nodes.is_empty());
199        assert!(state.metadata.node_count > 0);
200
201        // Restore into new colony
202        let mut restored = Colony::new();
203        restore_into_colony(&mut restored, &state);
204
205        let (_identical, orig_n, _orig_e, rest_n, rest_e) = verify_fidelity(&colony, &restored);
206        assert_eq!(orig_n, rest_n, "Node count should match");
207        // Edge count may differ slightly due to label collisions
208        assert!(rest_e > 0, "Restored colony should have edges");
209
210        std::fs::remove_file(&tmp).ok();
211    }
212}