1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12use super::GradientError;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ComputationNode {
23 pub id: String,
25 pub op: String,
27 pub input_cids: Vec<String>,
29 pub output_cid: Option<String>,
31 pub gradient_cid: Option<String>,
33 pub metadata: HashMap<String, String>,
35}
36
37impl ComputationNode {
38 pub fn new(op: impl Into<String>, input_cids: Vec<String>) -> Self {
40 Self {
41 id: uuid::Uuid::new_v4().to_string(),
42 op: op.into(),
43 input_cids,
44 output_cid: None,
45 gradient_cid: None,
46 metadata: HashMap::new(),
47 }
48 }
49
50 pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
52 self.metadata.insert(key.into(), value.into());
53 self
54 }
55}
56
57#[derive(Debug, thiserror::Error)]
61pub enum ComputationGraphError {
62 #[error("Node not found: {0}")]
63 NodeNotFound(String),
64
65 #[error("Circular dependency detected in computation graph")]
66 CircularDependency,
67
68 #[error("Serialization error: {0}")]
69 Serialization(String),
70
71 #[error("IO error: {0}")]
72 Io(#[from] std::io::Error),
73}
74
75impl From<ComputationGraphError> for GradientError {
76 fn from(e: ComputationGraphError) -> Self {
77 GradientError::InvalidGradient(e.to_string())
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct ComputationGraphStore {
91 nodes: HashMap<String, ComputationNode>,
93 edges: Vec<(String, String)>,
95}
96
97impl ComputationGraphStore {
98 pub fn new() -> Self {
100 Self::default()
101 }
102
103 pub fn add_node(&mut self, node: ComputationNode) {
109 let producers: Vec<String> = self
112 .nodes
113 .values()
114 .filter_map(|n| {
115 n.output_cid.as_ref().and_then(|oc| {
116 if node.input_cids.contains(oc) {
117 Some(n.id.clone())
118 } else {
119 None
120 }
121 })
122 })
123 .collect();
124
125 for producer_id in producers {
126 self.edges.push((producer_id, node.id.clone()));
127 }
128
129 self.nodes.insert(node.id.clone(), node);
130 }
131
132 pub fn record_output(
137 &mut self,
138 node_id: &str,
139 output_cid: String,
140 ) -> Result<(), GradientError> {
141 let node = self
142 .nodes
143 .get_mut(node_id)
144 .ok_or_else(|| GradientError::InvalidGradient(format!("Node not found: {node_id}")))?;
145 node.output_cid = Some(output_cid.clone());
146
147 let consumers: Vec<String> = self
149 .nodes
150 .values()
151 .filter(|n| n.id != node_id && n.input_cids.contains(&output_cid))
152 .map(|n| n.id.clone())
153 .collect();
154
155 for consumer_id in consumers {
156 let edge = (node_id.to_string(), consumer_id);
157 if !self.edges.contains(&edge) {
158 self.edges.push(edge);
159 }
160 }
161
162 Ok(())
163 }
164
165 pub fn record_gradient(
167 &mut self,
168 node_id: &str,
169 grad_cid: String,
170 ) -> Result<(), GradientError> {
171 let node = self
172 .nodes
173 .get_mut(node_id)
174 .ok_or_else(|| GradientError::InvalidGradient(format!("Node not found: {node_id}")))?;
175 node.gradient_cid = Some(grad_cid);
176 Ok(())
177 }
178
179 pub fn topological_order(&self) -> Vec<String> {
184 let mut in_degree: HashMap<&str, usize> =
186 self.nodes.keys().map(|id| (id.as_str(), 0)).collect();
187
188 let mut successors: HashMap<&str, Vec<&str>> = self
189 .nodes
190 .keys()
191 .map(|id| (id.as_str(), Vec::new()))
192 .collect();
193
194 for (from, to) in &self.edges {
195 *in_degree.entry(to.as_str()).or_insert(0) += 1;
196 successors
197 .entry(from.as_str())
198 .or_default()
199 .push(to.as_str());
200 }
201
202 let mut queue: std::collections::VecDeque<&str> = in_degree
204 .iter()
205 .filter(|(_, °)| deg == 0)
206 .map(|(&id, _)| id)
207 .collect();
208
209 let mut queue_vec: Vec<&str> = queue.drain(..).collect();
211 queue_vec.sort_unstable();
212 queue.extend(queue_vec);
213
214 let mut order: Vec<String> = Vec::with_capacity(self.nodes.len());
215
216 while let Some(node_id) = queue.pop_front() {
217 order.push(node_id.to_string());
218
219 if let Some(succs) = successors.get(node_id) {
220 let mut next: Vec<&str> = succs
221 .iter()
222 .copied()
223 .filter(|&s| {
224 let deg = in_degree.get_mut(s).map(|d| {
225 *d = d.saturating_sub(1);
226 *d
227 });
228 deg == Some(0)
229 })
230 .collect();
231 next.sort_unstable();
232 queue.extend(next);
233 }
234 }
235
236 order
237 }
238
239 pub fn store_gradient_as_arrow(
256 &mut self,
257 node_id: &str,
258 gradient_data: &[f32],
259 shape: &[usize],
260 ) -> Result<String, GradientError> {
261 use crate::arrow::{ArrowTensor, ArrowTensorStore};
262 use ipfrs_core::CidBuilder;
263
264 let shape_str = shape
266 .iter()
267 .map(|d| d.to_string())
268 .collect::<Vec<_>>()
269 .join(",");
270
271 let mut tensor = ArrowTensor::from_slice_f32("gradient", shape.to_vec(), gradient_data);
273 tensor
274 .metadata
275 .custom
276 .insert("gradient_shape".to_string(), shape_str);
277
278 let mut store = ArrowTensorStore::new();
279 store.insert(tensor);
280
281 let ipc_bytes = store
282 .to_bytes()
283 .map_err(|e| GradientError::InvalidGradient(format!("Arrow IPC encode error: {e}")))?;
284
285 let cid = CidBuilder::new()
287 .codec(0x71) .build(&ipc_bytes)
289 .map_err(|e| GradientError::InvalidGradient(format!("CID computation error: {e}")))?;
290 let cid_str = cid.to_string();
291
292 let node = self
294 .nodes
295 .get_mut(node_id)
296 .ok_or_else(|| GradientError::InvalidGradient(format!("Node not found: {node_id}")))?;
297 node.gradient_cid = Some(cid_str.clone());
298
299 Ok(cid_str)
300 }
301
302 pub fn load_gradient_from_arrow(
308 arrow_bytes: &[u8],
309 expected_shape: &[usize],
310 ) -> Result<Vec<f32>, GradientError> {
311 use crate::arrow::ArrowTensorStore;
312
313 let store = ArrowTensorStore::from_bytes(arrow_bytes)
314 .map_err(|e| GradientError::InvalidGradient(format!("Arrow IPC decode error: {e}")))?;
315
316 let tensor = store.get("gradient").ok_or_else(|| {
317 GradientError::InvalidGradient(
318 "Arrow IPC block does not contain a 'gradient' column".to_string(),
319 )
320 })?;
321
322 if !expected_shape.is_empty() && tensor.metadata.shape != expected_shape {
324 return Err(GradientError::ShapeMismatch {
325 expected: expected_shape.to_vec(),
326 actual: tensor.metadata.shape.clone(),
327 });
328 }
329
330 let slice = tensor
331 .as_slice_f32()
332 .ok_or(GradientError::IncompatibleDtype(
333 crate::arrow::TensorDtype::Float32,
334 ))?;
335
336 Ok(slice.to_vec())
337 }
338
339 pub fn checkpoint(&self) -> Result<Vec<u8>, GradientError> {
341 serde_json::to_vec(self)
342 .map_err(|e| GradientError::InvalidGradient(format!("Checkpoint serialization: {e}")))
343 }
344
345 pub fn from_checkpoint(data: &[u8]) -> Result<Self, GradientError> {
347 serde_json::from_slice(data)
348 .map_err(|e| GradientError::InvalidGradient(format!("Checkpoint deserialization: {e}")))
349 }
350
351 pub fn find_consumers(&self, cid: &str) -> Vec<&ComputationNode> {
353 self.nodes
354 .values()
355 .filter(|n| n.input_cids.iter().any(|ic| ic == cid))
356 .collect()
357 }
358
359 pub fn provenance_chain(&self, output_cid: &str) -> Vec<&ComputationNode> {
365 let root = self
367 .nodes
368 .values()
369 .find(|n| n.output_cid.as_deref() == Some(output_cid));
370
371 let Some(root) = root else {
372 return Vec::new();
373 };
374
375 let mut chain: Vec<&ComputationNode> = Vec::new();
377 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
378 let mut stack: Vec<&ComputationNode> = vec![root];
379
380 while let Some(node) = stack.pop() {
381 if !visited.insert(node.id.as_str()) {
382 continue;
383 }
384 chain.push(node);
385
386 for input_cid in &node.input_cids {
387 if let Some(parent) = self
388 .nodes
389 .values()
390 .find(|n| n.output_cid.as_deref() == Some(input_cid.as_str()))
391 {
392 stack.push(parent);
393 }
394 }
395 }
396
397 chain.reverse();
399 chain
400 }
401
402 pub fn get_node(&self, node_id: &str) -> Option<&ComputationNode> {
404 self.nodes.get(node_id)
405 }
406
407 pub fn node_count(&self) -> usize {
409 self.nodes.len()
410 }
411
412 pub fn edge_count(&self) -> usize {
414 self.edges.len()
415 }
416
417 pub fn nodes(&self) -> impl Iterator<Item = &ComputationNode> {
419 self.nodes.values()
420 }
421}
422
423#[cfg(test)]
426mod computation_graph_tests {
427 use super::*;
428
429 fn build_linear_graph() -> (ComputationGraphStore, String, String, String, String) {
431 let mut store = ComputationGraphStore::new();
432
433 let mut input_node = ComputationNode::new("input", vec![]);
435 let input_id = input_node.id.clone();
436 input_node.output_cid = Some("cid_a".to_string());
437 store.add_node(input_node);
438
439 let mut matmul_node = ComputationNode::new("matmul", vec!["cid_a".to_string()]);
441 let matmul_id = matmul_node.id.clone();
442 matmul_node.output_cid = Some("cid_b".to_string());
443 store.add_node(matmul_node);
444
445 let mut relu_node = ComputationNode::new("relu", vec!["cid_b".to_string()]);
447 let relu_id = relu_node.id.clone();
448 relu_node.output_cid = Some("cid_c".to_string());
449 store.add_node(relu_node);
450
451 let mut output_node = ComputationNode::new("output", vec!["cid_c".to_string()]);
453 let output_id = output_node.id.clone();
454 output_node.output_cid = Some("cid_d".to_string());
455 store.add_node(output_node);
456
457 (store, input_id, matmul_id, relu_id, output_id)
458 }
459
460 #[test]
461 fn test_add_and_retrieve_node() {
462 let mut store = ComputationGraphStore::new();
463
464 let node = ComputationNode::new("relu", vec!["cid_x".to_string()])
465 .with_meta("dtype", "f32")
466 .with_meta("shape", "[128, 64]");
467
468 let node_id = node.id.clone();
469 store.add_node(node);
470
471 assert_eq!(store.node_count(), 1);
472
473 let retrieved = store.get_node(&node_id).expect("node should exist");
474 assert_eq!(retrieved.op, "relu");
475 assert_eq!(retrieved.input_cids, vec!["cid_x".to_string()]);
476 assert_eq!(
477 retrieved.metadata.get("dtype").map(|s| s.as_str()),
478 Some("f32")
479 );
480 assert!(retrieved.output_cid.is_none());
481 assert!(retrieved.gradient_cid.is_none());
482 }
483
484 #[test]
485 fn test_topological_order() {
486 let (store, input_id, matmul_id, relu_id, output_id) = build_linear_graph();
487
488 let order = store.topological_order();
489
490 assert_eq!(order.len(), 4, "all four nodes should appear");
491
492 let pos = |id: &str| order.iter().position(|x| x == id).expect("id in order");
494
495 assert!(pos(&input_id) < pos(&matmul_id), "input before matmul");
496 assert!(pos(&matmul_id) < pos(&relu_id), "matmul before relu");
497 assert!(pos(&relu_id) < pos(&output_id), "relu before output");
498 }
499
500 #[test]
501 fn test_record_output_and_gradient() {
502 let mut store = ComputationGraphStore::new();
503 let node = ComputationNode::new("softmax", vec!["cid_in".to_string()]);
504 let node_id = node.id.clone();
505 store.add_node(node);
506
507 store
509 .record_output(&node_id, "cid_out".to_string())
510 .expect("test: should succeed");
511 assert_eq!(
512 store
513 .get_node(&node_id)
514 .expect("test: should succeed")
515 .output_cid
516 .as_deref(),
517 Some("cid_out")
518 );
519
520 store
522 .record_gradient(&node_id, "cid_grad".to_string())
523 .expect("test: should succeed");
524 assert_eq!(
525 store
526 .get_node(&node_id)
527 .expect("test: should succeed")
528 .gradient_cid
529 .as_deref(),
530 Some("cid_grad")
531 );
532 }
533
534 #[test]
535 fn test_record_output_missing_node() {
536 let mut store = ComputationGraphStore::new();
537 let result = store.record_output("nonexistent-id", "cid_out".to_string());
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn test_record_gradient_missing_node() {
543 let mut store = ComputationGraphStore::new();
544 let result = store.record_gradient("nonexistent-id", "cid_grad".to_string());
545 assert!(result.is_err());
546 }
547
548 #[test]
549 fn test_checkpoint_roundtrip() {
550 let (store, _, _, _, _) = build_linear_graph();
551
552 let bytes = store.checkpoint().expect("checkpoint serialization");
553 let restored =
554 ComputationGraphStore::from_checkpoint(&bytes).expect("checkpoint deserialization");
555
556 assert_eq!(restored.node_count(), 4);
557 assert_eq!(restored.edge_count(), store.edge_count());
558 }
559
560 #[test]
561 fn test_gradient_checkpoint_save_load() {
562 let (store, _, _, _, _) = build_linear_graph();
563
564 let mut ckpt = super::super::checkpoint::GradientCheckpoint::new(store, 42)
565 .with_loss_cid("cid_loss_xyz");
566 ckpt.set_optimizer_state("adam_m", vec![1u8, 2, 3]);
567 ckpt.set_optimizer_state("adam_v", vec![4u8, 5, 6]);
568
569 let dir = std::env::temp_dir().join(format!("ipfrs_grad_test_{}", uuid::Uuid::new_v4()));
571 std::fs::create_dir_all(&dir).expect("test: should succeed");
572 let path = dir.join("checkpoint.json");
573
574 ckpt.save(&path).expect("save checkpoint");
575
576 let loaded =
577 super::super::checkpoint::GradientCheckpoint::load(&path).expect("load checkpoint");
578
579 assert_eq!(loaded.step, 42);
580 assert_eq!(loaded.loss_cid.as_deref(), Some("cid_loss_xyz"));
581 assert_eq!(
582 loaded.optimizer_state.get("adam_m").map(|v| v.as_slice()),
583 Some([1u8, 2, 3].as_slice())
584 );
585 assert_eq!(
586 loaded.optimizer_state.get("adam_v").map(|v| v.as_slice()),
587 Some([4u8, 5, 6].as_slice())
588 );
589 assert_eq!(loaded.graph.node_count(), 4);
590
591 let _ = std::fs::remove_dir_all(&dir);
593 }
594
595 #[test]
596 fn test_provenance_chain() {
597 let mut store = ComputationGraphStore::new();
601
602 let mut node_a = ComputationNode::new("load", vec![]);
603 node_a.output_cid = Some("cid_a".to_string());
604 store.add_node(node_a);
605
606 let mut node_b = ComputationNode::new("linear", vec!["cid_a".to_string()]);
607 node_b.output_cid = Some("cid_b".to_string());
608 store.add_node(node_b);
609
610 let mut node_c = ComputationNode::new("relu", vec!["cid_b".to_string()]);
611 node_c.output_cid = Some("cid_c".to_string());
612 store.add_node(node_c);
613
614 let chain = store.provenance_chain("cid_c");
615 assert_eq!(chain.len(), 3, "chain should include all 3 nodes");
616
617 assert_eq!(
619 chain
620 .last()
621 .expect("test: should succeed")
622 .output_cid
623 .as_deref(),
624 Some("cid_c")
625 );
626
627 assert!(chain
629 .first()
630 .expect("test: should succeed")
631 .input_cids
632 .is_empty());
633 }
634
635 #[test]
636 fn test_provenance_chain_unknown_cid() {
637 let store = ComputationGraphStore::new();
638 let chain = store.provenance_chain("unknown_cid");
639 assert!(chain.is_empty());
640 }
641
642 #[test]
643 fn test_find_consumers() {
644 let mut store = ComputationGraphStore::new();
645
646 let mut node_a = ComputationNode::new("op_a", vec!["shared_cid".to_string()]);
648 node_a.output_cid = Some("cid_out_a".to_string());
649 let id_a = node_a.id.clone();
650
651 let mut node_b = ComputationNode::new(
652 "op_b",
653 vec!["shared_cid".to_string(), "other_cid".to_string()],
654 );
655 node_b.output_cid = Some("cid_out_b".to_string());
656 let id_b = node_b.id.clone();
657
658 let node_c = ComputationNode::new("op_c", vec!["different_cid".to_string()]);
660
661 store.add_node(node_a);
662 store.add_node(node_b);
663 store.add_node(node_c);
664
665 let consumers = store.find_consumers("shared_cid");
666 assert_eq!(consumers.len(), 2);
667
668 let consumer_ids: Vec<&str> = consumers.iter().map(|n| n.id.as_str()).collect();
669 assert!(consumer_ids.contains(&id_a.as_str()));
670 assert!(consumer_ids.contains(&id_b.as_str()));
671
672 let consumers_other = store.find_consumers("other_cid");
674 assert_eq!(consumers_other.len(), 1);
675 assert_eq!(consumers_other[0].id, id_b);
676 }
677
678 #[test]
679 fn test_empty_graph_topological_order() {
680 let store = ComputationGraphStore::new();
681 let order = store.topological_order();
682 assert!(order.is_empty());
683 }
684
685 #[test]
686 fn test_single_node_graph() {
687 let mut store = ComputationGraphStore::new();
688 let node = ComputationNode::new("loss", vec![]);
689 let id = node.id.clone();
690 store.add_node(node);
691
692 let order = store.topological_order();
693 assert_eq!(order, vec![id]);
694 }
695
696 #[test]
697 fn test_graph_store_node_and_edge_counts() {
698 let (store, _, _, _, _) = build_linear_graph();
699 assert_eq!(store.node_count(), 4);
701 assert_eq!(store.edge_count(), 3);
702 }
703
704 #[test]
707 fn test_store_and_load_gradient_arrow() {
708 let mut graph = ComputationGraphStore::new();
709 let node = ComputationNode::new("matmul", vec![]);
710 let node_id = node.id.clone();
711 graph.add_node(node);
712
713 let grad_data: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
714 let shape = vec![2usize, 3];
715
716 let cid_str = graph
718 .store_gradient_as_arrow(&node_id, &grad_data, &shape)
719 .expect("store_gradient_as_arrow");
720
721 assert!(!cid_str.is_empty(), "CID string must not be empty");
723 let node = graph.get_node(&node_id).expect("node should exist");
724 assert_eq!(node.gradient_cid.as_deref(), Some(cid_str.as_str()));
725
726 use crate::arrow::{ArrowTensor, ArrowTensorStore};
728 let tensor = ArrowTensor::from_slice_f32("gradient", shape.clone(), &grad_data);
729 let mut store = ArrowTensorStore::new();
730 store.insert(tensor);
731 let ipc_bytes = store.to_bytes().expect("to_bytes");
732
733 let loaded = ComputationGraphStore::load_gradient_from_arrow(&ipc_bytes, &shape)
734 .expect("load_gradient_from_arrow");
735
736 assert_eq!(loaded, grad_data, "Loaded gradient must match original");
737 }
738
739 #[test]
740 fn test_gradient_shape_preserved() {
741 use crate::arrow::{ArrowTensor, ArrowTensorStore};
742
743 let shape = vec![2usize, 3, 4];
745 let numel: usize = shape.iter().product();
746 let grad_data: Vec<f32> = (0..numel).map(|i| i as f32 * 0.01).collect();
747
748 let tensor = ArrowTensor::from_slice_f32("gradient", shape.clone(), &grad_data);
749 let mut store = ArrowTensorStore::new();
750 store.insert(tensor);
751 let ipc_bytes = store.to_bytes().expect("to_bytes");
752
753 let loaded = ComputationGraphStore::load_gradient_from_arrow(&ipc_bytes, &shape)
754 .expect("load_gradient_from_arrow");
755
756 assert_eq!(loaded.len(), numel, "Element count must be preserved");
757 for (i, (&orig, &loaded_val)) in grad_data.iter().zip(loaded.iter()).enumerate() {
758 assert!(
759 (orig - loaded_val).abs() < 1e-6,
760 "Mismatch at index {}: {} vs {}",
761 i,
762 orig,
763 loaded_val
764 );
765 }
766 }
767
768 #[test]
769 fn test_gradient_shape_mismatch_error() {
770 use crate::arrow::{ArrowTensor, ArrowTensorStore};
771
772 let shape = vec![2usize, 3];
773 let grad_data: Vec<f32> = vec![1.0; 6];
774 let tensor = ArrowTensor::from_slice_f32("gradient", shape.clone(), &grad_data);
775 let mut store = ArrowTensorStore::new();
776 store.insert(tensor);
777 let ipc_bytes = store.to_bytes().expect("to_bytes");
778
779 let wrong_shape = vec![3usize, 2];
781 let result = ComputationGraphStore::load_gradient_from_arrow(&ipc_bytes, &wrong_shape);
782 assert!(
783 matches!(result, Err(GradientError::ShapeMismatch { .. })),
784 "Expected ShapeMismatch error, got {:?}",
785 result
786 );
787 }
788
789 #[test]
790 fn test_store_gradient_node_not_found() {
791 let mut graph = ComputationGraphStore::new();
792 let result = graph.store_gradient_as_arrow("nonexistent-node-id", &[1.0, 2.0], &[2]);
793 assert!(result.is_err(), "Should fail for nonexistent node");
794 }
795}