Skip to main content

ipfrs_tensorlogic/gradient/
computation_graph.rs

1//! CID-linked computation graph for gradient tracking.
2//!
3//! This module provides:
4//! - [`ComputationNode`] — a single node in the gradient computation DAG
5//! - [`ComputationGraphError`] — graph-specific error type
6//! - [`ComputationGraphStore`] — full DAG with topological ordering, provenance,
7//!   Arrow IPC gradient storage, and checkpointing
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12use super::GradientError;
13
14// ── ComputationNode ────────────────────────────────────────────────────────
15
16/// A single node in the gradient computation graph.
17///
18/// Each node represents one tensor operation and holds CIDs that link it to
19/// the content-addressed blocks produced and consumed during that operation.
20/// This makes the full backward-pass auditable via IPLD provenance.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ComputationNode {
23    /// Unique stable identifier for this node (UUID-v4 string)
24    pub id: String,
25    /// Operation name, e.g. `"matmul"`, `"relu"`, `"softmax"`
26    pub op: String,
27    /// CIDs of the input tensor blocks consumed by this operation
28    pub input_cids: Vec<String>,
29    /// CID of the output tensor block produced (None until the forward pass runs)
30    pub output_cid: Option<String>,
31    /// CID of the gradient tensor block (None until the backward pass runs)
32    pub gradient_cid: Option<String>,
33    /// Arbitrary string key/value annotations (shape, dtype, device, …)
34    pub metadata: HashMap<String, String>,
35}
36
37impl ComputationNode {
38    /// Create a new node with the given operation and input CIDs.
39    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    /// Attach an arbitrary metadata annotation.
51    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// ── ComputationGraphError ──────────────────────────────────────────────────
58
59/// Errors specific to the CID-linked computation graph.
60#[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// ── ComputationGraphStore ──────────────────────────────────────────────────
82
83/// CID-linked computation graph store.
84///
85/// Maintains the directed acyclic graph (DAG) of [`ComputationNode`]s that
86/// constitutes the forward pass.  After forward execution each node is linked
87/// to its output CID; after the backward pass each node carries a gradient CID.
88/// The graph can be checkpointed to bytes and restored for resume.
89#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct ComputationGraphStore {
91    /// All nodes, keyed by node-id
92    nodes: HashMap<String, ComputationNode>,
93    /// Directed edges: (producer_node_id, consumer_node_id)
94    edges: Vec<(String, String)>,
95}
96
97impl ComputationGraphStore {
98    /// Create an empty computation graph store.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Insert a node into the graph.
104    ///
105    /// Edges are automatically derived from `node.input_cids`: for each input
106    /// CID we look for an existing node whose `output_cid` matches, and record
107    /// a directed edge from that producer node to the new node.
108    pub fn add_node(&mut self, node: ComputationNode) {
109        // Build edges: any existing node whose output_cid is in node.input_cids
110        // is a producer of this node.
111        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    /// Record that the forward pass for `node_id` produced `output_cid`.
133    ///
134    /// After recording, edges to any downstream nodes that list this CID as an
135    /// input are added automatically.
136    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        // Wire up any downstream nodes that already declared this CID as input.
148        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    /// Record that the backward pass computed a gradient for `node_id`.
166    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    /// Return node IDs in topological order (Kahn's algorithm).
180    ///
181    /// Nodes with no predecessors come first; this is the correct execution
182    /// order for the forward pass (and reverse for backward).
183    pub fn topological_order(&self) -> Vec<String> {
184        // Build adjacency and in-degree maps.
185        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        // Collect sources (in-degree == 0).
203        let mut queue: std::collections::VecDeque<&str> = in_degree
204            .iter()
205            .filter(|(_, &deg)| deg == 0)
206            .map(|(&id, _)| id)
207            .collect();
208
209        // Sort queue for determinism.
210        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    // ── Arrow IPC gradient storage ────────────────────────────────────────
240
241    /// Serialize a gradient tensor as an Apache Arrow IPC block and store its
242    /// CID in the named node's `gradient_cid` field.
243    ///
244    /// The gradient data is wrapped in an [`crate::arrow::ArrowTensorStore`]
245    /// as a single f32 column named `"gradient"`.  Shape metadata is embedded
246    /// in the Arrow field metadata so it survives a round-trip through
247    /// [`Self::load_gradient_from_arrow`].
248    ///
249    /// The CID is computed as a DAG-CBOR CID (SHA-256) over the raw Arrow IPC
250    /// bytes, matching the convention used throughout `ipld_codec`.
251    ///
252    /// # Returns
253    ///
254    /// The CID string that was recorded on the node.
255    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        // Encode shape into a metadata key so it survives IPC round-trips.
265        let shape_str = shape
266            .iter()
267            .map(|d| d.to_string())
268            .collect::<Vec<_>>()
269            .join(",");
270
271        // Build an ArrowTensor for the gradient data; embed shape in metadata.
272        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        // Compute a DAG-CBOR CID over the raw Arrow IPC bytes.
286        let cid = CidBuilder::new()
287            .codec(0x71) // DAG-CBOR codec
288            .build(&ipc_bytes)
289            .map_err(|e| GradientError::InvalidGradient(format!("CID computation error: {e}")))?;
290        let cid_str = cid.to_string();
291
292        // Record gradient CID on the node.
293        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    /// Decode gradient data from raw Arrow IPC block bytes.
303    ///
304    /// Validates that the decoded tensor shape matches `expected_shape` before
305    /// returning the f32 values.  Pass an empty `expected_shape` slice to skip
306    /// shape validation.
307    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        // Validate shape if provided.
323        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    /// Serialize the entire graph to JSON bytes for checkpointing.
340    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    /// Restore a graph from checkpoint bytes produced by [`Self::checkpoint`].
346    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    /// Return all nodes that list `cid` as one of their input CIDs.
352    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    /// Return the chain of nodes whose `output_cid` led to `output_cid` being
360    /// produced, ordered from earliest producer to the node that emitted it.
361    ///
362    /// This performs a depth-first traversal of the reverse DAG starting from
363    /// the node that owns `output_cid`.
364    pub fn provenance_chain(&self, output_cid: &str) -> Vec<&ComputationNode> {
365        // Find the node that produced this CID.
366        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        // Walk backwards through input CIDs using a stack (DFS).
376        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        // Reverse so the chain runs earliest → latest.
398        chain.reverse();
399        chain
400    }
401
402    /// Immutable access to a node by id.
403    pub fn get_node(&self, node_id: &str) -> Option<&ComputationNode> {
404        self.nodes.get(node_id)
405    }
406
407    /// Number of nodes currently in the graph.
408    pub fn node_count(&self) -> usize {
409        self.nodes.len()
410    }
411
412    /// Number of edges currently in the graph.
413    pub fn edge_count(&self) -> usize {
414        self.edges.len()
415    }
416
417    /// Iterate over all nodes (order is unspecified).
418    pub fn nodes(&self) -> impl Iterator<Item = &ComputationNode> {
419        self.nodes.values()
420    }
421}
422
423// ── Tests ──────────────────────────────────────────────────────────────────
424
425#[cfg(test)]
426mod computation_graph_tests {
427    use super::*;
428
429    // Helper: build a linear graph  input → matmul → relu → output
430    fn build_linear_graph() -> (ComputationGraphStore, String, String, String, String) {
431        let mut store = ComputationGraphStore::new();
432
433        // input node: no inputs, produces cid_a
434        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        // matmul node: consumes cid_a, produces cid_b
440        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        // relu node: consumes cid_b, produces cid_c
446        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        // output node: consumes cid_c, produces cid_d
452        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        // Verify ordering constraints: each node precedes its dependents.
493        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        // Record forward-pass output.
508        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        // Record backward-pass gradient.
521        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        // Use a unique temp directory to avoid collisions between parallel test runs.
570        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        // Cleanup.
592        let _ = std::fs::remove_dir_all(&dir);
593    }
594
595    #[test]
596    fn test_provenance_chain() {
597        // Graph: A → B → C  (linear pipeline)
598        // A produces cid_a, B consumes cid_a and produces cid_b,
599        // C consumes cid_b and produces cid_c.
600        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        // The last node in the chain should be the one that produced cid_c.
618        assert_eq!(
619            chain
620                .last()
621                .expect("test: should succeed")
622                .output_cid
623                .as_deref(),
624            Some("cid_c")
625        );
626
627        // The first node should be the source (no inputs).
628        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        // Two nodes share the same input CID "shared_cid".
647        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        // node_c does NOT consume "shared_cid".
659        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        // Querying for "other_cid" should return only node_b.
673        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        // 4 nodes, 3 edges (input→matmul, matmul→relu, relu→output)
700        assert_eq!(store.node_count(), 4);
701        assert_eq!(store.edge_count(), 3);
702    }
703
704    // ── Arrow IPC gradient storage tests ─────────────────────────────────────
705
706    #[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        // Store the gradient
717        let cid_str = graph
718            .store_gradient_as_arrow(&node_id, &grad_data, &shape)
719            .expect("store_gradient_as_arrow");
720
721        // CID must be non-empty and recorded on the node
722        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        // Now encode and decode via Arrow IPC
727        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        // 3-D tensor: shape [2, 3, 4]
744        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        // Requesting wrong shape should fail
780        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}